Add automatic nearby discovery and simplify device presence UI

This commit is contained in:
2026-10-12 17:53:10 +09:00
parent 7e9bb1ec36
commit 5f431ee153
23 changed files with 1148 additions and 106 deletions

View File

@@ -34,6 +34,7 @@ import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import { createNearbySearch } from './nearbySearch';
import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme';
import {
@@ -41,7 +42,12 @@ import {
account,
connected,
discoveryEnabled,
locationSettings,
publishLocation,
clearLocation,
reserveLocationAcceptance,
peers,
pendingPeers,
requests,
items,
spaces,
@@ -97,6 +103,14 @@ const receiving = computed(() =>
r.transferStatus !== 'failed',
),
);
createNearbySearch({
connected,
device,
spaces,
settings: locationSettings,
publish: publishLocation,
clear: clearLocation,
});
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
const blockedLinks = receiver.blockedLinks;
watch([requests, items], ([currentRequests, currentItems]) => {
@@ -105,7 +119,10 @@ watch([requests, items], ([currentRequests, currentItems]) => {
onUnmounted(receiver.dispose);
async function receiveAction(r, action) {
if (busy.value) return;
if (action === 'accept') receiver.reserve(r);
if (action === 'accept') {
reserveLocationAcceptance(r.id);
receiver.reserve(r);
}
await act(async () => {
try {
await requestAction(r.id, action);
@@ -136,8 +153,29 @@ const connectionCards = computed(() => {
});
}
}
for (const id of pendingPeers.value)
if (!cards.has(id)) cards.set(id, { peer: { id }, skeleton: true });
return [...cards.values()];
});
function leaveDevice(el) {
const { width, height } = el.getBoundingClientRect();
Object.assign(el.style, {
left: `${el.offsetLeft}px`,
top: `${el.offsetTop}px`,
width: `${width}px`,
height: `${height}px`,
});
}
function deviceLabel(peer) {
return /^(Android|iPhone|iPad|Mac|Windows|Linux|기기)$/i.test(peer.name) && peer.info?.model
? peer.info.model
: peer.name;
}
function deviceDetail(peer) {
return [peer.info?.manufacturer, peer.info?.model !== deviceLabel(peer) ? peer.info?.model : '']
.filter(Boolean)
.join(' · ');
}
async function toggleDeviceMenu(id) {
if (deviceMenu.value === id) {
deviceMenu.value = null;
@@ -534,9 +572,14 @@ async function spaceAction(space, action, memberId) {
<p v-if="!connected && !loading" class="reconnect-note" role="status">
연결을 복구하고 있어요.
</p>
<div v-if="loading" class="loading-state">
<LoaderCircle class="spin" :size="24" />
<p>기기를 연결하고 있어요.</p>
<div
v-if="loading"
class="initial-device-loading"
role="status"
aria-label="연결 정보 확인 중"
>
<div class="peer-card peer-skeleton"><i></i><span></span><small></small></div>
<p>연결 정보를 확인하고 있어요.</p>
</div>
<template v-else>
<Teleport to="body">
@@ -638,47 +681,32 @@ async function spaceAction(space, action, memberId) {
</Teleport>
<template v-if="tab === 'devices'">
<section class="devices-stage">
<div class="stage-heading">
<h1>어디로 보낼까요?</h1>
<p class="discovery-status" role="status">
<LoaderCircle
v-if="connected && device?.discoverable && (spaces.length || discoveryEnabled)"
class="spin"
:size="13"
/>
{{
!connected
? '연결을 확인하고 있어요.'
: spaces.length
? '방 안의 기기를 확인하고 있어요.'
: !device?.discoverable
? '발견 허용이 꺼져 있어요.'
: discoveryEnabled
? '주변 기기를 계속 찾고 있어요.'
: '주변 검색 설정이 필요해요. QR로 연결할 수 있어요.'
}}
</p>
</div>
<section class="nearby-section">
<div class="section-heading">
<div>
<h2>
{{ spaces.length ? '방 안의 기기' : '주변 기기' }}
<span>{{ connectionCards.length.toString().padStart(2, '0') }}</span>
</h2>
</div>
<button class="icon-button" @click="act(refresh)" aria-label="기기 목록 새로고침">
<RefreshCw :size="17" />
</button>
</div>
<p v-if="!connectionCards.length" class="empty-device-hint">
{{
device?.discoverable
? '상대도 hop.을 열거나 QR로 연결해 주세요.'
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
}}
</p>
<div
<div v-if="!connectionCards.length" class="empty-devices">
<Monitor :size="30" stroke-width="1.4" />
<h3>아직 연결된 기기가 없어요.</h3>
<p>
{{
device?.discoverable
? '다른 기기에서 hop.을 열거나 QR로 연결해 보세요.'
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
}}
</p>
</div>
<TransitionGroup
tag="div"
name="device"
@before-leave="leaveDevice"
class="peer-grid"
:class="`device-count-${connectionCards.length + (canInvite ? 1 : 0)}`"
v-if="connectionCards.length || canInvite"
@@ -689,7 +717,16 @@ async function spaceAction(space, action, memberId) {
class="peer-entry"
data-device-menu
>
<div
v-if="card.skeleton"
class="peer-card peer-skeleton"
role="status"
aria-label="연결 정보 확인 중"
>
<i></i><span></span><small></small>
</div>
<button
v-else
class="peer-card"
:class="{ selected: selected?.id === card.peer.id, pending: card.pending }"
:disabled="card.pending"
@@ -702,11 +739,20 @@ async function spaceAction(space, action, memberId) {
><template v-if="card.pending">승인 대기</template
><template v-else><i></i>온라인</template></span
></span
><strong>{{ card.peer.name }}</strong
><strong>{{ deviceLabel(card.peer) }}</strong
><span v-if="deviceDetail(card.peer)" class="peer-model">{{
deviceDetail(card.peer)
}}</span
><span class="peer-code">#{{ card.peer.code }}</span
><span class="peer-bottom"
><span
>{{ card.peer.sources.includes('network') ? '같은 네트워크' : '연결 공간' }}
>{{
card.peer.sources.includes('network')
? '같은 네트워크'
: card.peer.sources.includes('nearby')
? '근처 기기'
: '연결 공간'
}}
<span v-if="isHost(card.peer.id)" class="host-badge">HOST</span></span
><span v-if="!card.pending" class="peer-select"
><Check v-if="selected?.id === card.peer.id" :size="14" /><ArrowUpRight
@@ -714,7 +760,7 @@ async function spaceAction(space, action, memberId) {
:size="14" /></span
></span></button
><button
v-if="managedSpaces(card.peer.id).length"
v-if="!card.skeleton && managedSpaces(card.peer.id).length"
:id="`device-menu-toggle-${card.peer.id}`"
class="icon-button device-menu-toggle"
:aria-label="`${card.peer.name} 기기 더보기`"
@@ -759,6 +805,7 @@ async function spaceAction(space, action, memberId) {
</div>
<button
v-if="canInvite"
key="qr-connect"
class="qr-connect-card"
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
@click="modal = 'connect'"
@@ -768,24 +815,7 @@ async function spaceAction(space, action, memberId) {
}}</strong
><span>다른 기기 연결하기</span>
</button>
</div>
<div v-else class="empty-peers">
<div class="orbit-icon"><Radio :size="35" stroke-width="1.2" /></div>
<h3>
{{ spaces.length ? '기기 참여를 기다리고 있어요.' : '상대도 hop.을 열어주세요.' }}
</h3>
<p v-if="!spaces.length">다른 네트워크라면 QR이나 코드로 연결하세요.</p>
<button
v-if="
!spaces.length ||
(invite?.space.id === spaces[0]?.id && invite.space.expiresAt > now)
"
class="button room-connect"
@click="modal = 'connect'"
>
<QrCode :size="17" />{{ spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결' }}
</button>
</div>
</TransitionGroup>
</section>
<div v-if="spaces.length" class="connection-actions">
<button
@@ -840,11 +870,11 @@ async function spaceAction(space, action, memberId) {
class="modal send-dialog"
role="dialog"
aria-modal="true"
:aria-label="`${selected.name}에 보내기`"
:aria-label="`${deviceLabel(selected)}에 보내기`"
@keydown.tab="trapSendFocus"
>
<header>
<h2>{{ selected.name }}에 보내기</h2>
<h2>{{ deviceLabel(selected) }}에 보내기</h2>
<button
class="icon-button"
:disabled="busy"
@@ -908,9 +938,7 @@ async function spaceAction(space, action, memberId) {
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
</p>
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
<p class="discovery-status">
<LoaderCircle class="spin" :size="14" />기기 참여를 기다리고 있어요.
</p>
<p class="discovery-status">기기 참여를 기다리고 있어요.</p>
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
<p v-if="localOnlyQr" class="local-qr-note">
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의

View File

@@ -1,3 +1,4 @@
import { readDeviceInfo } from './deviceInfo';
import { createRefreshQueue } from './refreshQueue';
import { ref } from 'vue';
import { io } from 'socket.io-client';
@@ -23,7 +24,22 @@ export const device = ref(null),
spaces = ref([]),
discoveryEnabled = ref(false),
error = ref('');
export const pendingPeers = ref([]);
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
export async function publishLocation(position) {
if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.');
const result = await socket.timeout(5000).volatile.emitWithAck('location:update', position);
if (!result?.ok) throw Error(result?.message ?? '위치를 확인하지 못했습니다.');
}
export function clearLocation() {
if (socket?.connected) socket.emit('location:clear');
}
export function reserveLocationAcceptance(requestId) {
if (socket?.connected) socket.emit('location:accept', { requestId });
}
let socket;
let reauthorizing;
const drafts = new Map();
@@ -58,13 +74,14 @@ export async function bootstrap() {
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
else await init();
await refreshAccount();
deviceInfo = await readDeviceInfo();
connect();
await refresh();
startReconciliation();
}
function connect() {
socket?.disconnect();
socket = io({ auth: { csrfToken: csrfToken.value }, reconnection: true });
socket = io({ auth: { csrfToken: csrfToken.value, deviceInfo }, reconnection: true });
socket.on('connect', () => {
connected.value = true;
void refresh().catch(showError);
@@ -91,7 +108,7 @@ function connect() {
csrfToken.value = session.csrfToken;
sessionExpiresAt.value = session.expiresAt;
if (socket) {
socket.auth = { csrfToken: csrfToken.value };
socket.auth = { csrfToken: csrfToken.value, deviceInfo };
socket.connect();
}
})()
@@ -100,10 +117,20 @@ function connect() {
reauthorizing = undefined;
});
});
socket.on('device:updated', (event) => {
const known = new Set(peers.value.map((peer) => peer.id));
pendingPeers.value = (event?.devices ?? [])
.filter((peer) => !known.has(peer.id))
.map((peer) => peer.id);
clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => {
pendingPeers.value = [];
}, 5000);
void refresh().catch(showError);
});
for (const event of [
'device:online',
'device:offline',
'device:updated',
'space:updated',
'request:created',
'request:updated',
@@ -119,7 +146,15 @@ export const refresh = createRefreshQueue(async () => {
const snapshot = await api('/device/snapshot');
device.value = snapshot.device;
peers.value = snapshot.devices;
pendingPeers.value = pendingPeers.value.filter(
(id) => !snapshot.devices.some((peer) => peer.id === id),
);
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
locationSettings.value = snapshot.locationDiscovery ?? {
enabled: false,
radiusMeters: 100,
refreshMs: 120000,
};
requests.value = snapshot.requests;
items.value = snapshot.items;
nextCursor.value = snapshot.nextCursor;

View File

@@ -0,0 +1,24 @@
// Model hints are optional; user-defined names always remain intact.
export async function readDeviceInfo(nav = navigator) {
try {
const hint = await Promise.race([
nav.userAgentData?.getHighEntropyValues(['model']),
new Promise((resolve) => setTimeout(resolve, 1000)),
]);
const model = String(hint?.model ?? '')
.trim()
.slice(0, 80);
const platform = String(hint?.platform ?? nav.userAgentData?.platform ?? '')
.trim()
.slice(0, 40);
// Conservative manufacturer inference, not a browser-provided manufacturer field.
const manufacturer = /^SM-|^GT-/i.test(model)
? 'Samsung'
: /^Pixel(?: |$)/i.test(model)
? 'Google'
: '';
return { model, platform, manufacturer };
} catch {
return {};
}
}

View File

@@ -0,0 +1,123 @@
import { ref, watch, onUnmounted } from 'vue';
// Discovery is automatic. The browser owns the location permission prompt.
export function createNearbySearch({ connected, device, spaces, settings, publish, clear }) {
const active = ref(false),
locating = ref(false);
let visible = document.visibilityState === 'visible';
let revision = 0,
timer,
permission,
permissionQuery,
disposed = false,
blocked = false;
const ready = () =>
visible &&
connected.value &&
settings.value.enabled &&
device.value?.discoverable &&
!spaces.value.length;
function pause() {
const hadPosition = active.value || locating.value;
revision++;
clearTimeout(timer);
active.value = false;
locating.value = false;
if (hadPosition) clear();
}
async function readPermission() {
if (permission) return permission.state;
if (!navigator.permissions?.query) return null;
if (!permissionQuery)
permissionQuery = navigator.permissions
.query({ name: 'geolocation' })
.then((status) => {
if (disposed) return null;
permission = status;
status.onchange = () => {
blocked = status.state !== 'granted';
if (blocked) pause();
else void resume();
};
return status.state;
})
.catch(() => null)
.finally(() => {
permissionQuery = undefined;
});
return permissionQuery;
}
async function locate() {
if (!ready() || blocked || locating.value || disposed) return;
const current = ++revision;
locating.value = true;
clearTimeout(timer);
try {
if (!window.isSecureContext || !navigator.geolocation) return;
const position = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 0,
}),
);
if (current !== revision || !ready() || disposed) return;
await publish({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
});
if (current !== revision || !ready() || disposed) return;
active.value = true;
} catch (error) {
if (current !== revision) return;
active.value = false;
clear();
// Denied permissions must not trigger repeated prompts or affect file transfers.
if (error.code === 1) blocked = true;
} finally {
if (current === revision) {
locating.value = false;
if (!blocked && ready() && !disposed && window.isSecureContext && navigator.geolocation)
timer = setTimeout(locate, settings.value.refreshMs);
}
}
}
async function resume() {
if (!ready() || blocked || active.value || locating.value || disposed) return;
const current = revision;
const state = await readPermission();
if (disposed || current !== revision || !ready()) return;
if (state === 'denied') {
blocked = true;
return;
}
void locate();
}
function visibility() {
visible = document.visibilityState === 'visible';
if (visible) void resume();
else pause();
}
document.addEventListener('visibilitychange', visibility);
watch(
[
connected,
() => device.value?.discoverable,
() => spaces.value.length,
() => settings.value.enabled,
],
() => {
if (ready()) void resume();
else pause();
},
{ immediate: true },
);
onUnmounted(() => {
disposed = true;
pause();
document.removeEventListener('visibilitychange', visibility);
if (permission) permission.onchange = null;
});
return { active, locating };
}

View File

@@ -2604,15 +2604,6 @@ a.button {
.devices-stage {
margin: 34px auto 0;
}
.stage-heading {
text-align: center;
margin-bottom: 32px;
}
.stage-heading h1 {
font-size: 26px;
letter-spacing: -0.04em;
font-weight: 600;
}
.devices-stage .section-heading h2 {
font-size: 13px;
font-weight: 500;
@@ -2725,12 +2716,6 @@ a.button {
.devices-stage {
margin-top: 26px;
}
.stage-heading {
margin-bottom: 26px;
}
.stage-heading h1 {
font-size: 23px;
}
.devices-stage .peer-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@@ -2780,7 +2765,7 @@ a.button {
}
@media (max-width: 639px) {
.devices-stage .peer-grid.device-count-1 {
grid-template-columns: minmax(0, 190px);
grid-template-columns: minmax(0, 1fr);
}
}
.devices-stage .empty-peers {
@@ -2983,12 +2968,6 @@ a.button {
.devices-stage {
margin-top: 28px;
}
.stage-heading {
margin-bottom: 24px;
}
.stage-heading h1 {
font-size: 21px;
}
.device-footer {
margin-top: 38px;
padding: 20px 0;
@@ -3090,9 +3069,6 @@ a.button {
min-height: 166px;
padding: 18px 12px;
}
.stage-heading h1 {
font-size: 20px;
}
}
.footer-device-summary .footer-device-code {
font-size: 10px;
@@ -3154,3 +3130,103 @@ a.button {
height: 520px;
}
}
/* Presence changes animate once; idle discovery has no running animation. */
.devices-stage .peer-grid {
position: relative;
}
.devices-stage .peer-model {
color: var(--muted);
font-size: 10px;
margin-top: 3px;
overflow-wrap: anywhere;
}
.devices-stage .empty-devices {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.devices-stage .empty-devices h3 {
font-size: 17px;
margin: 0;
}
.devices-stage .empty-devices p {
font-size: 13px;
color: var(--muted);
margin: 0;
}
.initial-device-loading {
padding: 32px 0;
max-width: 360px;
margin: auto;
text-align: center;
color: var(--muted);
font-size: 13px;
}
.peer-skeleton {
min-height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
pointer-events: none;
}
.peer-skeleton i,
.peer-skeleton span,
.peer-skeleton small {
display: block;
background: var(--line);
border-radius: 8px;
}
.peer-skeleton i {
width: 42px;
height: 42px;
}
.peer-skeleton span {
width: 65%;
height: 16px;
}
.peer-skeleton small {
width: 42%;
height: 10px;
}
.device-enter-active {
transition:
opacity 180ms ease,
transform 180ms ease;
}
.device-enter-from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
.device-move {
transition: transform 220ms ease;
}
.device-leave-active {
position: absolute;
pointer-events: none;
animation: device-pop-away 240ms ease-in forwards;
}
@keyframes device-pop-away {
25% {
transform: scale(1.035);
}
100% {
opacity: 0;
transform: translateY(-6px) scale(0.82);
}
}
@media (prefers-reduced-motion: reduce) {
.device-enter-active,
.device-move {
transition: none;
}
.device-leave-active {
animation: none;
opacity: 0;
}
}