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

@@ -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