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

@@ -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, '');
});

View 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();
}
});