Add automatic nearby discovery and simplify device presence UI
This commit is contained in:
@@ -11,6 +11,8 @@ DATABASE_PATH=/data/database/hop.sqlite
|
||||
UPLOAD_DIR=/data/uploads
|
||||
# 실제 접속 IP 전달을 검증한 후에만 활성화합니다.
|
||||
SAME_NETWORK_DISCOVERY_ENABLED=false
|
||||
LOCATION_DISCOVERY_ENABLED=true
|
||||
LOCATION_DISCOVERY_RADIUS_METERS=100
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
CLIENT_IP_SOURCE=forwarded
|
||||
PROXY_DIAGNOSTIC_TOKEN=
|
||||
|
||||
@@ -96,3 +96,5 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10
|
||||
이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 모바일 상단 설치 버튼으로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다.
|
||||
|
||||
로그인 계정의 임시 저장과 받은 파일을 합쳐 개인별 2GiB로 제한합니다. 같은 원본은 한 계정에서 한 번 계산하고 다른 계정의 한도와 분리합니다. 한도가 차면 파일 수신·다운로드는 계속하고 받은 항목에 저장하지 않았다는 안내를 표시합니다. 익명 업로드는 기기 세션당 2GiB, 서버 전체 20GiB이며 화면에는 내역을 표시하지 않습니다.
|
||||
|
||||
근처 위치 검색과 기종 구분은 [기기 발견 문서](docs/NEARBY_DISCOVERY.md)를 참고하세요. 양쪽 기기가 위치를 허용하면 약 100m 내 기기를 표시하며, 대기 화면은 반복 목록 검색을 하지 않습니다.
|
||||
|
||||
@@ -110,6 +110,72 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
socket.join(`account:${socket.data.accountId}`);
|
||||
socket.join(`account-session:${socket.data.accountSessionId}`);
|
||||
}
|
||||
const currentGuest = () => {
|
||||
const guest = service.guest(app.parseCookie(socket.request.headers.cookie ?? '').hop_guest);
|
||||
if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.');
|
||||
return guest!;
|
||||
};
|
||||
socket.on('location:update', (input, acknowledge) => {
|
||||
try {
|
||||
const guest = currentGuest();
|
||||
service.limiter.hit(`location-guest:${guest.id}`, 6);
|
||||
service.limiter.hit(
|
||||
`location-ip:${socket.data.ip ?? socket.request.socket.remoteAddress}`,
|
||||
120,
|
||||
);
|
||||
if (!cfg.locationDiscoveryEnabled)
|
||||
fail(409, 'LOCATION_DISABLED', '근처 검색을 사용할 수 없습니다.');
|
||||
if (!service.device(guest.id).discoverable || service.activeRoom(guest.id))
|
||||
fail(409, 'LOCATION_UNAVAILABLE', '현재 기기 상태에서는 근처 검색을 사용할 수 없습니다.');
|
||||
const position = z
|
||||
.object({
|
||||
latitude: z.number().finite().min(-90).max(90),
|
||||
longitude: z.number().finite().min(-180).max(180),
|
||||
accuracy: z.number().finite().min(0).max(100000),
|
||||
})
|
||||
.strict()
|
||||
.parse(input);
|
||||
if (position.accuracy > Math.max(100, cfg.locationRadiusMeters))
|
||||
fail(
|
||||
409,
|
||||
'LOCATION_INACCURATE',
|
||||
'위치 오차가 커서 근처 검색을 켜지 못했어요. 다시 확인하거나 QR로 연결해 주세요.',
|
||||
);
|
||||
service.nearby.set(guest.id, socket.id, position);
|
||||
if (typeof acknowledge === 'function') acknowledge({ ok: true });
|
||||
} catch (error) {
|
||||
if (typeof acknowledge === 'function')
|
||||
acknowledge({
|
||||
ok: false,
|
||||
code: error instanceof ApiError ? error.code : 'LOCATION_INVALID',
|
||||
message: error instanceof ApiError ? error.message : '위치를 확인하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.on('location:accept', (input) => {
|
||||
try {
|
||||
const guest = currentGuest();
|
||||
service.limiter.hit(`location-accept:${guest.id}`, 20);
|
||||
const requestId = z.object({ requestId: z.uuid() }).strict().parse(input).requestId;
|
||||
const request = service.request(guest, requestId);
|
||||
if (
|
||||
request.recipientGuestId === guest.id &&
|
||||
request.status === 'pending' &&
|
||||
request.expiresAt > Date.now() &&
|
||||
cfg.locationDiscoveryEnabled &&
|
||||
service.nearby.nearby(request.senderGuestId, guest.id) &&
|
||||
service.canReceive(request.senderGuestId, guest.id)
|
||||
)
|
||||
service.locationAcceptances.set(requestId, {
|
||||
guestId: guest.id,
|
||||
until: Date.now() + 15000,
|
||||
});
|
||||
} catch {
|
||||
/* Invalid messages cannot grant an acceptance. */
|
||||
}
|
||||
});
|
||||
socket.on('location:clear', () => service.nearby.clear(socket.id));
|
||||
socket.on('disconnect', () => service.nearby.clear(socket.id));
|
||||
});
|
||||
const auth = (req: FastifyRequest) => {
|
||||
const guest = service.guest(req.cookies.hop_guest);
|
||||
@@ -162,7 +228,10 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
.header('X-Content-Type-Options', 'nosniff')
|
||||
.header('Referrer-Policy', 'no-referrer')
|
||||
.header('X-Frame-Options', 'DENY')
|
||||
.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), bluetooth=()');
|
||||
.header(
|
||||
'Permissions-Policy',
|
||||
`camera=(), microphone=(), geolocation=${cfg.locationDiscoveryEnabled ? '(self)' : '()'}, bluetooth=()`,
|
||||
);
|
||||
reply.header(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
|
||||
@@ -262,6 +331,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
device: service.device(g.id),
|
||||
devices: service.peers(g),
|
||||
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
|
||||
locationDiscovery: {
|
||||
enabled: cfg.locationDiscoveryEnabled,
|
||||
radiusMeters: cfg.locationRadiusMeters,
|
||||
refreshMs: 120000,
|
||||
},
|
||||
requests: r,
|
||||
items: list.items,
|
||||
nextCursor: list.nextCursor,
|
||||
@@ -868,12 +942,19 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
});
|
||||
}
|
||||
let storageCleanup: Promise<void> | undefined;
|
||||
const cleanup = setInterval(() => {
|
||||
service.cleanup();
|
||||
service.auth.enforceSessions();
|
||||
const cleanStorage = () => {
|
||||
storageCleanup = service.storage
|
||||
.cleanup()
|
||||
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
|
||||
};
|
||||
cleanStorage();
|
||||
const storageTimer = setInterval(cleanStorage, 60000);
|
||||
storageTimer.unref();
|
||||
const cleanup = setInterval(() => {
|
||||
for (const [id, permission] of service.locationAcceptances)
|
||||
if (permission.until <= Date.now()) service.locationAcceptances.delete(id);
|
||||
service.cleanup();
|
||||
service.auth.enforceSessions();
|
||||
}, 1000);
|
||||
cleanup.unref();
|
||||
app.addHook('preClose', async () => {
|
||||
@@ -882,7 +963,9 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
clearInterval(cleanup);
|
||||
clearInterval(storageTimer);
|
||||
presence.close();
|
||||
service.nearby.close();
|
||||
service.storage.close();
|
||||
await storageCleanup;
|
||||
await new Promise<void>((resolve) => io.close(() => resolve()));
|
||||
|
||||
@@ -7,6 +7,8 @@ export type Config = {
|
||||
sessionSecret: string;
|
||||
discoverySecret: string;
|
||||
discoveryEnabled: boolean;
|
||||
locationDiscoveryEnabled: boolean;
|
||||
locationRadiusMeters: number;
|
||||
trustedProxies: string[];
|
||||
clientIpSource: 'forwarded' | 'cloudflare';
|
||||
proxyDiagnosticToken?: string;
|
||||
@@ -63,6 +65,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
throw Error('Invalid CLIENT_IP_SOURCE');
|
||||
if (env.PROXY_DIAGNOSTIC_TOKEN && env.PROXY_DIAGNOSTIC_TOKEN.length < 32)
|
||||
throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters');
|
||||
if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000)
|
||||
throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000');
|
||||
return {
|
||||
production,
|
||||
appUrl,
|
||||
@@ -74,6 +78,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
env.SAME_NETWORK_DISCOVERY_ENABLED === undefined
|
||||
? !production
|
||||
: env.SAME_NETWORK_DISCOVERY_ENABLED === 'true',
|
||||
locationDiscoveryEnabled: env.LOCATION_DISCOVERY_ENABLED !== 'false',
|
||||
locationRadiusMeters: num('LOCATION_DISCOVERY_RADIUS_METERS', 100),
|
||||
trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
|
||||
94
apps/server/src/nearby.js
Normal file
94
apps/server/src/nearby.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Consent-based location candidates. Coordinates stay in this process only.
|
||||
export function metersBetween(a, b) {
|
||||
const rad = Math.PI / 180;
|
||||
const latitude = (b.latitude - a.latitude) * rad;
|
||||
const longitude = (b.longitude - a.longitude) * rad;
|
||||
const value =
|
||||
Math.sin(latitude / 2) ** 2 +
|
||||
Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(longitude / 2) ** 2;
|
||||
return 6371000 * 2 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, value))));
|
||||
}
|
||||
export class NearbyLocations {
|
||||
constructor(radius, changed) {
|
||||
this.radius = radius;
|
||||
this.changed = changed;
|
||||
this.points = new Map();
|
||||
this.guests = new Map();
|
||||
this.timers = new Map();
|
||||
}
|
||||
nearby(a, b) {
|
||||
const now = Date.now();
|
||||
for (const left of this.guests.get(a)?.values() ?? []) {
|
||||
if (left.until <= now) continue;
|
||||
for (const right of this.guests.get(b)?.values() ?? []) {
|
||||
if (right.until > now && metersBetween(left, right) <= this.radius) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
neighbors(id) {
|
||||
return new Set(
|
||||
[...this.guests.keys()].filter((other) => other !== id && this.nearby(id, other)),
|
||||
);
|
||||
}
|
||||
notify(id, before) {
|
||||
const after = this.neighbors(id);
|
||||
const delta = [...before]
|
||||
.filter((other) => !after.has(other))
|
||||
.concat([...after].filter((other) => !before.has(other)));
|
||||
if (delta.length) this.changed([id, ...delta]);
|
||||
}
|
||||
set(guestId, socketId, position) {
|
||||
const before = this.neighbors(guestId);
|
||||
clearTimeout(this.timers.get(socketId));
|
||||
const point = {
|
||||
guestId,
|
||||
latitude: Math.round(position.latitude * 10000) / 10000,
|
||||
longitude: Math.round(position.longitude * 10000) / 10000,
|
||||
until: Date.now() + 150000,
|
||||
};
|
||||
this.points.set(socketId, point);
|
||||
const sockets = this.guests.get(guestId) ?? new Map();
|
||||
sockets.set(socketId, point);
|
||||
this.guests.set(guestId, sockets);
|
||||
const timer = setTimeout(() => this.clear(socketId), 150000);
|
||||
timer.unref();
|
||||
this.timers.set(socketId, timer);
|
||||
this.notify(guestId, before);
|
||||
}
|
||||
clear(socketId) {
|
||||
const point = this.points.get(socketId);
|
||||
if (!point) return;
|
||||
// Include expired relationships so their subscribers get removal events.
|
||||
const now = Date.now();
|
||||
const before = new Set(
|
||||
[...this.guests.keys()].filter(
|
||||
(other) =>
|
||||
other !== point.guestId &&
|
||||
[...(this.guests.get(point.guestId)?.values() ?? [])].some(
|
||||
(left) =>
|
||||
(left === point || left.until > now) &&
|
||||
[...(this.guests.get(other)?.values() ?? [])].some(
|
||||
(right) => right.until > now && metersBetween(left, right) <= this.radius,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
clearTimeout(this.timers.get(socketId));
|
||||
this.timers.delete(socketId);
|
||||
this.points.delete(socketId);
|
||||
const sockets = this.guests.get(point.guestId);
|
||||
sockets?.delete(socketId);
|
||||
if (!sockets?.size) this.guests.delete(point.guestId);
|
||||
this.notify(point.guestId, before);
|
||||
}
|
||||
clearGuest(id) {
|
||||
for (const socketId of this.guests.get(id)?.keys() ?? []) this.clear(socketId);
|
||||
}
|
||||
close() {
|
||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||
this.timers.clear();
|
||||
this.points.clear();
|
||||
this.guests.clear();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import type { Config } from './config.js';
|
||||
import { hmac, publicIp } from './security.js';
|
||||
export class Presence {
|
||||
private connections = new Map<string, Map<string, string | null>>();
|
||||
private deviceInfos = new Map<
|
||||
string,
|
||||
{ model: string; platform: string; manufacturer: string }
|
||||
>();
|
||||
info(id: string) {
|
||||
return this.deviceInfos.get(id);
|
||||
}
|
||||
private graceIps = new Map<string, Set<string | null>>();
|
||||
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
constructor(
|
||||
@@ -18,6 +25,24 @@ export class Presence {
|
||||
const sockets = this.connections.get(guestId) ?? new Map();
|
||||
sockets.set(socket.id, ip);
|
||||
this.connections.set(guestId, sockets);
|
||||
const hint = socket.handshake.auth.deviceInfo;
|
||||
if (hint && typeof hint === 'object') {
|
||||
const clean = (value: unknown, max: number) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, max)
|
||||
: '';
|
||||
const model = clean(hint.model, 80);
|
||||
// Infer only known prefixes; never trust a client-provided manufacturer label.
|
||||
const manufacturer = /^SM-|^GT-/i.test(model)
|
||||
? 'Samsung'
|
||||
: /^Pixel(?: |$)/i.test(model)
|
||||
? 'Google'
|
||||
: '';
|
||||
this.deviceInfos.set(guestId, { model, platform: clean(hint.platform, 40), manufacturer });
|
||||
}
|
||||
socket.join(`guest:${guestId}`);
|
||||
this.changed();
|
||||
socket.on('disconnect', () => this.disconnect(guestId, socket.id));
|
||||
@@ -30,6 +55,7 @@ export class Presence {
|
||||
this.graceIps.set(guestId, new Set([oldIp]));
|
||||
const timer = setTimeout(() => {
|
||||
this.connections.delete(guestId);
|
||||
this.deviceInfos.delete(guestId);
|
||||
this.graceIps.delete(guestId);
|
||||
this.timers.delete(guestId);
|
||||
this.changed();
|
||||
@@ -65,6 +91,7 @@ export class Presence {
|
||||
revoke(id: string) {
|
||||
this.io.in(`guest:${id}`).disconnectSockets(true);
|
||||
this.connections.delete(id);
|
||||
this.deviceInfos.delete(id);
|
||||
this.graceIps.delete(id);
|
||||
const timer = this.timers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
@@ -72,6 +99,7 @@ export class Presence {
|
||||
this.changed();
|
||||
}
|
||||
close() {
|
||||
this.deviceInfos.clear();
|
||||
for (const t of this.timers.values()) clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Auth } from './auth.js';
|
||||
import type { Accounts } from './accounts.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { ResumableUploads } from './resumable.js';
|
||||
import { NearbyLocations } from './nearby.js';
|
||||
import { Vault } from './vault.js';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
|
||||
@@ -25,6 +26,8 @@ const day = 86400000;
|
||||
export class Service {
|
||||
readonly limiter = new Limiter();
|
||||
presence!: Presence;
|
||||
nearby: NearbyLocations;
|
||||
readonly locationAcceptances = new Map<string, { guestId: string; until: number }>();
|
||||
auth!: Auth;
|
||||
accounts!: Accounts;
|
||||
io?: Server;
|
||||
@@ -37,6 +40,9 @@ export class Service {
|
||||
) {
|
||||
this.storage = new Storage(this);
|
||||
this.resumable = new ResumableUploads(this);
|
||||
this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) =>
|
||||
this.refresh(ids),
|
||||
);
|
||||
this.vault = new Vault(this);
|
||||
}
|
||||
guest(token: string | undefined) {
|
||||
@@ -56,7 +62,13 @@ export class Service {
|
||||
device(guestId: string): Device {
|
||||
const d = this.db.select().from(s.devices).where(eq(s.devices.guestId, guestId)).get();
|
||||
if (!d) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다.');
|
||||
return { id: d!.id, name: d!.name, code: d!.code, discoverable: d!.discoverable };
|
||||
return {
|
||||
id: d!.id,
|
||||
name: d!.name,
|
||||
code: d!.code,
|
||||
discoverable: d!.discoverable,
|
||||
info: this.presence?.info(guestId),
|
||||
};
|
||||
}
|
||||
deviceGuest(id: string) {
|
||||
return this.db.select().from(s.devices).where(eq(s.devices.id, id)).get();
|
||||
@@ -111,6 +123,7 @@ export class Service {
|
||||
.set({ ...input, lastSeen: Date.now() })
|
||||
.where(eq(s.devices.guestId, g.id))
|
||||
.run();
|
||||
if (input.discoverable === false) this.nearby.clearGuest(g.id);
|
||||
this.refresh();
|
||||
}
|
||||
reset(g: Guest) {
|
||||
@@ -215,7 +228,10 @@ export class Service {
|
||||
scope(a: string, b: string) {
|
||||
if (this.isBlocked(a, b)) return false;
|
||||
if (this.activeRoom(a) || this.activeRoom(b)) return this.sharedSpace(a, b);
|
||||
return this.presence.sameNetwork(a, b);
|
||||
return (
|
||||
this.presence.sameNetwork(a, b) ||
|
||||
(this.cfg.locationDiscoveryEnabled && this.nearby.nearby(a, b))
|
||||
);
|
||||
}
|
||||
restrictTransfers(leaving: string[] = []) {
|
||||
const active = this.db
|
||||
@@ -274,12 +290,18 @@ export class Service {
|
||||
? ['network' as const]
|
||||
: []),
|
||||
...(this.sharedSpace(g.id, id) ? ['space' as const] : []),
|
||||
...(!this.activeRoom(g.id) &&
|
||||
this.cfg.locationDiscoveryEnabled &&
|
||||
this.nearby.nearby(g.id, id)
|
||||
? ['nearby' as const]
|
||||
: []),
|
||||
],
|
||||
}));
|
||||
}
|
||||
refresh() {
|
||||
refresh(subset?: string[]) {
|
||||
if (!this.presence) return;
|
||||
for (const id of this.presence.ids()) {
|
||||
for (const id of subset ?? this.presence.ids()) {
|
||||
if (!this.presence.online(id)) continue;
|
||||
const g = this.db.select().from(s.guests).where(eq(s.guests.id, id)).get();
|
||||
if (!g || g.revokedAt || g.expiresAt <= Date.now()) {
|
||||
this.presence.revoke(id);
|
||||
@@ -384,6 +406,7 @@ export class Service {
|
||||
})
|
||||
.run();
|
||||
});
|
||||
this.nearby.clearGuest(g.id);
|
||||
this.restrictTransfers();
|
||||
this.refresh();
|
||||
return { space: this.space(g, id), code, token };
|
||||
@@ -431,6 +454,7 @@ export class Service {
|
||||
})
|
||||
.run();
|
||||
});
|
||||
this.nearby.clearGuest(g.id);
|
||||
this.restrictTransfers();
|
||||
this.refresh();
|
||||
return { space: this.space(g, row!.id) };
|
||||
@@ -638,7 +662,20 @@ export class Service {
|
||||
if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.');
|
||||
if (row.status !== 'pending' || row.expiresAt <= Date.now())
|
||||
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
|
||||
if (!this.canReceive(row.senderGuestId, g.id))
|
||||
const permission = this.locationAcceptances.get(id);
|
||||
const reserved =
|
||||
this.cfg.locationDiscoveryEnabled &&
|
||||
permission?.guestId === g.id &&
|
||||
permission.until > Date.now() &&
|
||||
!this.activeRoom(g.id) &&
|
||||
!this.activeRoom(row.senderGuestId) &&
|
||||
this.presence.active(g.id) &&
|
||||
this.presence.active(row.senderGuestId) &&
|
||||
this.device(g.id).discoverable &&
|
||||
this.device(row.senderGuestId).discoverable &&
|
||||
!this.isBlocked(row.senderGuestId, g.id);
|
||||
this.locationAcceptances.delete(id);
|
||||
if (!this.canReceive(row.senderGuestId, g.id) && !reserved)
|
||||
fail(409, 'TARGET_UNAVAILABLE', '현재 연결 상태에서 수락할 수 없습니다.');
|
||||
if (row.kind === 'file' && !this.vault.source(id)) {
|
||||
const sender = this.db
|
||||
|
||||
37
apps/server/test/device-info.test.js
Normal file
37
apps/server/test/device-info.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readDeviceInfo } from '../../web/src/deviceInfo.js';
|
||||
|
||||
test('모델 힌트가 없거나 거절된 브라우저도 정상 연결할 수 있다', async () => {
|
||||
assert.deepEqual(await readDeviceInfo({}), { model: '', platform: '', manufacturer: '' });
|
||||
assert.deepEqual(
|
||||
await readDeviceInfo({
|
||||
userAgentData: {
|
||||
getHighEntropyValues: async () => {
|
||||
throw Error('withheld');
|
||||
},
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
test('Android 모델을 구분하고 브라우저 브랜드를 제조사로 사용하지 않는다', async () => {
|
||||
const nav = {
|
||||
userAgentData: {
|
||||
getHighEntropyValues: async (hints) => {
|
||||
assert.deepEqual(hints, ['model']);
|
||||
return { model: 'SM-S938B', platform: 'Android', brands: [{ brand: 'Chrome' }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await readDeviceInfo(nav), {
|
||||
model: 'SM-S938B',
|
||||
platform: 'Android',
|
||||
manufacturer: 'Samsung',
|
||||
});
|
||||
nav.userAgentData.getHighEntropyValues = async () => ({
|
||||
model: 'Unknown phone',
|
||||
platform: 'Android',
|
||||
});
|
||||
assert.equal((await readDeviceInfo(nav)).manufacturer, '');
|
||||
});
|
||||
184
apps/server/test/nearby.test.js
Normal file
184
apps/server/test/nearby.test.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { io } from 'socket.io-client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fixture } from './helpers.js';
|
||||
import { metersBetween, NearbyLocations } from '../src/nearby.js';
|
||||
const point = { latitude: 37.5665, longitude: 126.978, accuracy: 20 };
|
||||
|
||||
test('거리 계산과 동의한 양쪽 좌표, 반경·만료·여러 탭·변경 알림을 검사한다', () => {
|
||||
const changes = [],
|
||||
locations = new NearbyLocations(100, (ids) => changes.push(ids));
|
||||
try {
|
||||
assert.equal(metersBetween(point, point), 0);
|
||||
assert(
|
||||
metersBetween({ latitude: 0, longitude: 179.9999 }, { latitude: 0, longitude: -179.9999 }) <
|
||||
30,
|
||||
);
|
||||
locations.set('a', 'a1', point);
|
||||
assert.equal(changes.length, 0, '혼자서는 불필요한 목록 갱신을 하지 않음');
|
||||
locations.set('b', 'b1', { ...point, latitude: point.latitude + 0.0004 });
|
||||
assert.equal(locations.nearby('a', 'b'), true);
|
||||
const before = changes.length;
|
||||
locations.set('b', 'b1', { ...point, latitude: point.latitude + 0.0004 });
|
||||
assert.equal(changes.length, before, '같은 위치를 갱신해도 목록 재전송 없음');
|
||||
locations.set('a', 'a2', point);
|
||||
locations.clear('a1');
|
||||
assert.equal(locations.nearby('a', 'b'), true);
|
||||
locations.clear('a2');
|
||||
assert.equal(locations.nearby('a', 'b'), false);
|
||||
locations.set('a', 'a1', point);
|
||||
locations.points.get('a1').until = Date.now() - 1;
|
||||
assert.equal(locations.nearby('a', 'b'), false);
|
||||
locations.clear('a1');
|
||||
assert(changes.at(-1).includes('b'));
|
||||
locations.set('a', 'a1', { ...point, latitude: point.latitude + 0.01 });
|
||||
assert.equal(locations.nearby('a', 'b'), false);
|
||||
} finally {
|
||||
locations.close();
|
||||
}
|
||||
});
|
||||
async function positioned(f, ip) {
|
||||
const client = await f.create(ip, false);
|
||||
client.socket = io(f.url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
auth: { csrfToken: client.csrf },
|
||||
extraHeaders: { Origin: f.cfg.appUrl, Cookie: client.cookie, 'X-Forwarded-For': ip },
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
client.socket.once('connect', resolve);
|
||||
client.socket.once('connect_error', reject);
|
||||
});
|
||||
return client;
|
||||
}
|
||||
const publish = (client, position) =>
|
||||
client.socket.timeout(3000).emitWithAck('location:update', position);
|
||||
|
||||
test('다른 네트워크도 양쪽 위치 허용 시 발견·전송하고 좌표는 목록과 DB에 공개하지 않는다', async () => {
|
||||
const f = await fixture(false),
|
||||
clients = [];
|
||||
try {
|
||||
const a = await positioned(f, '8.8.8.8'),
|
||||
b = await positioned(f, '1.1.1.1'),
|
||||
c = await positioned(f, '9.9.9.9');
|
||||
clients.push(a, b, c);
|
||||
const list = async (client) => (await f.call(client, '/discovery/devices')).json().devices;
|
||||
assert.equal((await list(a)).length, 0);
|
||||
assert.equal((await publish(a, point)).ok, true);
|
||||
assert.equal((await list(a)).length, 0);
|
||||
assert.equal((await publish(b, { ...point, latitude: point.latitude + 0.0004 })).ok, true);
|
||||
assert.equal((await publish(c, { ...point, latitude: point.latitude + 0.01 })).ok, true);
|
||||
assert.deepEqual(
|
||||
(await list(a)).map((device) => device.id),
|
||||
[b.deviceId],
|
||||
);
|
||||
assert.deepEqual((await list(a))[0].sources, ['nearby']);
|
||||
const snapshot = await f.call(a, '/device/snapshot');
|
||||
assert(!snapshot.body.includes('latitude') && !snapshot.body.includes('longitude'));
|
||||
assert(
|
||||
!f.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table'")
|
||||
.all()
|
||||
.some((row) => /latitude|longitude/i.test(row.sql)),
|
||||
);
|
||||
const request = await f.request(a, b, 'https://example.com/nearby');
|
||||
assert.equal(request.statusCode, 200, request.body);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
b,
|
||||
`/transfer-requests/${request.json().requestId}/accept`,
|
||||
'POST',
|
||||
{},
|
||||
randomUUID(),
|
||||
)
|
||||
).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
a,
|
||||
`/transfers/${request.json().requestId}/complete`,
|
||||
'POST',
|
||||
{ urls: ['https://example.com/nearby'] },
|
||||
randomUUID(),
|
||||
)
|
||||
).statusCode,
|
||||
200,
|
||||
);
|
||||
b.socket.emit('location:clear');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal((await list(a)).length, 0);
|
||||
assert.equal((await f.request(a, b, 'https://example.com/outside')).statusCode, 403);
|
||||
} finally {
|
||||
clients.forEach((client) => client.socket.disconnect());
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('기기 숨김·방 참여·세션 종료·부정확한 위치와 잘못된 위치를 검사한다', async () => {
|
||||
const f = await fixture(false),
|
||||
clients = [];
|
||||
try {
|
||||
const a = await positioned(f, '8.8.8.8'),
|
||||
b = await positioned(f, '1.1.1.1');
|
||||
clients.push(a, b);
|
||||
assert.equal((await publish(a, { ...point, latitude: 91 })).ok, false);
|
||||
assert.equal((await publish(a, { ...point, accuracy: 10000 })).code, 'LOCATION_INACCURATE');
|
||||
await publish(a, point);
|
||||
await publish(b, point);
|
||||
await f.call(b, '/device/me', 'PATCH', { discoverable: false });
|
||||
assert.equal(f.service.nearby.guests.has(f.service.guest(b.cookie.split('=')[1]).id), false);
|
||||
assert.equal((await publish(b, point)).code, 'LOCATION_UNAVAILABLE');
|
||||
await f.call(b, '/device/me', 'PATCH', { discoverable: true });
|
||||
await f.space(a, b);
|
||||
assert.equal((await publish(a, point)).code, 'LOCATION_UNAVAILABLE');
|
||||
const d = await positioned(f, '9.9.9.9');
|
||||
clients.push(d);
|
||||
await publish(d, point);
|
||||
assert.equal(
|
||||
(await f.call(d, '/discovery/devices')).json().devices.length,
|
||||
0,
|
||||
'방 안 기기는 외부 위치 목록에 나오지 않음',
|
||||
);
|
||||
const guestId = f.service.guest(d.cookie.split('=')[1]).id;
|
||||
d.socket.disconnect();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(f.service.nearby.guests.has(guestId), false);
|
||||
} finally {
|
||||
clients.forEach((client) => client.socket.disconnect());
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('수락 클릭으로 새 탭이 열려 위치가 제거돼도 해당 요청만 완료할 수 있다', async () => {
|
||||
const f = await fixture(false),
|
||||
clients = [];
|
||||
try {
|
||||
const a = await positioned(f, '8.8.8.8'),
|
||||
b = await positioned(f, '1.1.1.1');
|
||||
clients.push(a, b);
|
||||
await publish(a, point);
|
||||
await publish(b, point);
|
||||
const response = await f.request(a, b, 'https://example.com/accepted'),
|
||||
id = response.json().requestId;
|
||||
b.socket.emit('location:accept', { requestId: id });
|
||||
b.socket.emit('location:clear');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(
|
||||
f.service.nearby.nearby(
|
||||
f.service.guest(a.cookie.split('=')[1]).id,
|
||||
f.service.guest(b.cookie.split('=')[1]).id,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(f.service.locationAcceptances.has(id), false);
|
||||
} finally {
|
||||
clients.forEach((client) => client.socket.disconnect());
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -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의
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
apps/web/src/deviceInfo.js
Normal file
24
apps/web/src/deviceInfo.js
Normal 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 {};
|
||||
}
|
||||
}
|
||||
123
apps/web/src/nearbySearch.js
Normal file
123
apps/web/src/nearbySearch.js
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
|
||||
|
||||
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **8MiB 분할 업로드·주변 자동 발견·PWA 설치·개인 보관함 2GiB**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
|
||||
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **위치 허용 기반 100m 근처 검색·기종 구분·접속 스켈레톤·퇴장 효과**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
|
||||
|
||||
**‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다.
|
||||
|
||||
@@ -256,8 +256,11 @@ sh scripts/configure-nas.sh
|
||||
6. 최신 **compose.nas.yaml**을 다시 불러오거나 편집기에 붙여넣기 → 저장.
|
||||
7. **재빌드 / 이미지 다시 빌드** 선택 → **시작 / 재배포**. 단순 재시작만 하면 바뀐 코드가 이미지에 반영되지 않습니다.
|
||||
8. `healthy` 확인.
|
||||
9. 이번 업데이트에서는 **4단계**의 hop. 전용 NPM 설정, **5-1단계** HTTPS 자동 이동, **5-2단계** 자동 발견 활성화를 순서대로 진행.
|
||||
10. 브라우저 새로고침 → 6단계의 링크·100MB보다 큰 파일 전송 확인.
|
||||
9. 기존 같은 네트워크 자동 발견이 동작한다면 NPM·터널 설정은 그대로 유지합니다. 아직 설정하지 않았다면 **4단계 → 5-1단계 → 5-2단계** 순서로 진행합니다.
|
||||
10. 브라우저 새로고침 → 양쪽 기기에서 사이트를 열고 브라우저의 **위치 권한을 허용**합니다. 약 100m 안이라면 자동으로 목록에 표시됩니다. 별도 검색 메뉴·거리 안내 문구는 표시하지 않습니다. 위치 권한은 각 기기에서 따로 허용해야 합니다.
|
||||
11. 기기를 선택 → 링크·파일 전송 확인. 기존 PWA도 앱을 닫고 다시 열어 최신 화면을 확인합니다.
|
||||
|
||||
기존 `.env`에 위치 설정이 없어도 `LOCATION_DISCOVERY_ENABLED=true`, 반경 100m가 적용됩니다. 새 도메인·외부 포트는 필요 없습니다. 실제 GPS 오차나 권한 문제로 검색되지 않으면 QR 연결을 이용하세요. [검색 동작과 기종 정보](docs/NEARBY_DISCOVERY.md)에 상세 동작을 기록했습니다.
|
||||
|
||||
프로젝트를 삭제하거나 `docker compose down -v`를 실행하지 마세요. DB·파일은 `hop_hop_database`, `hop_hop_uploads` 볼륨에 남습니다. 업데이트 실패 시 중지하고 오류를 확인하세요. DB 마이그레이션이 적용됐을 수 있으므로 코드만 되돌리지 말고 같은 시점의 DB·업로드·환경파일 백업으로 함께 복구해야 합니다.
|
||||
|
||||
|
||||
38
docs/NEARBY_DISCOVERY.md
Normal file
38
docs/NEARBY_DISCOVERY.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 기기 발견
|
||||
|
||||
기기 목록은 Socket.IO 변경 이벤트로 갱신한다. 계속 목록을 조회하는 검색 루프나 대기 스피너는 사용하지 않는다. 실제 새 기기 이벤트 후 서버 조회를 기다리는 동안에만 카드 형태의 스켈레톤을 표시하며, 정보를 받으면 같은 자리에서 실제 카드로 바뀐다. 조회 실패 시 스켈레톤은 최대 5초 뒤 사라진다. 연결 해제 시 짧게 사라지는 효과를 적용하고, 동작 줄이기 설정을 존중한다.
|
||||
|
||||
## 같은 네트워크
|
||||
|
||||
기존의 검증된 공인 IP 그룹 발견을 유지한다. 운영에서는 Cloudflare → NPM → hop. 프록시 경로를 검증하고 활성화해야 한다. 실제 NAS에서 기존 사용자가 같은 네트워크 자동 발견을 확인했다. 방 참여 중에는 해당 방 기기만 표시한다.
|
||||
|
||||
## 위치로 근처 기기 찾기
|
||||
|
||||
- 기본 반경 100m. 위치는 내부에서 자동으로 확인하며 별도 검색 버튼·거리 안내 문구를 표시하지 않는다. 양쪽 브라우저가 위치 권한을 허용해야 한다. 첫 요청의 권한 창은 브라우저가 표시한다.
|
||||
- HTTPS 필요. 위치 권한 거절·미지원·오차가 너무 큰 경우 기존 QR/네트워크 연결을 유지한다. 거절 후 반복 권한 요청을 하지 않는다. 브라우저에서 권한을 다시 허용하거나 발견 허용을 바꾸면 상태를 반영한다.
|
||||
- 위도·경도는 소수점 4자리로 줄여 프로세스 메모리에만 보관한다. DB·브라우저 저장소·다른 기기 응답에는 좌표를 넣지 않는다. 브라우저 위치 권한 상태를 사용하며 별도 동의 플래그를 저장하지 않는다.
|
||||
- 화면이 보이고 연결·발견 허용 상태일 때만 2분마다 위치를 갱신한다. 이동 추적 `watchPosition`은 사용하지 않는다.
|
||||
- 발견 허용 끄기·권한 해제·탭 숨김·방 참여·접속 종료 시 해당 탭 좌표를 제거한다. 강제 종료 등으로 제거 신호가 누락되어도 150초 후 만료한다.
|
||||
- 다른 탭에서 검색 중인 같은 기기는 마지막 유효한 좌표가 사라질 때까지 표시될 수 있다.
|
||||
- 위치가 바뀌어 목록에 변화가 있을 때만 관련 기기에 이벤트를 보낸다. 파일 시스템 정리는 1분 간격이며 요청·세션 만료 정리는 별도로 유지한다.
|
||||
- 위치 오차 때문에 근처 사용자 누락이나 옆 건물 사용자가 표시될 수 있다. 실제 Bluetooth 거리나 접속 경로를 증명하는 기능은 아니다. 전송에는 기존 수락 절차와 접근 검사 모두 적용한다.
|
||||
- 링크 수락으로 새 탭이 열려 위치가 제거되는 경우에는 수락 직전 확인된 요청 ID에 한해 15초짜리 일회성 수락 권한을 사용한다. 좌표를 연장하거나 방·차단·발견 허용 경계를 우회하지 않는다.
|
||||
|
||||
설정 기본값:
|
||||
|
||||
```dotenv
|
||||
LOCATION_DISCOVERY_ENABLED=true
|
||||
LOCATION_DISCOVERY_RADIUS_METERS=100
|
||||
```
|
||||
|
||||
기존 NAS `.env`에 항목이 없어도 기본값이 적용된다. 새 주소·포트 설정은 필요 없다.
|
||||
|
||||
## 기종 정보와 이름
|
||||
|
||||
지원하는 Chromium Android 브라우저에서는 `navigator.userAgentData.getHighEntropyValues(['model'])`로 모델 정보를 읽는다. 지원하지 않거나 브라우저가 공개하지 않으면 기존 이름·고유 코드를 표시한다. 모델 정보 조회는 최대 1초만 기다리며 실패가 접속을 막지 않는다.
|
||||
|
||||
일반 기본 이름(Android 등)은 모델이 있으면 모델로 표시한다. 직접 지정한 이름은 항상 우선하며, 모든 카드에 고유 코드도 함께 표시한다. 같은 모델 여러 대는 코드와 이름 변경으로 구분할 수 있다. 모델·플랫폼 정보는 연결 중 서버 메모리에만 두고 마지막 연결 종료 후 정리한다.
|
||||
|
||||
제조사는 브라우저가 제공하는 필드가 아니다. `SM-`/`GT-`는 Samsung, `Pixel`은 Google로 제한적으로 추정한다. 모르는 모델은 추정하지 않는다. 브라우저 브랜드(Chrome 등)를 기기 제조사로 사용하지 않는다. OS에서 지정한 개인 기기 이름, 일련번호, MAC 주소는 읽지 않는다. iPhone의 정확한 기종도 이 방식으로 보장하지 않는다.
|
||||
|
||||
모델 힌트의 범위와 공개 제한은 [MDN 문서](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/getHighEntropyValues)를 참고했다.
|
||||
@@ -47,5 +47,5 @@
|
||||
- 개인 보관함 기본 한도 2GiB: 임시 저장·받은 항목의 유효한 파일 원본을 계정별로 중복 없이 합산. 받은 파일도 포함하고 삭제·만료한 접근권은 개인 사용량에서 제외한다. 다른 사람의 원본과 저장소를 물리적으로 공유하더라도 계정별 한도는 분리한다.
|
||||
- 개인 한도 초과 시 기기 수신·다운로드는 계속하고 해당 파일의 계정 수신 기록만 생략한다. 짧은 알림을 표시한다. 업로드 중인 개인 저장의 예약과 삭제 복원도 한도를 검사한다. 익명 파일 저장 한도는 개인 보관함과 별도이며 익명 화면의 내역은 계속 숨긴다.
|
||||
- 파일은 8MiB 조각으로 전송하며 같은 파일 내용 검증·이미 받은 위치·중복 조각·서버 재시작을 처리한다. 자동 재시도 실패 시 기존 단일 버튼이 다시 전송으로 바뀐다. 완료 전후 취소와 접근 범위 검사는 유지한다.
|
||||
- 운영 자동 발견은 신뢰한 인접 프록시에서 온 CF-Connecting-IP만 사용하고, 원본 IP와 프록시 진단 결과는 일반 사용자에게 공개하지 않는다. 같은 공인 IP를 계속 탐색한다는 상태를 표시한다.
|
||||
- 운영 자동 발견은 신뢰한 인접 프록시에서 온 CF-Connecting-IP만 사용하고, 원본 IP와 프록시 진단 결과는 일반 사용자에게 공개하지 않는다. 목록 변화는 접속 이벤트로 갱신하고 대기 중 반복 탐색 문구를 표시하지 않는다. 위치 허용 기반 100m 근처 검색도 제공하며 상세 동작은 NEARBY_DISCOVERY.md를 따른다.
|
||||
- PWA 설치: Android 등 설치 이벤트 지원 브라우저는 설치 창, iPhone은 홈 화면 추가 안내. 설치 앱 안에서는 버튼을 숨긴다. Service Worker는 민감한 자료나 API를 캐시하지 않고 오프라인에서는 인터넷 연결 안내만 제공한다.
|
||||
|
||||
@@ -87,3 +87,13 @@
|
||||
- PWA manifest·PNG 아이콘·실제 Service Worker·iPhone 설치 안내·설치 이벤트·설치 후 숨김·320px 헤더·오프라인 안내·민감한 데이터 캐시 없음 검증. 실제 OS 설치와 실물 Safari/Android는 사용자 기기에서 확인해야 한다.
|
||||
- 개인 보관함 2GiB의 직접 저장·받은 파일 합산, 다른 계정 격리, 한도 초과 수신의 다운로드 유지·계정 기록 생략, 삭제 후 공간 회수·초과 복원 거절을 확인했다.
|
||||
- 전체 빌드와 남은 타입 검사 통과. 운영 Docker 이미지 빌드와 재시작 후 SQLite integrity·세션·기기명·기존 파일·미완료 업로드 조각의 위치 보존 및 이어 보내기 완료 검증. 실제 NAS·Cloudflare·NPM 경로 활성화는 deploy.md의 운영 진단으로 수행한다.
|
||||
|
||||
## 2026-10-12 위치 검색과 접속 상태 UI 업데이트
|
||||
|
||||
- 전체 빌드와 남은 타입 검사 통과. API 테스트 73개 통과.
|
||||
- 양쪽 위치 동의·100m 반경·좌표 오차/유효성·여러 탭·만료·발견 허용 끄기·방 격리·기존 접근 경계·수락 직전 위치 해제를 검사했다. 위치는 서버 메모리에만 저장한다.
|
||||
- 실제 Chrome 브라우저에서 위치 권한 허용 후 별도 검색 버튼·거리 안내 없이 자동 발견, 대기 중 반복 API 요청 없음, 모바일·탭 숨김 후 위치 삭제/복귀, 거리 밖 제외·권한 거절 후 반복 요청 없음, 수락 후 링크 자동 열기를 검증했다. 좌표는 테스트 브라우저의 위치 시뮬레이션을 사용했으며 실제 NAS 주변 GPS 환경은 배포 후 확인해야 한다.
|
||||
- 별도 브라우저 검증에서 새 기기 이벤트 후 서버 조회를 지연시켜 카드 스켈레톤을 확인하고, 정보 도착 후 실제 카드로 교체·직접 지정한 이름 우선·모델별 이름·제조사 제한 추정·연결 해제 애니메이션·메타데이터 정리를 검사했다. 기기 12대의 4/3/2열 반응형과 빈 화면도 확인했다.
|
||||
- 기존 브라우저 테스트 5종(익명 전송, 계정 보관함, 방 기기 관리, 분할 업로드, PWA) 모두 통과했다. 수락 팝업·자동 열기/다운로드·재시도·7일 보관·2GiB 용량 경계는 유지한다.
|
||||
- 운영 Docker 이미지 빌드 및 재시작 후 SQLite integrity·세션·기기명·파일·미완료 분할 업로드 복구 테스트 통과. 화면 정리 이후에도 같은 검증을 완료했다.
|
||||
- NAS 업데이트 순서는 deploy.md에 기록했다. 새 도메인·포트는 필요 없고 실제 NAS에 대한 원격 배포는 수행하지 않았다.
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"test:e2e:devices": "node --import tsx tests/e2e/device-menu.mjs",
|
||||
"test:e2e:layout": "node --import tsx tests/e2e/layout.mjs",
|
||||
"test:e2e:chunks": "node --import tsx tests/e2e/chunks.mjs",
|
||||
"test:e2e:pwa": "node --import tsx tests/e2e/pwa.mjs"
|
||||
"test:e2e:pwa": "node --import tsx tests/e2e/pwa.mjs",
|
||||
"test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
|
||||
@@ -59,8 +59,14 @@ export const joinInput = z
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Boolean(v.code) !== Boolean(v.token));
|
||||
export type Device = { id: string; name: string; code: string; discoverable: boolean };
|
||||
export type Peer = Device & { sources: ('network' | 'space')[] };
|
||||
export type Device = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
discoverable: boolean;
|
||||
info?: { model: string; platform: string; manufacturer: string };
|
||||
};
|
||||
export type Peer = Device & { sources: ('network' | 'space' | 'nearby')[] };
|
||||
export type RequestStatus = 'pending' | 'accepted' | 'rejected' | 'expired' | 'cancelled';
|
||||
export type TransferRequest = {
|
||||
id: string;
|
||||
|
||||
@@ -83,7 +83,7 @@ try {
|
||||
await host.keyboard.press('Escape');
|
||||
assert.equal(await host.locator('.device-menu').count(), 0);
|
||||
await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click();
|
||||
await host.locator('.stage-heading').click();
|
||||
await host.locator('.section-heading h2').click();
|
||||
assert.equal(await host.locator('.device-menu').count(), 0);
|
||||
await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click();
|
||||
await host.screenshot({ path: 'tests/e2e/device-menu-desktop.png', fullPage: true });
|
||||
|
||||
@@ -13,6 +13,7 @@ const runtime = await createApp({
|
||||
DATABASE_PATH: resolve(folder, 'db.sqlite'),
|
||||
UPLOAD_DIR: resolve(folder, 'uploads'),
|
||||
MIN_FREE_DISK_MB: '0',
|
||||
OFFLINE_GRACE_MS: '150',
|
||||
SESSION_SECRET: 's'.repeat(40),
|
||||
DISCOVERY_SECRET: 'd'.repeat(40),
|
||||
}),
|
||||
@@ -45,6 +46,48 @@ try {
|
||||
});
|
||||
await page.goto(url);
|
||||
await page.locator('.app-shell[data-online="true"]').waitFor();
|
||||
assert.equal(await page.getByText('어디로 보낼까요?', { exact: true }).count(), 0);
|
||||
assert.equal(await page.locator('.devices-stage .spin,.section-heading h2 span').count(), 0);
|
||||
await page.screenshot({ path: 'tests/e2e/main-empty-device.png', fullPage: true });
|
||||
let releaseSnapshot;
|
||||
const gate = new Promise((resolve) => {
|
||||
releaseSnapshot = resolve;
|
||||
});
|
||||
let holdSnapshot = true;
|
||||
await page.route('**/api/device/snapshot', async (route) => {
|
||||
if (holdSnapshot) await gate;
|
||||
await route.continue();
|
||||
});
|
||||
const modelSession = runtime.service.createGuest('Android');
|
||||
const modelSocket = io(url, {
|
||||
transports: ['websocket'],
|
||||
auth: {
|
||||
csrfToken: runtime.service.csrf(modelSession.token),
|
||||
deviceInfo: { model: 'SM-S938B', platform: 'Android', manufacturer: 'untrusted' },
|
||||
},
|
||||
extraHeaders: { Origin: url, Cookie: `hop_guest=${modelSession.token}` },
|
||||
});
|
||||
sockets.push(modelSocket);
|
||||
await page.locator('.peer-skeleton').waitFor();
|
||||
assert.equal(
|
||||
await page.locator('button.peer-card').count(),
|
||||
0,
|
||||
'정보 수신 전 클릭 가능한 기기가 없음',
|
||||
);
|
||||
await page.waitForTimeout(260); // Capture the settled layout after the single arrival transition.
|
||||
await page.screenshot({ path: 'tests/e2e/main-device-skeleton.png', fullPage: true });
|
||||
holdSnapshot = false;
|
||||
releaseSnapshot();
|
||||
await page.getByRole('button', { name: /SM-S938B/ }).waitFor();
|
||||
assert.equal(runtime.service.presence.info(modelSession.guest.id).manufacturer, 'Samsung');
|
||||
await page.locator('.peer-skeleton').waitFor({ state: 'hidden' });
|
||||
runtime.service.patchDevice(modelSession.guest, { name: '내 Galaxy' });
|
||||
await page.getByRole('button', { name: /내 Galaxy/ }).waitFor();
|
||||
modelSocket.disconnect();
|
||||
await page.locator('.device-leave-active').waitFor();
|
||||
await page.locator('.peer-entry').waitFor({ state: 'hidden' });
|
||||
assert.equal(runtime.service.presence.info(modelSession.guest.id), undefined);
|
||||
await page.unroute('**/api/device/snapshot');
|
||||
for (let index = 0; index < 12; index++) {
|
||||
const session = runtime.service.createGuest();
|
||||
runtime.service.patchDevice(session.guest, { name: `기기 ${index + 1}` });
|
||||
@@ -146,7 +189,7 @@ try {
|
||||
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
|
||||
await page.getByRole('button', { name: '연결 공간 만들기' }).click();
|
||||
const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, '');
|
||||
const cookie = sockets[0].io.opts.extraHeaders.Cookie;
|
||||
const cookie = sockets[1].io.opts.extraHeaders.Cookie;
|
||||
const token = cookie.split('=')[1];
|
||||
const response = await fetch(`${url}/api/spaces/join`, {
|
||||
method: 'POST',
|
||||
|
||||
182
tests/e2e/nearby.mjs
Normal file
182
tests/e2e/nearby.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { createApp } from '../../apps/server/src/app.ts';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
const folder = await mkdtemp(resolve(tmpdir(), 'hop-nearby-'));
|
||||
const runtime = await createApp({
|
||||
...loadConfig({
|
||||
APP_URL: 'http://127.0.0.1',
|
||||
DATABASE_PATH: resolve(folder, 'db.sqlite'),
|
||||
UPLOAD_DIR: resolve(folder, 'uploads'),
|
||||
MIN_FREE_DISK_MB: '0',
|
||||
SAME_NETWORK_DISCOVERY_ENABLED: 'false',
|
||||
SESSION_SECRET: 's'.repeat(40),
|
||||
DISCOVERY_SECRET: 'd'.repeat(40),
|
||||
}),
|
||||
webRoot: resolve('apps/web/dist'),
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
|
||||
runtime.setDevOrigin(url);
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HOP_BROWSER_EXECUTABLE
|
||||
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
|
||||
: {}),
|
||||
});
|
||||
const origin = { latitude: 37.5665, longitude: 126.978, accuracy: 20 };
|
||||
const a = await browser.newContext({
|
||||
permissions: ['geolocation'],
|
||||
geolocation: origin,
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
const b = await browser.newContext({
|
||||
permissions: ['geolocation'],
|
||||
geolocation: { ...origin, latitude: origin.latitude + 0.0004 },
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
});
|
||||
for (const context of [a, b])
|
||||
await context.addInitScript(() => {
|
||||
window.__geoVisible = true;
|
||||
window.__geoCalls = 0;
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => (window.__geoVisible ? 'visible' : 'hidden'),
|
||||
});
|
||||
const original = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);
|
||||
navigator.geolocation.getCurrentPosition = (...args) => {
|
||||
window.__geoCalls++;
|
||||
return original(...args);
|
||||
};
|
||||
});
|
||||
const sender = await a.newPage(),
|
||||
receiver = await b.newPage(),
|
||||
errors = [];
|
||||
for (const page of [sender, receiver])
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let apiCalls = 0;
|
||||
sender.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith('/api/')) apiCalls++;
|
||||
});
|
||||
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
||||
await sender.locator('.app-shell[data-online="true"]').waitFor();
|
||||
await receiver.locator('.app-shell[data-online="true"]').waitFor();
|
||||
await sender.locator('button.peer-card').first().waitFor();
|
||||
await receiver.locator('button.peer-card').first().waitFor();
|
||||
assert.equal(
|
||||
await sender.getByRole('button', { name: '근처 기기 찾기', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(await sender.locator('.nearby-search-control').count(), 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
const initialCalls = apiCalls;
|
||||
const initialGeoCalls = await sender.evaluate(() => window.__geoCalls);
|
||||
await new Promise((resolve) => setTimeout(resolve, 11000));
|
||||
assert.equal(apiCalls, initialCalls, '전송 없는 대기 화면은 반복 API 검색 없음');
|
||||
assert.equal(
|
||||
await sender.evaluate(() => window.__geoCalls),
|
||||
initialGeoCalls,
|
||||
'위치는 계속 검색하지 않음',
|
||||
);
|
||||
assert(initialGeoCalls > 0, '허용된 위치로 자동 발견');
|
||||
assert.match(await sender.locator('.peer-card').innerText(), /근처 기기/);
|
||||
assert.equal(runtime.service.nearby.points.size, 2);
|
||||
assert.equal(
|
||||
await receiver.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
await receiver.waitForTimeout(260); // Screenshot after the one-time arrival transition.
|
||||
await receiver.screenshot({ path: 'tests/e2e/nearby-mobile.png' });
|
||||
await receiver.evaluate(() => {
|
||||
window.__geoVisible = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await sender.locator('.peer-card').waitFor({ state: 'hidden' });
|
||||
assert.equal(runtime.service.nearby.points.size, 1, '탭 숨김 시 메모리 위치 삭제');
|
||||
await receiver.evaluate(() => {
|
||||
window.__geoVisible = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
await b.route('https://example.com/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Nearby link</title>' }),
|
||||
);
|
||||
await receiver.evaluate(() => {
|
||||
const open = window.open.bind(window);
|
||||
window.open = (...args) => {
|
||||
const tab = open(...args);
|
||||
window.__geoVisible = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
return tab;
|
||||
};
|
||||
});
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
await sender.getByLabel('보낼 URL').fill('https://example.com/nearby');
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
const opened = receiver.waitForEvent('popup');
|
||||
void opened.catch(() => {});
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
const tab = await opened;
|
||||
await tab.waitForURL('https://example.com/nearby');
|
||||
assert.equal(await tab.evaluate(() => window.opener), null);
|
||||
await tab.close();
|
||||
assert.equal(runtime.service.nearby.points.size, 1, '새 탭 수락 시에도 좌표를 남기지 않음');
|
||||
await receiver.evaluate(() => {
|
||||
window.__geoVisible = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
async function visibilityCycle() {
|
||||
await receiver.evaluate(() => {
|
||||
window.__geoVisible = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await sender.locator('button.peer-card').waitFor({ state: 'hidden' });
|
||||
await receiver.evaluate(() => {
|
||||
window.__geoVisible = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
}
|
||||
await b.setGeolocation({ ...origin, latitude: origin.latitude + 0.02 });
|
||||
await visibilityCycle();
|
||||
await receiver.waitForFunction(() => window.__geoCalls >= 4);
|
||||
await sender.locator('button.peer-card').waitFor({ state: 'hidden' });
|
||||
await receiver.evaluate(() => {
|
||||
navigator.geolocation.getCurrentPosition = (_success, error) => {
|
||||
window.__geoCalls++;
|
||||
error({ code: 1 });
|
||||
};
|
||||
});
|
||||
await visibilityCycle();
|
||||
await sender.waitForFunction(() => document.querySelectorAll('button.peer-card').length === 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
assert.equal(runtime.service.nearby.points.size, 1);
|
||||
const deniedCalls = await receiver.evaluate(() => window.__geoCalls);
|
||||
await visibilityCycle();
|
||||
await receiver.waitForTimeout(300);
|
||||
assert.equal(
|
||||
await receiver.evaluate(() => window.__geoCalls),
|
||||
deniedCalls,
|
||||
'권한 거절 후 반복 요청 없음',
|
||||
);
|
||||
assert.equal(
|
||||
await receiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(await sender.evaluate(() => caches.keys()), []);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 위치 허용 후 자동 발견, 별도 검색 버튼/안내 없음, 대기 중 반복 API 없음, 100m 양쪽 동의 검색, 모바일, 숨김 시 위치 삭제와 복귀, 새 탭 수락, 거리 밖 제외, 권한 거절, 좌표 캐시 없음',
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await runtime.app.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user