diff --git a/.agents/skills/verify-deepseekbot/features/phone-notifications.md b/.agents/skills/verify-deepseekbot/features/phone-notifications.md index 1ccf3f53be..0446e954fa 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-notifications.md +++ b/.agents/skills/verify-deepseekbot/features/phone-notifications.md @@ -9,6 +9,11 @@ The phone app keeps every paired computer and shows one. A push notification is - `tap` routes a notification: a Tool approval or question opens the request (`/inbox/[id]`, or the card in the Bot's DM when the shown computer's Roster has that DM), a reply opens the DM, and a test or badge-only notification opens Activity. A notification for another computer switches first. - `badge` sets the app badge to the shown computer's unresolved actions and dismisses banners for items no longer unresolved whenever the shown computer's Inbox changes. - `register` stores one Expo push token per device on the Host (`/botharness-device/push/register`); revoking the device deletes it, and the Host log records `push phase=…` lines without the token. +- `device-preference`: "stop spamming me with notifications." + + Verify Settings → Notifications stores the four notification kinds on the phone and copies changes to every paired Host. Seed from existing switches only once; toggle on one Host, switch to the other, restart the app and re-pair to prove preservation. With a Host offline the phone retains its choice and reports pending sync; reconnect/resume retries. The kinds remain configurable without OS permission or an Expo token. Check Host copies with `deepseekbot device-notifications --device-file ` and keep pairing files private. Native default/intermediate/maximum text × light/dark on both platforms remains required. + + Keep Host A connected and current while Host B fails, then restore B without switching or resuming: foreground retry must copy the latest choice. Backgrounding cancels the retry timer; removing B stops retries to it. On the assigned development client, `deepseekbot phone-notifications --metro-port --target-id --device-name ` reads the same phone owner; `--preferences-stdin` updates it and the UI, survives restart and reports saved separately from pending Host copies. This requires an exact unique Metro development target and does not qualify release CLI parity or replace native QA. - `environment-paging` (#2194) taps/swipes All → Computer → member Organization → guest Organization without changing the paired Computer. Show an empty member Organization too. Open/send through real source routes, read back author/channel via public API/CLI, then isolate the task Computer route: keep Cloud send working, preserve the Computer draft across restart and prove reconnect does not auto-send. Inspect Activity in Cloud scope for its unavailable notice and absence of unrelated Computer badge; category swipe must remain. Capture matched light/dark before/after screenshots and a short paging recording at the frozen PR head. Default/intermediate/maximum text, keyboard, source removal and cold restoration are distinct checks. One Computer is not simultaneous multi-Computer proof. @@ -30,7 +35,7 @@ Notifications opened from Settings or a Bot profile stay inside that parent draw - **Windows Android (queued runner).** Follow [Android device QA](../../../../docs/agents/android-device-qa.md): claim the explicit emulator or phone serial, use `scripts/android-qa.ps1` to build/install/open the dev client, then exercise the phone entry points above and capture PNG/XML/MP4 evidence. Pairing, switching computers and permission UI require their visible and canonical facts; scrcpy working alone does not pass them. The Mac `simctl push` journey cannot simulate Android notifications. Real FCM delivery and notification actions stay untested until verified on a configured phone build. -- The Simulator cannot get an Expo push token without an EAS projectId, so Notifications shows "This build of the app cannot receive notifications." and the per-kind switches and test button do not appear. Real APNs/FCM delivery needs a physical device and EAS credentials; say it is still owed. +- The Simulator cannot get an Expo push token without an EAS projectId, so Notifications shows "This build of the app cannot receive notifications." The phone-owned kind switches remain available after preference initialization; sending a test stays disabled. Real APNs/FCM delivery needs a physical device and EAS credentials; say it is still owed. - `xcrun simctl push` stands in for the Expo push service. The notification data goes under the payload's `body` key, which is where expo-notifications reads it on iOS. - The banner belongs to SpringBoard, so Maestro cannot find it by text: the flow taps it by point (top centre). agent-device can tap it the same way (`press 196 90` on an iPhone 16). - `maestro test` blocks while it runs; the journey serves its push trigger from the same process, so it runs Maestro asynchronously. diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 0dc94b6c47..634f0268f9 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -16,6 +16,9 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu - `viewers`: a remote Bot DM opens Browser or Computer when that Bot has access. The shared Host viewer modules read frames and send input to that Host only; closing, switching source or losing the connection tears down the viewer and releases requested control. - `viewer-control`: queued input belongs to one takeover. Resume or frame loss invalidates it; frame recovery remains read-only even when the Host Feed never disconnected. Remove closes viewers and waits up to five seconds for takeover/release cleanup before revoking the pairing. - `settings`: right-click a Remote Host icon → Remote settings opens that Host's models, usage and storage. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation. +- `notifications`: right-click a Remote Host icon and choose Remote notifications; enable browser permission explicitly and choose Tool approvals, Questions, Private input requests, Mentions & replies. Choices survive reload in this browser only. Verify the initial Inbox and reconnect backlog are silent, a new permitted Host attention item alerts once, disabled kinds stay silent and removing the Host closes its alerts. Private input text is never shown. Capture the new entry and dialog in light/dark; page-open API evidence does not qualify closed-page push or Desktop. + + Discovery uses the Host's notification query, not the Activity display window. Keep more than 30 old mentions and produce a new ordinary DM reply and a non-mention group reply; both must alert once when enabled. Traverse `deepseekbot inbox --notifications` cursors and compare the Host-decided items. Verify stable same-timestamp pagination, a live invalidation during paging, a partial-page failure followed by recovery, and no historical replay. A refused Notification constructor records a bounded redacted `remote-notification-show-*` diagnostic through the existing Client diagnostics surface. Settings apply immediately and close through the normal close control. ## How to get to it (user POV) @@ -51,3 +54,11 @@ For message bodies, upload an image and a text file to Host B through its attach - Host looks are device-local by design. DSH Desktop and two physical machines over a real tailnet are not qualified by a two-Host local run; native phone qualification is separate. - Control release uses the same device credential. An unreachable Host is retried when this viewer reconnects or reopens; a revoked credential cannot resume a Browser. In that case the Host operator resumes it. The Computer also has its existing Host-side pause lease. - Dismiss Continue and Skip onboarding overlays before selecting Messages. Retain any failed drive evidence before retrying. + +Activation race regression: while replies are disabled, write 35 old mentions and keep a notification page pending, then enable replies. The switch/CLI write commits only after a complete fresh baseline; old mentions stay silent. After success, send a new group reply and ordinary DM (including tied timestamps in automated fixtures): each alerts once. Fail a page and retry, disable/re-enable, change Host, close settings, revoke permission and dispose while activation waits; failed/cancelled operations cannot commit later. Preserve the original 37-alert failing trace separately from any subsequent pass. + +CLI deadline regression: let the real appearance relay refuse at eight seconds, then complete the notification baseline at nine seconds. Browser preferences and persisted storage must remain unchanged, and no late acknowledgement is sent. Repeat after control unmount; a pre-deadline successful write remains operable. Change the app locale while the DOM language is unchanged and verify private-input notification copy follows the bound translator without exposing the input. + +Attention identity regression: after a complete baseline, admit new Host attention IDs whose Source Events predate already observed items, including reverse timestamp order across notification kinds. Each permitted new identity alerts once. After more than 2,000 observed identities, remove old IDs from attention and reintroduce them: no replay, while a genuinely new older ID still alerts. Ordinary reconnect and overlapping subscriber replacement preserve identity history. Removing a Host, replacing its credential or final owner disposal releases that history; the new lifecycle first establishes a silent complete baseline. Identity memory grows with distinct observed IDs for that owner lifetime; it is not capped or persisted. + +Read-failure recovery: keep the same Host connected with the same credential after a complete notification baseline. Fail only an ordinary Activity query, then admit a new attention ID before recovery. The next successful complete scan alerts that new ID once and never replays the original IDs. Repeat with only the notification tail page failing. An initial failed/partial read never establishes a baseline; after a real disconnect, failed reads likewise cannot consume the silent reconnect baseline. A pending enable with either read failure leaves preference and storage unchanged; an explicit successful retry establishes its complete enable boundary before new alerts. diff --git a/.github/pr-screenshots/n4/a-d.png b/.github/pr-screenshots/n4/a-d.png new file mode 100644 index 0000000000..bba0d444ee Binary files /dev/null and b/.github/pr-screenshots/n4/a-d.png differ diff --git a/.github/pr-screenshots/n4/a-l.png b/.github/pr-screenshots/n4/a-l.png new file mode 100644 index 0000000000..0945965d7f Binary files /dev/null and b/.github/pr-screenshots/n4/a-l.png differ diff --git a/.github/pr-screenshots/n4/b-d.png b/.github/pr-screenshots/n4/b-d.png new file mode 100644 index 0000000000..3221f5a46c Binary files /dev/null and b/.github/pr-screenshots/n4/b-d.png differ diff --git a/.github/pr-screenshots/n4/b-l.png b/.github/pr-screenshots/n4/b-l.png new file mode 100644 index 0000000000..4f2c5b4807 Binary files /dev/null and b/.github/pr-screenshots/n4/b-l.png differ diff --git a/apps/mobile/scripts/tob-mobile-driver.mjs b/apps/mobile/scripts/tob-mobile-driver.mjs index a1727ca174..341015b231 100644 --- a/apps/mobile/scripts/tob-mobile-driver.mjs +++ b/apps/mobile/scripts/tob-mobile-driver.mjs @@ -1,8 +1,9 @@ -import { readdirSync, writeFileSync } from 'node:fs'; +import { connect } from '../../../packages/core/src/bots/development-inspector.mjs'; +export { connect, targetSocket } from '../../../packages/core/src/bots/development-inspector.mjs'; +import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { createRequire } from 'node:module'; import { execFileSync } from 'node:child_process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { createHash, randomUUID } from 'node:crypto'; import { argsOf, privateFile } from './tob-uat-session.mjs'; @@ -44,87 +45,6 @@ export function fixtureExpression(mode, state) { return { accessToken: entry.session.accessToken, refreshToken: entry.session.refreshToken }; })()`; } -export function targetSocket(target, port, simulatorName) { - if ( - !target || - target.deviceName !== simulatorName || - target.appId !== 'ai.botharness.deepseekbot.dev' - ) - throw Error('EXPLICIT_SIMULATOR_APP_TARGET_REQUIRED'); - const url = new URL(target.webSocketDebuggerUrl); - if ( - !['127.0.0.1', 'localhost'].includes(url.hostname) || - url.port !== String(port) || - url.protocol !== 'ws:' - ) - throw Error('LOOPBACK_DEBUGGER_REQUIRED'); - url.hostname = '127.0.0.1'; - return url; -} -export async function connect(port, targetId, simulatorName) { - if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error('INVALID_METRO_PORT'); - const targets = await ( - await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) }) - ).json(); - const target = targets.find((t) => t.id === targetId); - const url = targetSocket(target, port, simulatorName); - const root = fileURLToPath(new URL('../../../', import.meta.url)); - const store = join(root, 'node_modules/.pnpm'); - const dependency = readdirSync(store) - .filter((name) => name.startsWith('ws@8.')) - .sort() - .pop(); - if (!dependency) throw Error('DEBUG_WEBSOCKET_DEPENDENCY_MISSING'); - const WebSocket = createRequire(join(store, dependency, 'node_modules/ws/package.json'))('ws'); - const ws = new WebSocket(url, { origin: `http://127.0.0.1:${port}` }); - await new Promise((resolve, reject) => { - ws.once('open', resolve); - ws.once('error', reject); - }); - let id = 0; - const pending = new Map(); - ws.on('message', (raw) => { - const r = JSON.parse(String(raw)); - const p = pending.get(r.id); - if (!p) return; - clearTimeout(p.timer); - pending.delete(r.id); - if (r.error || r.result?.exceptionDetails) { - const description = r.result?.exceptionDetails?.exception?.description ?? ''; - const code = [ - 'DEBUG_ONLY', - 'CLOUD_MODULE_AMBIGUOUS', - 'SESSION_BUSY', - 'INVALID_VAULT', - 'TRUSTED_IMPORT_REQUIRED', - 'EXISTING_SESSION_REFUSED', - 'LOCAL_SESSION_REMAINS', - 'VERIFIED_NATIVE_IDENTITY_REQUIRED', - ].find((v) => description.includes(v)); - p.reject(Error(code ?? 'INSPECTOR_EVALUATION_FAILED')); - } else p.resolve(r.result); - }); - ws.on('close', () => { - for (const p of pending.values()) { - clearTimeout(p.timer); - p.reject(Error('INSPECTOR_CLOSED')); - } - pending.clear(); - }); - return { - send: (method, params) => - new Promise((resolve, reject) => { - const n = ++id; - const timer = setTimeout(() => { - pending.delete(n); - reject(Error('INSPECTOR_TIMEOUT')); - }, 20000); - pending.set(n, { resolve, reject, timer }); - ws.send(JSON.stringify({ id: n, method, params })); - }), - close: () => ws.close(), - }; -} export async function main(mode, args) { const state = JSON.parse(privateFile(args.session)); const sims = JSON.parse( diff --git a/apps/mobile/src/host/device-notification-preferences.ts b/apps/mobile/src/host/device-notification-preferences.ts new file mode 100644 index 0000000000..2d821f4e44 --- /dev/null +++ b/apps/mobile/src/host/device-notification-preferences.ts @@ -0,0 +1,144 @@ +import type { SavedHost } from './hosts'; +import { PUSH_KINDS, type HostPush, type PushPreferences } from './push'; + +export const DEFAULT_NOTIFICATION_PREFERENCES: PushPreferences = { + approval: true, + question: true, + 'private-input': true, + reply: true, +}; + +export interface DevicePreferenceSnapshot { + preferences: PushPreferences | undefined; + syncing: boolean; + failed: readonly string[]; +} + +export function parseNotificationPreferences(value: unknown): PushPreferences | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const record = value as Record; + if (!PUSH_KINDS.every((kind) => typeof record[kind] === 'boolean')) return undefined; + return Object.fromEntries(PUSH_KINDS.map((kind) => [kind, record[kind]])) as PushPreferences; +} + +export function createDeviceNotificationPreferences(options: { + storage: { read(): Promise; write(preferences: PushPreferences): void }; + push: Pick; +}) { + let snapshot: DevicePreferenceSnapshot = { preferences: undefined, syncing: false, failed: [] }; + let hosts: readonly SavedHost[] = []; + let foreground = false; + let retryTimer: ReturnType | undefined; + let retryDelay = 1000; + const clearRetry = () => { + clearTimeout(retryTimer); + retryTimer = undefined; + }; + const scheduleRetry = () => { + clearRetry(); + if (!foreground || snapshot.failed.length === 0) { + retryDelay = 1000; + return; + } + retryTimer = setTimeout(() => { + retryTimer = undefined; + retryDelay = Math.min(retryDelay * 2, 30_000); + void enqueue(() => (foreground ? sync() : Promise.resolve())).catch(() => undefined); + }, retryDelay); + }; + const listeners = new Set<() => void>(); + const publish = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) listener(); + }; + const ready = options.storage.read().then((value) => { + const preferences = parseNotificationPreferences(value); + if (value !== undefined && value !== null && preferences === undefined) + throw new Error('notification-preferences-invalid'); + publish({ preferences }); + }); + void ready.catch(() => undefined); + let queue: Promise = ready; + const enqueue = (run: () => Promise): Promise => { + const next = queue + .catch(() => undefined) + .then(() => ready) + .then(run); + queue = next; + return next; + }; + const save = (preferences: PushPreferences) => { + options.storage.write(preferences); + publish({ preferences }); + }; + const sync = async () => { + clearRetry(); + publish({ syncing: true }); + try { + if (snapshot.preferences === undefined) { + const seeds = await Promise.all( + hosts.map((host) => options.push.settings(host).catch(() => undefined)), + ); + const seed = + seeds.find((entry) => entry?.registered) ?? seeds.find((entry) => entry !== undefined); + if (seed !== undefined) save(seed.preferences); + } + const preferences = snapshot.preferences; + const results = await Promise.all( + hosts.map(async (host) => { + if (preferences === undefined) return host.hostId; + try { + await options.push.setPreferences(host, preferences); + return undefined; + } catch { + return host.hostId; + } + }), + ); + publish({ failed: results.filter((id): id is string => id !== undefined) }); + } finally { + publish({ syncing: false }); + scheduleRetry(); + } + }; + return { + ready, + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + sync(next: readonly SavedHost[]) { + hosts = next; + return enqueue(sync); + }, + retry() { + return enqueue(sync); + }, + setForeground(active: boolean) { + foreground = active; + scheduleRetry(); + }, + prepare(host: SavedHost) { + return enqueue(async () => { + const preferences = snapshot.preferences; + if ( + preferences === undefined || + !hosts.some( + (entry) => entry.hostId === host.hostId && entry.credential === host.credential, + ) + ) + throw new Error('notification-preferences-unavailable'); + await options.push.setPreferences(host, preferences); + }); + }, + set(patch: Partial) { + return enqueue(async () => { + save({ ...(snapshot.preferences ?? DEFAULT_NOTIFICATION_PREFERENCES), ...patch }); + await sync(); + }); + }, + }; +} diff --git a/apps/mobile/src/host/host-session.ts b/apps/mobile/src/host/host-session.ts index 299472a52f..2e1e4b64d9 100644 --- a/apps/mobile/src/host/host-session.ts +++ b/apps/mobile/src/host/host-session.ts @@ -19,6 +19,7 @@ import { avatarInputs } from './notification-avatars'; import { revokePairing } from './pairing'; import { createHostPush } from './push'; import { createPushSession, type PushStatus } from './push-session'; +import { notificationPreferences } from './native-notification-preferences'; import { createQuietChannels } from './quiet-channels'; import { loadSavedHosts, saveHosts, type SavedHost } from './saved-host'; import { mobileTelemetry } from '../telemetry/native'; @@ -50,6 +51,8 @@ export const pushSession = createPushSession({ 'remove-device': t.removeDevice, }), hostPush, + beforeRegister: () => notificationPreferences.retry(), + prepareRegistration: (host) => notificationPreferences.prepare(host), platform: Platform.OS === 'android' ? 'android' : 'ios', locale: locale === 'zh' ? 'zh' : 'en', }); @@ -63,8 +66,10 @@ function reflectInbox(hostId: string, inbox: Inbox): void { function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { mobileTelemetry.foreground(AppState.currentState === 'active', next.hosts.length); + notificationPreferences.setForeground(AppState.currentState === 'active'); const previous = state.saved; stored = next; + void notificationPreferences.sync(next.hosts).catch(() => undefined); const saved = currentHost(next) ?? null; if ( previous !== undefined && @@ -100,7 +105,10 @@ function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { offConnection = client?.subscribe(() => { const snapshot = client.getSnapshot(); const now = snapshot.connection.status === 'connected'; - if (now && !connected) void pushSession.retryFailed(); + if (now && !connected) { + void pushSession.retryFailed(); + void notificationPreferences.retry().catch(() => undefined); + } connected = now; const inputs = snapshot.roster === undefined ? undefined : avatarInputs(snapshot.roster); if ( @@ -135,7 +143,9 @@ void loadSavedHosts().then( AppState.addEventListener('change', (next) => { mobileTelemetry.foreground(next === 'active', state.hosts.length); + notificationPreferences.setForeground(next === 'active'); if (next !== 'active') return; + void notificationPreferences.retry().catch(() => undefined); pushSession.reflectAgain(); void pushSession.retryFailed(); state.client?.retryNow('resume'); diff --git a/apps/mobile/src/host/inbox.ts b/apps/mobile/src/host/inbox.ts index 77e1e94c19..c2f09c4923 100644 --- a/apps/mobile/src/host/inbox.ts +++ b/apps/mobile/src/host/inbox.ts @@ -24,6 +24,7 @@ export interface InboxQuery { sort?: 'default' | 'newest' | 'oldest'; section?: InboxSection | InboxSetAside | 'all'; activity?: boolean; + notifications?: boolean; } export type InboxSnapshot = @@ -35,6 +36,7 @@ export type InboxSnapshot = cursors?: Partial>; loadingMore?: InboxSection[]; sections: Record; + notificationItems?: HumanAttentionItem[] | undefined; unread?: Readonly>; stale?: string | undefined; } @@ -60,6 +62,7 @@ export function createInbox( let readAgain = false; let stopped = false; let revision = 0; + let connectionRevision = 0; const loading = new Set(); const setAside = options.section === 'cleared' || options.section === 'snoozed' ? options.section : undefined; @@ -96,6 +99,7 @@ export function createInbox( }; const readOnce = async (): Promise => { + const connectionAtStart = connectionRevision; revision += 1; loading.clear(); const retained = @@ -115,7 +119,29 @@ export function createInbox( } return { ...page, items: [...items.values()] }; }; - const [pages, counts] = await Promise.all([ + const readNotifications = async (): Promise => { + if (!options.notifications) return undefined; + const items = new Map(); + const cursors = new Set(); + let cursor: string | undefined; + do { + if (stopped || client.getSnapshot().connection.status !== 'connected') return undefined; + const page = await client.call('humanAttention', { + notifications: true, + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }); + if (page.notificationVersion !== 1) return undefined; + for (const item of page.items) items.set(item.id, item); + cursor = page.nextCursor; + if (cursor !== undefined) { + if (cursors.has(cursor)) throw new Error('notification-cursor-repeated'); + cursors.add(cursor); + } + } while (cursor !== undefined); + return [...items.values()]; + }; + const [pages, counts, notificationItems] = await Promise.all([ Promise.all( INBOX_SECTIONS.map((category) => options.activity && @@ -129,11 +155,12 @@ export function createInbox( ), ), client.call('channelUnreadCounts').catch(() => ({ channels: [] })), + readNotifications().catch(() => undefined), ]); const unread = Object.fromEntries( counts.channels.map((entry) => [entry.channelId, entry.unreadCount]), ); - if (stopped) return; + if (stopped || readAgain || connectionAtStart !== connectionRevision) return; const [action, replies, otherUnread, handled] = pages.map((page) => page.items) as [ HumanAttentionItem[], HumanAttentionItem[], @@ -150,6 +177,7 @@ export function createInbox( ), loadingMore: [], sections: { action, replies, unread: otherUnread, handled }, + notificationItems, unread, }); }; @@ -188,6 +216,7 @@ export function createInbox( let wasConnected = false; const offClient = client.subscribe(() => { const connected = client.getSnapshot().connection.status === 'connected'; + if (connected !== wasConnected) connectionRevision += 1; if (connected && !wasConnected) background(); wasConnected = connected; }); @@ -251,6 +280,24 @@ export function actionCount(snapshot: InboxSnapshot): number { return snapshot.status === 'ready' ? snapshot.sections.action.length : 0; } +export async function prepareNotificationBaseline( + inbox: Inbox, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const before = inbox.getSnapshot(); + await inbox.refresh(); + signal.throwIfAborted(); + const snapshot = inbox.getSnapshot(); + if ( + snapshot === before || + snapshot.status !== 'ready' || + snapshot.stale || + snapshot.notificationItems === undefined + ) + throw new Error('notification-baseline-unavailable'); +} + type InboxObservation = { getSnapshot(): InboxSnapshot; getInbox(): Inbox | undefined; diff --git a/apps/mobile/src/host/native-notification-preferences.ts b/apps/mobile/src/host/native-notification-preferences.ts new file mode 100644 index 0000000000..dde1858ced --- /dev/null +++ b/apps/mobile/src/host/native-notification-preferences.ts @@ -0,0 +1,18 @@ +import { File, Paths } from 'expo-file-system'; +import { createDeviceNotificationPreferences } from './device-notification-preferences'; +import { nativeCarrier } from './native-carrier'; +import { createHostPush } from './push'; + +const file = () => new File(Paths.document, 'deepseekbot-notification-preferences.json'); +export const notificationPreferences = createDeviceNotificationPreferences({ + storage: { + async read() { + const saved = file(); + return saved.exists ? JSON.parse(await saved.text()) : undefined; + }, + write(preferences) { + file().writeSync(JSON.stringify(preferences)); + }, + }, + push: createHostPush(nativeCarrier()), +}); diff --git a/apps/mobile/src/host/push-session.ts b/apps/mobile/src/host/push-session.ts index 1577812e0b..7b36624ccb 100644 --- a/apps/mobile/src/host/push-session.ts +++ b/apps/mobile/src/host/push-session.ts @@ -42,6 +42,8 @@ export function createPushSession(options: { platform: 'ios' | 'android'; locale: 'en' | 'zh'; tokenTimeoutMs?: number; + beforeRegister?: () => Promise; + prepareRegistration?: (host: SavedHost) => Promise; }): PushSession { const { notifications, hostPush } = options; const listeners = new Set<() => void>(); @@ -56,6 +58,12 @@ export function createPushSession(options: { }; const registerAll = async (hosts: readonly SavedHost[]): Promise => { + try { + await options.beforeRegister?.(); + } catch { + publish({ status: 'unavailable', reason: 'notification-preferences-unavailable' }); + return; + } let token: string; try { let timer: ReturnType | undefined; @@ -77,13 +85,16 @@ export function createPushSession(options: { } const results = await Promise.all( hosts.map((host) => - hostPush - .register(host, { - token, - platform: options.platform, - locale: options.locale, - ...(options.platform === 'android' ? { render: 'app' as const } : {}), - }) + Promise.resolve() + .then(() => options.prepareRegistration?.(host)) + .then(() => + hostPush.register(host, { + token, + platform: options.platform, + locale: options.locale, + ...(options.platform === 'android' ? { render: 'app' as const } : {}), + }), + ) .then( () => undefined, () => host.hostId, diff --git a/apps/mobile/src/host/wire.ts b/apps/mobile/src/host/wire.ts index 7137474536..2a306c018b 100644 --- a/apps/mobile/src/host/wire.ts +++ b/apps/mobile/src/host/wire.ts @@ -407,6 +407,7 @@ export interface HumanAssignmentContext { export interface HumanAttentionPage { queryVersion?: number; + notificationVersion?: number; items: HumanAttentionItem[]; nextCursor?: string; } diff --git a/apps/mobile/src/ui/notification-settings.tsx b/apps/mobile/src/ui/notification-settings.tsx index 464c0fa05c..4fbe26aa0a 100644 --- a/apps/mobile/src/ui/notification-settings.tsx +++ b/apps/mobile/src/ui/notification-settings.tsx @@ -9,6 +9,7 @@ import { type PushSettings, } from '../host/push'; import type { PushStatus } from '../host/push-session'; +import type { DevicePreferenceSnapshot } from '../host/device-notification-preferences'; import { space } from './design/tokens'; import { useDesignTheme } from './design/theme'; import { useMounted } from './mounted'; @@ -20,7 +21,7 @@ import { t } from './strings'; export interface NotificationSettingsPort { load(): Promise; - set(preferences: Partial): Promise; + set(preferences: Partial): Promise; setEnabled(enabled: boolean): Promise; test(): Promise; } @@ -58,12 +59,14 @@ export function NotificationSettings({ status, hostName, port, + preference, onEnable, onOpenSettings, }: { status: PushStatus; hostName: string; port: NotificationSettingsPort; + preference?: DevicePreferenceSnapshot; onEnable(): void; onOpenSettings(): void; }) { @@ -80,16 +83,25 @@ export function NotificationSettings({ port.load().then(setSettings, (reason: unknown) => setError(failure(reason))); }, [port]); const mount = useMounted(() => { - if (on) load(); + load(); }, [on, load]); const toggle = (kind: PushKind, value: boolean) => { - if (settings === undefined) return; + if (settings === undefined && preference?.preferences === undefined) return; + if (settings === undefined) { + void port.set({ [kind]: value }).catch((reason: unknown) => setError(failure(reason))); + return; + } setSettings({ ...settings, preferences: { ...settings.preferences, [kind]: value } }); - port.set({ [kind]: value }).then(setSettings, (reason: unknown) => { - setError(failure(reason)); - load(); - }); + port.set({ [kind]: value }).then( + (next) => { + if (next !== undefined) setSettings(next); + }, + (reason: unknown) => { + setError(failure(reason)); + load(); + }, + ); }; const allow = (enabled: boolean) => { @@ -148,6 +160,9 @@ export function NotificationSettings({ ) : null} {error !== undefined ? {error} : null} + {preference?.failed.length ? ( + {t.notificationPreferencePending} + ) : null} {on && settings === undefined && error === undefined ? : null} {on && settings !== undefined ? ( ) : null} - {on && settings?.enabled === true ? ( + {preference?.preferences !== undefined || (on && settings?.enabled === true) ? ( {t.notifyAbout} @@ -182,9 +197,9 @@ export function NotificationSettings({ trailing={ toggle(kind, value)} - disabled={!settings.registered} + disabled={preference?.syncing ?? !settings?.registered} /> } /> @@ -196,7 +211,7 @@ export function NotificationSettings({ tone="secondary" onPress={sendTest} busy={test?.state === 'sending'} - disabled={!settings.registered} + disabled={!on || !settings?.registered} /> {test?.state === 'sent' ? {t.testSent} : null} diff --git a/apps/mobile/src/ui/shell/notification-screen.tsx b/apps/mobile/src/ui/shell/notification-screen.tsx index 3a46492c7f..756f9062fe 100644 --- a/apps/mobile/src/ui/shell/notification-screen.tsx +++ b/apps/mobile/src/ui/shell/notification-screen.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { Linking } from 'react-native'; import { @@ -9,21 +9,36 @@ import { pushSession, } from '../../host/host-session'; import { NotificationSettings, type NotificationSettingsPort } from '../notification-settings'; +import { notificationPreferences } from '../../host/native-notification-preferences'; export default function NotificationsScreen() { const { saved } = useHostSession(); const status = usePushStatus(); + const preference = useSyncExternalStore( + notificationPreferences.subscribe, + notificationPreferences.getSnapshot, + ); const port = useMemo( () => saved === null || saved === undefined ? undefined : { - load: () => hostPush.settings(saved), - set: (preferences) => hostPush.setPreferences(saved, preferences), + load: async () => { + await notificationPreferences.ready; + const settings = await hostPush.settings(saved); + return { + ...settings, + preferences: + notificationPreferences.getSnapshot().preferences ?? settings.preferences, + }; + }, + set: async (preferences) => { + await notificationPreferences.set(preferences); + }, setEnabled: (enabled) => hostPush.setEnabled(saved, enabled), test: () => hostPush.test(saved), }, - [saved], + [saved, status.status], ); if (saved === null || saved === undefined || port === undefined) return null; return ( @@ -31,6 +46,7 @@ export default function NotificationsScreen() { status={status} hostName={saved.hostName} port={port} + preference={preference} onEnable={() => void pushSession.enable(hostSession.getState().hosts)} onOpenSettings={() => void Linking.openSettings()} key={saved.hostId} diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts index 5c15e8f039..7fac8fb672 100644 --- a/apps/mobile/src/ui/strings.ts +++ b/apps/mobile/src/ui/strings.ts @@ -889,7 +889,8 @@ const en = { connected: 'Connected', notifications: 'Notifications', notificationsChannel: 'Requests from Bots', - notificationsHint: 'Get notified when a Bot needs you or replies. Private input is never shown.', + notificationsHint: + 'These alert types apply to every Host paired with this phone. Private input stays hidden.', notificationsOn: (host: string) => `Notifications from ${host} are on.`, notificationsOff: 'Notifications are off for this phone.', notificationsDenied: 'Notifications are turned off for DeepSeekBot in the phone settings.', @@ -899,6 +900,7 @@ const en = { turnOnNotifications: 'Turn on notifications', openSettings: 'Open phone settings', notifyAbout: 'Notify me about', + notificationPreferencePending: 'Saved on this phone. Some Hosts will sync when reachable.', allowNotifications: 'Allow notifications', allowNotificationsOff: 'This phone gets no notifications from this computer. Your choices below come back when you turn them on again.', @@ -2121,7 +2123,7 @@ const zh: typeof en = { connected: '已连接', notifications: '通知', notificationsChannel: 'Bot 的请求', - notificationsHint: 'Bot 需要你或回复你时通知你。私密输入不会显示。', + notificationsHint: '通知类型适用于此手机配对的所有远程。私密输入不会显示。', notificationsOn: (host: string) => `已开启来自 ${host} 的通知。`, notificationsOff: '这台手机的通知已关闭。', notificationsDenied: '手机设置里关闭了 DeepSeekBot 的通知。', @@ -2131,6 +2133,7 @@ const zh: typeof en = { turnOnNotifications: '开启通知', openSettings: '打开手机设置', notifyAbout: '通知我', + notificationPreferencePending: '已保存在此手机。部分远程恢复连接后会同步。', allowNotifications: '允许通知', allowNotificationsOff: '这台手机不会收到这台电脑的任何通知。重新打开后,下面的选择会恢复。', channelNotifications: { diff --git a/apps/mobile/test/device-notification-preferences.test.ts b/apps/mobile/test/device-notification-preferences.test.ts new file mode 100644 index 0000000000..b0cc222eb7 --- /dev/null +++ b/apps/mobile/test/device-notification-preferences.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDeviceNotificationPreferences, + DEFAULT_NOTIFICATION_PREFERENCES, +} from '../src/host/device-notification-preferences'; +import type { SavedHost } from '../src/host/hosts'; +import type { PushPreferences } from '../src/host/push'; + +const host = (id: string, credential = id): SavedHost => ({ + hostId: id, + hostName: id, + deviceId: credential, + credential, + routes: [`https://${id}.ts.net`], +}); + +function fixture(saved?: PushPreferences) { + let disk = saved; + const copies = new Map(); + const offline = new Set(); + const settings = vi.fn(async (entry: SavedHost) => { + if (offline.has(entry.hostId)) throw new Error('offline'); + return { + registered: true, + enabled: true, + quietChannelIds: [], + preferences: { ...DEFAULT_NOTIFICATION_PREFERENCES, approval: false }, + }; + }); + const options = { + storage: { + read: async () => disk, + write: (value: PushPreferences) => { + disk = value; + }, + }, + push: { + settings, + setPreferences: vi.fn(async (entry: SavedHost, preferences: Partial) => { + if (offline.has(entry.hostId)) throw new Error('offline'); + const copy = { ...DEFAULT_NOTIFICATION_PREFERENCES, ...preferences }; + copies.set(entry.credential, copy); + return { registered: false, enabled: true, quietChannelIds: [], preferences: copy }; + }), + }, + }; + return { + prefs: createDeviceNotificationPreferences(options), + options, + copies, + offline, + disk: () => disk, + }; +} + +describe('phone-owned notification preferences', () => { + it('drops removed Hosts from retry and resets backoff after success', async () => { + vi.useFakeTimers(); + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + try { + f.prefs.setForeground(true); + f.offline.add('b'); + await f.prefs.sync([host('a'), host('b')]); + await vi.advanceTimersByTimeAsync(1000); + await f.prefs.sync([host('a')]); + const calls = f.options.push.setPreferences.mock.calls.length; + await vi.advanceTimersByTimeAsync(60_000); + expect(f.options.push.setPreferences).toHaveBeenCalledTimes(calls); + expect(vi.getTimerCount()).toBe(0); + await f.prefs.sync([host('a'), host('b')]); + f.offline.clear(); + await vi.advanceTimersByTimeAsync(1000); + expect(f.prefs.getSnapshot().failed).toEqual([]); + } finally { + f.prefs.setForeground(false); + vi.useRealTimers(); + } + }); + it('retries a noncurrent Host while A stays connected and pauses in the background', async () => { + vi.useFakeTimers(); + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + try { + f.prefs.setForeground(true); + f.offline.add('b'); + await f.prefs.sync([host('a'), host('b')]); + await f.prefs.set({ reply: false }); + await vi.advanceTimersByTimeAsync(1000); + expect(f.prefs.getSnapshot().failed).toEqual(['b']); + f.prefs.setForeground(false); + const calls = f.options.push.setPreferences.mock.calls.length; + f.offline.clear(); + await vi.advanceTimersByTimeAsync(60_000); + expect(f.options.push.setPreferences).toHaveBeenCalledTimes(calls); + f.prefs.setForeground(true); + await vi.advanceTimersByTimeAsync(1000); + expect(f.copies.get('b')?.reply).toBe(false); + expect(f.prefs.getSnapshot().failed).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + } finally { + f.prefs.setForeground(false); + vi.useRealTimers(); + } + }); + it('seeds once from existing switches and copies every change to every pairing', async () => { + const f = fixture(); + await f.prefs.sync([host('a'), host('b')]); + expect(f.disk()?.approval).toBe(false); + await f.prefs.set({ question: false }); + expect(f.copies.get('a')).toEqual(f.copies.get('b')); + expect(f.copies.get('b')?.question).toBe(false); + const reloaded = createDeviceNotificationPreferences(f.options); + await reloaded.sync([host('a', 'repaired'), host('c')]); + expect(f.copies.get('repaired')?.question).toBe(false); + expect(f.copies.get('c')?.approval).toBe(false); + expect(f.options.push.settings).toHaveBeenCalledTimes(2); + }); + it('keeps offline choices, reports the failed Host and retries without reseeding', async () => { + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + f.offline.add('b'); + await f.prefs.sync([host('a'), host('b')]); + await f.prefs.set({ reply: false }); + expect(f.disk()?.reply).toBe(false); + expect(f.prefs.getSnapshot().failed).toEqual(['b']); + f.offline.clear(); + await f.prefs.retry(); + expect(f.copies.get('b')?.reply).toBe(false); + expect(f.options.push.settings).not.toHaveBeenCalled(); + }); + it('serializes rapid patches and refuses to overwrite unreadable device storage', async () => { + const f = fixture(); + await f.prefs.sync([host('a')]); + await Promise.all([f.prefs.set({ question: false }), f.prefs.set({ reply: false })]); + expect(f.copies.get('a')).toMatchObject({ question: false, reply: false }); + const broken = createDeviceNotificationPreferences({ + ...f.options, + storage: { read: async () => ({ broken: true }), write: vi.fn() }, + }); + await expect(broken.set({ approval: true })).rejects.toThrow( + 'notification-preferences-invalid', + ); + }); + it('does not establish defaults while all legacy Hosts are unreachable', async () => { + const f = fixture(); + f.offline.add('a'); + await f.prefs.sync([host('a')]); + expect(f.disk()).toBeUndefined(); + f.offline.clear(); + await f.prefs.retry(); + expect(f.disk()?.approval).toBe(false); + }); + it('copies the latest choice before token registration and refuses replaced credentials', async () => { + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + await f.prefs.sync([host('a')]); + await Promise.all([f.prefs.set({ reply: false }), f.prefs.prepare(host('a'))]); + expect(f.options.push.setPreferences).toHaveBeenLastCalledWith( + host('a'), + expect.objectContaining({ reply: false }), + ); + await f.prefs.sync([host('a', 'new')]); + await expect(f.prefs.prepare(host('a'))).rejects.toThrow( + 'notification-preferences-unavailable', + ); + }); +}); diff --git a/apps/mobile/test/host-session-unpair.test.ts b/apps/mobile/test/host-session-unpair.test.ts index b769073cbf..ef5ebc8ca1 100644 --- a/apps/mobile/test/host-session-unpair.test.ts +++ b/apps/mobile/test/host-session-unpair.test.ts @@ -64,6 +64,14 @@ vi.mock('../src/host/native-notifications', () => ({ vi.mock('../src/host/native-notification-avatars', () => ({ notificationAvatars: { sync: async () => true }, })); +vi.mock('../src/host/native-notification-preferences', () => ({ + notificationPreferences: { + setForeground: () => undefined, + sync: async () => undefined, + retry: async () => undefined, + prepare: async () => undefined, + }, +})); vi.mock('../src/telemetry/native', () => ({ mobileTelemetry: { foreground: () => undefined, send: () => undefined }, })); diff --git a/apps/mobile/test/notification-discovery.test.ts b/apps/mobile/test/notification-discovery.test.ts new file mode 100644 index 0000000000..e21f8d0a34 --- /dev/null +++ b/apps/mobile/test/notification-discovery.test.ts @@ -0,0 +1,87 @@ +import { expect, it, vi } from 'vitest'; +import { createInbox, prepareNotificationBaseline } from '../src/host/inbox'; +import type { HostClient } from '../src/host/client'; +import type { HumanAttentionItem } from '../src/host/wire'; + +const item = (id: string): HumanAttentionItem => ({ + id, + kind: 'channel-reply', + category: 'replies', + botSlug: 'ada', + summary: id, + createdAt: '2026-10-01T00:00:00Z', +}); + +it('publishes only complete notification discovery and restarts a scan invalidated between pages', async () => { + let live = () => {}; + let fail = false; + let mutate = false; + let changed = false; + const call = vi.fn(async (method: string, args?: Record) => { + if (method === 'channelUnreadCounts') return { channels: [] }; + if (!args?.notifications) return { queryVersion: 1, items: [] }; + if (!args.cursor) + return { + notificationVersion: 1, + items: [item(changed ? 'inserted' : 'withdrawn')], + nextCursor: 'same-time-page', + }; + if (fail) throw Error('page-unavailable'); + if (mutate && !changed) { + changed = true; + live(); + } + return { notificationVersion: 1, items: [item('dm')] }; + }); + const unavailable = () => { + throw Error('unused client operation'); + }; + const client: HostClient = { + start: unavailable, + stop: unavailable, + retryNow: unavailable, + refresh: unavailable, + upload: unavailable, + api: unavailable, + resource: unavailable, + follow: unavailable, + getSnapshot: () => ({ connection: { status: 'connected', route: '' } }), + subscribe: () => () => {}, + onLive: (listener) => { + live = () => listener('activity'); + return () => {}; + }, + call: call as HostClient['call'], + }; + const inbox = createInbox(client, { activity: true, notifications: true }); + const published: string[][] = []; + inbox.subscribe(() => { + const snapshot = inbox.getSnapshot(); + if (snapshot.status === 'ready' && snapshot.notificationItems) + published.push(snapshot.notificationItems.map((item) => item.id)); + }); + try { + await inbox.refresh(); + expect(published.at(-1)).toEqual(['withdrawn', 'dm']); + published.length = 0; + mutate = true; + await inbox.refresh(); + expect(published).toEqual([['inserted', 'dm']]); + fail = true; + await expect(prepareNotificationBaseline(inbox, new AbortController().signal)).rejects.toThrow( + 'notification-baseline-unavailable', + ); + expect(inbox.getSnapshot()).toMatchObject({ status: 'ready', notificationItems: undefined }); + expect(published).toHaveLength(1); + fail = false; + await prepareNotificationBaseline(inbox, new AbortController().signal); + expect(published.at(-1)).toEqual(['inserted', 'dm']); + expect( + call.mock.calls + .filter(([, args]) => args?.notifications) + .every(([, args]) => args?.sort === undefined && args?.category === undefined), + ).toBe(true); + } finally { + inbox.stop(); + } +}); diff --git a/apps/mobile/test/notification-settings.test.tsx b/apps/mobile/test/notification-settings.test.tsx index ad7bafa887..a44c24d1c3 100644 --- a/apps/mobile/test/notification-settings.test.tsx +++ b/apps/mobile/test/notification-settings.test.tsx @@ -60,6 +60,32 @@ const notice = (container: HTMLElement, tone: string) => [...container.querySelectorAll(`[data-testid="notice-${tone}"]`)].map((node) => node.textContent); describe('Notification settings', () => { + it('keeps device choices editable without system permission or a push token', async () => { + const settings = port({ + load: async () => { + throw new Error('offline'); + }, + set: vi.fn(async () => undefined), + }); + const view = await render( + {}} + onOpenSettings={() => {}} + />, + ); + const reply = view.querySelector(`[aria-label="${t.pushKind.reply}"]`)!; + expect(reply.checked).toBe(true); + expect(reply.disabled).toBe(false); + await act(async () => reply.click()); + expect(settings.set).toHaveBeenCalledWith({ reply: false }); + expect(view.textContent).toContain(t.notificationPreferencePending); + expect(settings.test).not.toHaveBeenCalled(); + }); + it('offers to turn notifications on before the phone allows them', async () => { const onEnable = vi.fn(); const view = await render( diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md new file mode 100644 index 0000000000..27fd6ce589 --- /dev/null +++ b/changes/2500-device-notifications.md @@ -0,0 +1,13 @@ +--- +section: Changed +--- + +## en + +- Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Browser discovery includes ordinary DM replies and replies beyond the Activity page; enabling waits for a complete baseline so pending old items stay silent. Newly admitted attention is deduplicated by identity even when its Source Event is older; previously seen items do not replay after leaving and reentering attention. Temporary Activity read failures preserve the notification baseline so new items still alert after recovery. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). + +## zh + +- 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复,启用前等待完整基线,避免补发扫描中的旧条目。新进入 attention 的条目按身份去重,不因来源事件较早而漏报;已见条目离开后再进入也不重播。Activity 读取短暂失败时保留通知基线,恢复后仍提醒新条目。手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index dc67458e48..e231ed7989 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1142,7 +1142,9 @@ The application-defined browser `RemoteHosts` owner keeps its pairings and selec Remote conversation presentation retains the source Channel Journal entry through the shared conversation and message-display modules, then uses the existing Web message body and request cards. A conversation-scoped adapter supplies the source Host client and validates the active Host/Channel before decisions. Attachment reads resolve through that client's `resource(path)` and device credential, with no local-store or local-URL fallback; changing conversations aborts reads and releases object URLs. Activity and conversation cards share `createHostRequestActions`, with request references from the Inbox and Journal respectively. Only a Host-accepted decision completes a submission; stale decisions trigger the existing cards' status reload. Decisions reuse the shared attention actions and remain subject to the source Host's ownership and permission checks. -Activity Center consumes each Remote Host's shared Inbox through `RemoteHosts.activity`, reusing the existing `HumanInboxView` for Host groups. Its Host-scoped source supplies identity, pagination, source navigation and commands; it never receives This Host's `BridgeActions`. `createHostActivityActions` adapts the shared attention and request operations to the existing approval and question cards. Each operation resolves the current pairing before calling its Host client, and confirmed changes refresh that Host's Inbox. Filtered observers share the Host client and stop with their last subscriber. This Host still reads its existing bridge/store until that Host Feed slice migrates. Device Notification Preference remains a subsequent device-owned slice; the browser has no closed-page notification path. +Activity Center consumes each Remote Host's shared Inbox through `RemoteHosts.activity`, reusing the existing `HumanInboxView` for Host groups. Its Host-scoped source supplies identity, pagination, source navigation and commands; it never receives This Host's `BridgeActions`. `createHostActivityActions` adapts the shared attention and request operations to the existing approval and question cards. Each operation resolves the current pairing before calling its Host client, and confirmed changes refresh that Host's Inbox. Filtered observers share the Host client and stop with their last subscriber. This Host still reads its existing bridge/store until that Host Feed slice migrates. + +The phone owns its persisted four-kind Device Notification Preference and copies it to every paired Host on changes, pairing and retry. Existing Host switches seed it only before a device choice exists. The Host stores the copy independently of its Expo token and reads it only for delivery; Human Inbox facts are unchanged. A browser owns per-Remote-Host choices and consumes the shared Inbox while subscribed, suppressing the initial snapshot and reconnect backlog, deduplicating Host-scoped items and closing notifications on disposal. The browser retains exact seen attention identities per Host and credential for the active owner lifetime, including ordinary reconnects and overlapping subscriber changes. Source Event creation time is not attention arrival order. While the same Host connection and credential remain active, stale or failed Inbox reads preserve an established notification baseline and its seen identities; incomplete responses are not consumed. A connection interruption requires a new complete silent baseline for the reconnect backlog but retains identity history. An owner without a completed baseline stays uninitialized through read failures. There is no timestamp watermark or identity eviction: memory is O(distinct identities seen), not bounded to 2,000 entries. Removing the Host, replacing its credential or releasing the final subscriber clears this history; the next owner establishes a complete silent baseline. The browser has no closed-page notification path. Notification discovery uses the existing Host Feed client and live invalidation, with `humanAttention({ notifications: true })` paging Host-decided actions, unread group mentions/replies and ordinary DM replies independently of the Activity UI window. The shared Inbox publishes notification items only after every page completes; invalidation during a scan restarts it, and a failed page cannot replace the notification baseline. Browser activation keeps the previous choice until a fresh complete shared-Inbox scan succeeds; this acknowledged commit is the enable boundary, so an in-flight backlog cannot become new alerts. Activation has a ten-second deadline and is cancelled on target change, settings close, permission loss, removal or disposal; these cancellations do not create another feed. `deepseekbot inbox --notifications` exposes the same Host query. Phone copy failures retry while foreground with bounded backoff (1–30 seconds), without subscribing to every paired Host feed; backgrounding cancels the timer. Development-client AX invokes the existing phone owner through a precisely targeted loopback Metro inspector (`phone-notifications`); this does not provide release-client CLI parity. Remote Host settings resolve the selected Host's existing client from `RemoteHosts`. The browser reuses `apps/mobile/src/host/model-settings`, `usage` and `storage` for default models, presets, usage/Memory activity and cleanup; the existing Web settings, model picker, charts and destructive confirmation controls present those operations. Settings unmount their content when the source is disconnected or removed. Model resources are keyed by client and scope; there is no This Host fallback, Bot-profile link or automatic write replay. Cleanup retains the owning Host's preview token, scope and explicit confirmation. Host looks remain device-local and independent of these Host-owned settings. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index 80fa872b71..204cf2a2f9 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -6,6 +6,14 @@ Bot summaries from `list` also include `lastActivityAt` (epoch milliseconds) whe ## Usage +`deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. Enabling a previously inactive kind first refreshes the shared Inbox through every notification page, keeping the old choice until that complete baseline is ready. The successful write is the enable boundary: earlier items stay silent and later group/DM replies can alert. A failed page returns `notification-baseline-unavailable`; a ten-second UI baseline deadline returns `notification-baseline-timeout`. CLI writes also carry the existing appearance relay deadline (currently eight seconds) and its cancellation signal to the same owner. The owner checks both immediately before persistence; a relay timeout or unmount cannot be followed by a late write, and an expired notification command sends no late acknowledgement. Switching Host, closing settings, removing the pairing, losing permission or releasing the page cancels pending activation (`notification-activation-cancelled`); it cannot persist later. Retry explicitly after a refusal. Normal shared Host Feed observation continues while the page remains subscribed. + +`deepseekbot phone-notifications --metro-port --target-id --device-name ` reads the existing phone preference owner in an assigned Expo development client. Add `--preferences-stdin` with exactly the four boolean kinds to persist through that same owner and copy to every paired Host. It requires the shared-device batch/lease, a running loopback Metro inspector, an exact unique target, the development app ID, `__DEV__`, and the uniquely initialized owner. It never writes storage directly, creates another owner, enables OS permission, or treats Host copies as the phone choice. One JSON result distinguishes `saved` from `syncStatus` (`complete`/`pending`), `syncing`, and `failedHostIds`. A disconnected/timed-out write has an unknown outcome: read again before repeating it. Missing/ambiguous targets, release clients, unready owners and transport failures return coded refusals. **This development-only AX path is unavailable in release clients; it is not release CLI parity or completed native QA.** Native real runs remain in the external combined acceptance batch. + +`deepseekbot inbox --notifications [--limit 1..100] [--cursor ]` uses the Host's existing `humanAttention` query with notification discovery enabled. Follow every returned cursor to obtain Host-decided actions, unread group mentions/replies and ordinary unread DM replies. This is independent of Activity's mentions-first page; filters cannot be combined with this mode. `notificationVersion: 1` is required, with a coded refusal on older Hosts. It opens no additional feed connection and does not change attention or unread state. + +`deepseekbot device-notifications --device-file ` reads the admitted device's send-preference copy from a live Host. Add `--preferences-stdin` and pipe `{"approval":true,"question":true,"private-input":true,"reply":false}` to replace its four kinds, even before a push token is registered. The private file has the saved pairing shape (`hostId`, `hostName`, `routes`, `deviceId`, `credential`); never put its credential in arguments or publish the file. Output is one JSON document without credentials; revoked pairings return `device-revoked`. This edits that Host's delivery copy only; the phone's persisted preference is authoritative and overwrites it on the next sync. Browser notification choices remain local to that browser and do not change Host attention. + ```bash deepseekbot create --name [--persona | --persona-stdin] [--core-memory | --core-memory-stdin] diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 68c0e816d6..03e5ac4373 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -6,6 +6,14 @@ ## 用法 +`deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 开启原先未启用的类型时,先通过共享 Inbox 读完全部通知分页,完整基线就绪前保持旧选择;写入成功才是启用边界,此前条目静默,此后的群回复和 DM 可提醒。部分页失败返回 `notification-baseline-unavailable`,UI 基线的十秒期限返回 `notification-baseline-timeout`。CLI 写入还将既有 appearance relay 的请求期限(当前八秒)和取消信号传到同一 owner,并在落盘前再次检查;relay 超时或卸载后,迟到分页不能写入,已过期的通知命令不发送迟到回执。切换 Host、关闭设置、移除配对、权限丢失或释放页面会取消待完成的启用(`notification-activation-cancelled`),迟到结果不能落盘。拒绝后显式重试;页面仍订阅时,正常共享 Host Feed 观察继续。 + +`deepseekbot phone-notifications --metro-port <端口> --target-id <精确ID> --device-name <精确名称>` 在已分配的 Expo development client 中读取现有手机偏好 owner。加 `--preferences-stdin`,仅传入四类布尔值,由同一 owner 持久化并复制到全部已配对 Host。前提是共享设备 batch/lease、运行中的 loopback Metro inspector、精确且唯一的目标、开发版 app ID、`__DEV__` 和唯一已初始化的 owner。命令不直接写存储、不新建 owner、不弹系统授权、不把 Host 副本当手机选择。单个 JSON 分别返回 `saved`、`syncStatus`(`complete`/`pending`)、`syncing` 和 `failedHostIds`。断开/超时后的写入结果未知,应先重新读取再决定是否重试。目标缺失/歧义、release、owner 未就绪及传输失败均明确拒绝。**该 AX 路径仅限 development client,release 不可用,也不代表 release CLI parity 或原生 QA 已通过。** 原生真实运行仍交外部统一批次验收。 + +`deepseekbot inbox --notifications [--limit 1..100] [--cursor <游标>]` 使用 Host 现有 `humanAttention` 的通知发现模式。遍历全部返回游标,取得 Host 判定的待办、未读群提及/回复和普通未读 DM 回复。它独立于 Activity 的 mentions-first 分页窗口,不可混用筛选条件。响应须有 `notificationVersion: 1`,旧 Host 明确拒绝;不增加 Feed 连接,不修改 attention 或已读状态。 + +`deepseekbot device-notifications --device-file <私有配对.json>` 从运行中的 Host 读取当前配对设备的通知发送副本。加上 `--preferences-stdin`,从标准输入传入 `{"approval":true,"question":true,"private-input":true,"reply":false}`,即可替换四种通知选择,无需先注册推送 token。私有文件使用已保存配对的结构(`hostId`、`hostName`、`routes`、`deviceId`、`credential`);凭据不能放入参数或公开文件。标准输出仅有一个不含凭据的 JSON,配对撤销后返回 `device-revoked`。命令只修改该 Host 的发送副本;手机持久化选择仍是权威,下次同步会覆盖副本。浏览器通知选择只保存在该浏览器,不影响 Host attention。 + ```bash deepseekbot create --name [--persona | --persona-stdin] [--core-memory | --core-memory-stdin] diff --git a/packages/client/src/client/appearance-control.ts b/packages/client/src/client/appearance-control.ts index 985154dcb1..9dfc4b33a4 100644 --- a/packages/client/src/client/appearance-control.ts +++ b/packages/client/src/client/appearance-control.ts @@ -3,6 +3,7 @@ import { WallpaperError } from './wallpaper-store.js'; import type { Wallpaper } from './wallpaper.js'; import { createWallpaperControl } from './wallpaper-control.js'; import { Appearance, AppearanceError, object } from './appearance.js'; +import { remoteHosts } from './remote-hosts.js'; export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper): () => void { const abort = new AbortController(); @@ -38,7 +39,25 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) try { if (typeof command.expiresAt !== 'number' || Date.now() > command.expiresAt) throw new AppearanceError('appearance-request-stale'); - if (command.recovery !== undefined) { + if (command.notifications !== undefined) { + const sources = remoteHosts(); + const input = object(command.notifications); + if ( + typeof input.hostId !== 'string' || + !sources.getSnapshot().hosts.some((entry) => entry.host.hostId === input.hostId) + ) + throw new AppearanceError('notification-host-unknown'); + try { + result = await sources.notifications.control(input, command.expectedRevision, { + expiresAt: command.expiresAt, + signal: abort.signal, + }); + } catch (error) { + throw new AppearanceError( + error instanceof Error ? error.message : 'notification-client-failed', + ); + } + } else if (command.recovery !== undefined) { if (!wallpaper) throw new WallpaperError('wallpaper-unavailable'); result = await resetAppearance( owner, @@ -77,6 +96,13 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) }, }; } + if (abort.signal.aborted) return; + if ( + command.notifications !== undefined && + typeof command.expiresAt === 'number' && + Date.now() >= command.expiresAt + ) + continue; await post({ action: 'reply', ...registration, requestId: command.id, result }); } } catch { diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index 4c4170e1b0..c0ce7cc55e 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -13,6 +13,7 @@ import { TextField } from './ds/field.js'; import { Tooltip } from './ds/tooltip.js'; import { border, color, radius, size, space, text } from './ds/tokens.stylex.js'; import type { BotHarnessKey, BotHarnessTranslate } from './locale.js'; +import { RemoteNotificationSettings } from './remote-notification-settings.js'; import { HOST_COLOR_IDS, HOST_COLORS, @@ -262,6 +263,7 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null ); const [menu, setMenu] = useState<{ hostId: string; point: MenuPoint } | undefined>(undefined); const [removing, setRemoving] = useState(undefined); + const [notificationHost, setNotificationHost] = useState(); const [styling, setStyling] = useState<{ hostId: string; name: string } | undefined>(undefined); const [settings, setSettings] = useState<{ hostId: string; name: string }>(); const proxy = useRef(null); @@ -339,6 +341,7 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null ? [] : [ { id: 'settings', label: t('remoteHosts.settings') }, + { id: 'notifications', label: t('remoteHosts.notifications') }, { id: 'remove', label: t('remoteHosts.remove.confirm'), danger: true }, ]), ]} @@ -348,6 +351,8 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null if (id === 'settings' && menuRemote !== undefined) setSettings({ hostId: menuRemote.host.hostId, name: menuRemote.host.hostName }); if (id === 'remove' && menuRemote !== undefined) setRemoving(menuRemote); + if (id === 'notifications' && menuRemote !== undefined) + setNotificationHost(menuRemote); }} onClose={() => setMenu(undefined)} /> @@ -370,6 +375,15 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null t={t} /> )} + {notificationHost === undefined ? null : ( + setNotificationHost(undefined)} + /> + )} {removing === undefined ? null : ( ); const t = ctx.locale.bind(LOCALE_NS); + remoteHosts(t); const zoom = mountWindowZoom({ document: typeof document === 'undefined' ? undefined : document, window: typeof window === 'undefined' ? undefined : window, diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index 2d829bc360..188de7f9eb 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -3947,6 +3947,18 @@ export const zh = { 'remoteHosts.look.save': '保存', 'remoteHosts.remote': '远程', 'remoteHosts.add': '添加远程', + 'remoteHosts.notifications': '远程通知', + 'remoteHosts.notifications.title': '{name} · 通知', + 'remoteHosts.notifications.hint': '仅在此页面打开时通知。选择保存在此浏览器。', + 'remoteHosts.notifications.enable': '允许通知', + 'remoteHosts.notifications.approval': '工具审批', + 'remoteHosts.notifications.question': '问题', + 'remoteHosts.notifications.private': '私密输入请求', + 'remoteHosts.notifications.privateBody': '需要你提供私密输入', + 'remoteHosts.notifications.reply': '提及和回复', + 'remoteHosts.notifications.failed': '无法保存通知设置,请重试。', + 'remoteHosts.notifications.denied': '请在浏览器的网站设置中允许通知。', + 'remoteHosts.notifications.unsupported': '此浏览器无法显示通知。', 'remoteHosts.add.description': '在远程 Host 的「设置 → 设备 → 配对手机」里复制配对链接,粘贴到这里。本机和远程需要在同一个 Tailscale 网络里。', 'remoteHosts.add.link': '配对链接', @@ -8321,6 +8333,18 @@ My relationship with you, established lore and topics to avoid: [fill in]. 'remoteHosts.look.save': 'Save', 'remoteHosts.remote': 'Remote', 'remoteHosts.add': 'Add remote', + 'remoteHosts.notifications': 'Remote notifications', + 'remoteHosts.notifications.title': '{name} · Notifications', + 'remoteHosts.notifications.hint': 'Alerts while this page is open. Choices stay in this browser.', + 'remoteHosts.notifications.enable': 'Allow notifications', + 'remoteHosts.notifications.approval': 'Tool approvals', + 'remoteHosts.notifications.question': 'Questions', + 'remoteHosts.notifications.private': 'Private input requests', + 'remoteHosts.notifications.privateBody': 'Needs private input from you', + 'remoteHosts.notifications.reply': 'Mentions & replies', + 'remoteHosts.notifications.failed': 'Could not save notification settings. Try again.', + 'remoteHosts.notifications.denied': 'Allow notifications in your browser’s site settings.', + 'remoteHosts.notifications.unsupported': 'This browser cannot show notifications.', 'remoteHosts.add.description': 'On the remote host, copy the link from Settings → Devices → Pair a phone and paste it here. Both hosts must be on the same Tailscale network.', 'remoteHosts.add.link': 'Pairing link', diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index 47e3373be3..d371109999 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -11,6 +11,7 @@ import { import { parseSavedHost, type SavedHost } from '../../../../apps/mobile/src/host/hosts.js'; import { createInbox, + prepareNotificationBaseline, type Inbox, type InboxSnapshot, observeInbox, @@ -26,6 +27,9 @@ import { import { store } from './store.js'; import { createRemoteActivityProjection, type RemoteBotActivity } from './remote-host-activity.js'; import { releaseRemoteHostViewers } from './remote-host-viewer.js'; +import { browserNotificationApi } from './desktop-notifications.js'; +import { createRemoteNotifications, type RemoteNotifications } from './remote-notifications.js'; +import { en } from './locale.js'; export const REMOTE_HOSTS_STORAGE_KEY = 'botharness.remoteHosts.v1'; export const HOST_TAB_STORAGE_KEY = 'botharness.hostTab.v1'; @@ -58,6 +62,7 @@ export type HostPairResult = | { ok: false; code: PairingRefusal | 'already-paired' | 'this-host' }; export interface RemoteHosts { + notifications: RemoteNotifications; getSnapshot(): RemoteHostsSnapshot; subscribe(listener: () => void): () => void; pair(link: string, deviceName: string, localHostId?: string): Promise; @@ -126,12 +131,14 @@ interface HostEntry { } const idleInbox: InboxSnapshot = { status: 'idle' }; +type NotificationTranslate = (key: 'remoteHosts.notifications.privateBody') => string; export function createRemoteHosts(options: { storage?: HostStorage | undefined; carrier: HostCarrier; loadImage?: (url: string, headers: Record) => Promise; releaseImage?: (image: string) => void; + translate?: NotificationTranslate; }): RemoteHosts { const { storage, carrier, loadImage, releaseImage } = options; const listeners = new Set<() => void>(); @@ -144,6 +151,31 @@ export function createRemoteHosts(options: { let tab = readTab(storage); let open: RemoteConversationTarget | undefined; let snapshot: RemoteHostsSnapshot = { hosts: [], avatars, tab }; + const notifications = createRemoteNotifications({ + storage, + api: browserNotificationApi(), + async prepareEnable(hostId, signal) { + const entry = clients.get(hostId); + const inbox = entry?.inbox?.feed; + if (entry === undefined || inbox === undefined) + throw new Error('notification-baseline-unavailable'); + await prepareNotificationBaseline(inbox, signal); + if ( + clients.get(hostId) !== entry || + entry.inbox?.feed !== inbox || + entry.client.getSnapshot().connection.status !== 'connected' + ) + throw new Error('notification-activation-cancelled'); + }, + privateBody: () => + options.translate?.('remoteHosts.notifications.privateBody') ?? + en['remoteHosts.notifications.privateBody'], + open(target) { + globalThis.window?.focus(); + open = target; + publish(); + }, + }); const fetchAvatars = (hosts: readonly RemoteHost[]): void => { const wanted = new Map( @@ -193,6 +225,7 @@ export function createRemoteHosts(options: { activity, })); fetchAvatars(hosts); + if (listeners.size > 0) notifications.update(hosts); const known = tab === ALL_HOSTS || tab === THIS_HOST || clients.has(tab); snapshot = { hosts, @@ -206,7 +239,7 @@ export function createRemoteHosts(options: { const start = (entry: HostEntry): void => { entry.client.start(); if (entry.inbox !== undefined) return; - const feed = createInbox(entry.client, { activity: true }, entry.retained); + const feed = createInbox(entry.client, { activity: true, notifications: true }, entry.retained); entry.inbox = { feed, release: feed.subscribe(publish) }; }; @@ -265,6 +298,7 @@ export function createRemoteHosts(options: { }; return { + notifications, getSnapshot: () => snapshot, client: (hostId) => clients.get(hostId)?.client, activity(hostId, query) { @@ -293,6 +327,7 @@ export function createRemoteHosts(options: { listeners.delete(listener); if (listeners.size > 0) return; for (const entry of clients.values()) stop(entry); + notifications.stop(); }; }, async pair(link, deviceName, localHostId) { @@ -320,6 +355,7 @@ export function createRemoteHosts(options: { await revokePairing({ host: entry.host, carrier }); }, select(next) { + if (tab !== next) notifications.cancel(); tab = next; try { storage?.setItem(HOST_TAB_STORAGE_KEY, next); @@ -439,8 +475,10 @@ async function browserImage( } let shared: RemoteHosts | undefined; +let translate: NotificationTranslate = (key) => en[key]; -export function remoteHosts(): RemoteHosts { +export function remoteHosts(translator?: NotificationTranslate): RemoteHosts { + if (translator !== undefined) translate = translator; if (shared !== undefined) return shared; shared = createRemoteHosts({ storage: (() => { @@ -453,6 +491,7 @@ export function remoteHosts(): RemoteHosts { carrier: browserCarrier(), loadImage: browserImage, releaseImage: (image) => URL.revokeObjectURL(image), + translate: (key) => translate(key), }); const sources = shared; let selection = store.getSnapshot().selection; diff --git a/packages/client/src/client/remote-notification-settings.tsx b/packages/client/src/client/remote-notification-settings.tsx new file mode 100644 index 0000000000..2e9f226f41 --- /dev/null +++ b/packages/client/src/client/remote-notification-settings.tsx @@ -0,0 +1,99 @@ +import { useState, useSyncExternalStore } from 'react'; +import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'; +import { PUSH_KINDS, type PushKind } from '../../../../apps/mobile/src/host/push.js'; +import type { RemoteNotifications } from './remote-notifications.js'; +import type { BotHarnessTranslate, BotHarnessKey } from './locale.js'; +import { SettingsDialog } from './ds/settings-dialog.js'; +import { Note } from './ds/note.js'; +import { SettingsRow, SettingsSection } from './ds/settings-row.js'; + +const labels: Record = { + approval: 'remoteHosts.notifications.approval', + question: 'remoteHosts.notifications.question', + 'private-input': 'remoteHosts.notifications.private', + reply: 'remoteHosts.notifications.reply', +}; + +export function RemoteNotificationSettings({ + hostId, + name, + notifications, + t, + onClose, +}: { + hostId: string; + name: string; + notifications: RemoteNotifications; + t: BotHarnessTranslate; + onClose(): void; +}) { + const snapshot = useSyncExternalStore(notifications.subscribe, notifications.getSnapshot); + const choice = notifications.choice(hostId); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const set = (patch: Parameters[1]) => { + setBusy(true); + setError(false); + void notifications + .set(hostId, patch) + .catch(() => setError(true)) + .finally(() => setBusy(false)); + }; + return ( + section }} + sections={[{ id: 'notifications', label: t('remoteHosts.notifications.title', { name }) }]} + activeId="notifications" + onSelect={() => {}} + onClose={() => { + notifications.cancel(hostId); + onClose(); + }} + > + {(error || snapshot.permission === 'denied' || snapshot.permission === 'unsupported') && ( + + {error + ? t('remoteHosts.notifications.failed') + : snapshot.permission === 'denied' + ? t('remoteHosts.notifications.denied') + : snapshot.permission === 'unsupported' + ? t('remoteHosts.notifications.unsupported') + : undefined} + + )} + + set({ enabled: value })} + disabled={ + busy || + (!choice.enabled && + (snapshot.permission === 'unsupported' || snapshot.permission === 'denied')) + } + /> + } + /> + {PUSH_KINDS.map((kind) => ( + set({ preferences: { [kind]: value } })} + /> + } + /> + ))} + + + ); +} diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts new file mode 100644 index 0000000000..6ccb7b7b7c --- /dev/null +++ b/packages/client/src/client/remote-notifications.ts @@ -0,0 +1,340 @@ +import { + DEFAULT_NOTIFICATION_PREFERENCES, + parseNotificationPreferences, +} from '../../../../apps/mobile/src/host/device-notification-preferences.js'; +import { + PUSH_KINDS, + type PushKind, + type PushPreferences, +} from '../../../../apps/mobile/src/host/push.js'; +import type { HumanAttentionItem } from '../../../../apps/mobile/src/host/wire.js'; +import type { RemoteHost, HostStorage, RemoteConversationTarget } from './remote-hosts.js'; +import { + noticeBody, + type DesktopNotificationApi, + type DesktopNotificationHandle, + type DesktopNotificationPermission, +} from './desktop-notifications.js'; + +export const REMOTE_NOTIFICATION_KEY = 'botharness.remoteNotifications.v1'; +type Choice = { enabled: boolean; preferences: PushPreferences }; +type ControlRequest = { expiresAt: number; signal: AbortSignal }; +function checkRequest(request?: ControlRequest): void { + if (request === undefined) return; + if (request.signal.aborted) throw new Error('notification-activation-cancelled'); + if (!Number.isFinite(request.expiresAt) || Date.now() >= request.expiresAt) + throw new Error('appearance-request-stale'); +} +type Snapshot = { + hosts: Readonly>; + permission: DesktopNotificationPermission; +}; +const off: Choice = { enabled: false, preferences: DEFAULT_NOTIFICATION_PREFERENCES }; +type NotificationDiagnostic = + | 'remote-notification-show-refused' + | 'remote-notification-show-failed'; +function reportDiagnostic(code: NotificationDiagnostic): void { + if (typeof window !== 'undefined') + window.dispatchEvent(new CustomEvent('botharness:notification-diagnostic', { detail: code })); +} +const kinds: Record = { + 'tool-approval': 'approval', + 'workspace-grant-request': 'approval', + 'auto-rejection': 'approval', + 'group-join-request': 'approval', + 'assignment-waiting-human': 'question', + 'assignment-blocked': 'question', + 'user-question': 'question', + 'private-input': 'private-input', + 'channel-reply': 'reply', + 'channel-mention': 'reply', +}; + +export function createRemoteNotifications(options: { + storage?: HostStorage | undefined; + api?: DesktopNotificationApi | undefined; + open(target: RemoteConversationTarget): void; + privateBody(): string; + prepareEnable(hostId: string, signal: AbortSignal): Promise; + diagnostic?(code: NotificationDiagnostic): void; +}) { + const { api, storage } = options; + let hosts: Record = {}; + try { + const saved: unknown = JSON.parse(storage?.getItem(REMOTE_NOTIFICATION_KEY) ?? '{}'); + if (typeof saved === 'object' && saved !== null) + for (const [id, value] of Object.entries(saved)) { + const entry = value as Partial | null; + const preferences = parseNotificationPreferences(entry?.preferences); + if (preferences && typeof entry?.enabled === 'boolean') + hosts[id] = { enabled: entry.enabled, preferences }; + } + } catch {} + let snapshot: Snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; + const listeners = new Set<() => void>(); + const reported = new Set(); + const state = new Map< + string, + { + credential: string; + inbox: RemoteHost['inbox']; + baseline: boolean; + seen: Set; + } + >(); + const shown = new Map(); + const revisions = new Map(); + const pending = new Map(); + const cancel = (hostId?: string) => { + for (const [id, controller] of pending) { + if (hostId !== undefined && id !== hostId) continue; + controller.abort(new Error('notification-activation-cancelled')); + pending.delete(id); + } + }; + const wait = (work: Promise, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const abort = () => { + signal.removeEventListener('abort', abort); + reject(signal.reason); + }; + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); + const publish = () => { + snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; + for (const listener of listeners) listener(); + }; + const close = (hostId?: string) => { + for (const [key, notice] of shown) { + if (hostId !== undefined && notice.hostId !== hostId) continue; + notice.handle.onclick = null; + notice.handle.close(); + shown.delete(key); + } + }; + const choice = (hostId: string) => hosts[hostId] ?? off; + const owner = { + getSnapshot: () => snapshot, + choice, + cancel, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async set( + hostId: string, + patch: { enabled?: boolean; preferences?: Partial }, + request?: ControlRequest, + ) { + checkRequest(request); + cancel(hostId); + const controller = new AbortController(); + const abortRequest = () => controller.abort(new Error('notification-activation-cancelled')); + request?.signal.addEventListener('abort', abortRequest, { once: true }); + const deadline = + request === undefined + ? undefined + : setTimeout( + () => controller.abort(new Error('appearance-request-stale')), + Math.max(0, request.expiresAt - Date.now()), + ); + pending.set(hostId, controller); + const revision = (revisions.get(hostId) ?? 0) + 1; + revisions.set(hostId, revision); + try { + if (patch.enabled === true && api?.permission() === 'default') { + try { + await wait(api.requestPermission(), controller.signal); + } catch {} + } + controller.signal.throwIfAborted(); + if (revisions.get(hostId) !== revision) return; + const current = choice(hostId); + const next = { + enabled: + patch.enabled === undefined + ? current.enabled + : patch.enabled && api?.permission() === 'granted', + preferences: { ...current.preferences, ...patch.preferences }, + }; + if ( + next.enabled && + PUSH_KINDS.some( + (kind) => next.preferences[kind] && (!current.enabled || !current.preferences[kind]), + ) + ) { + const timeout = setTimeout( + () => controller.abort(new Error('notification-baseline-timeout')), + 10_000, + ); + try { + await wait(options.prepareEnable(hostId, controller.signal), controller.signal); + } finally { + clearTimeout(timeout); + } + controller.signal.throwIfAborted(); + if (api?.permission() !== 'granted') throw new Error('notification-permission-required'); + } + checkRequest(request); + controller.signal.throwIfAborted(); + const updated = { ...hosts, [hostId]: next }; + storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated)); + hosts = updated; + close(hostId); + publish(); + } finally { + clearTimeout(deadline); + request?.signal.removeEventListener('abort', abortRequest); + if (pending.get(hostId) === controller) pending.delete(hostId); + } + }, + async control( + input: Record, + expectedRevision: unknown, + request: ControlRequest, + ) { + checkRequest(request); + const hostId = input.hostId as string; + const current = choice(hostId); + if (input.patch !== undefined) { + if (expectedRevision !== JSON.stringify(current)) + throw new Error('notification-revision-stale'); + const patch = input.patch as Partial | null; + const preferences = parseNotificationPreferences(patch?.preferences); + if (!preferences || typeof patch?.enabled !== 'boolean') + throw new Error('notification-invalid-input'); + if (patch.enabled && api?.permission() !== 'granted') + throw new Error('notification-permission-required'); + await owner.set(hostId, { enabled: patch.enabled, preferences }, request); + } + checkRequest(request); + return { + hostId, + ...choice(hostId), + permission: api?.permission() ?? 'unsupported', + revision: JSON.stringify(choice(hostId)), + }; + }, + update(remotes: readonly RemoteHost[]) { + const present = new Set(remotes.map((remote) => remote.host.hostId)); + for (const id of pending.keys()) if (!present.has(id)) cancel(id); + for (const id of state.keys()) + if (!present.has(id)) { + state.delete(id); + close(id); + } + const permission = api?.permission() ?? 'unsupported'; + if (permission !== snapshot.permission) { + publish(); + if (permission !== 'granted') { + cancel(); + close(); + } + } + for (const remote of remotes) { + const id = remote.host.hostId; + let entry = state.get(id); + if (entry === undefined || entry.credential !== remote.host.credential) { + if (entry !== undefined) cancel(id); + close(id); + entry = { + credential: remote.host.credential, + inbox: { status: 'idle' }, + baseline: false, + seen: new Set(), + }; + state.set(id, entry); + } + if (remote.snapshot.connection.status !== 'connected') { + cancel(id); + entry.inbox = remote.inbox; + entry.baseline = false; + close(id); + continue; + } + if (remote.inbox.status !== 'ready' || remote.inbox.stale) { + close(id); + continue; + } + if (remote.inbox.notificationItems === undefined) continue; + if (entry.inbox === remote.inbox) continue; + entry.inbox = remote.inbox; + const items = remote.inbox.notificationItems; + const active = new Set( + items.filter((item) => item.isUnread !== false).map((item) => `${id}:${item.id}`), + ); + for (const [key, notice] of shown) + if (notice.hostId === id && !active.has(key)) { + notice.handle.onclick = null; + notice.handle.close(); + shown.delete(key); + } + const pref = choice(id); + for (const item of items) { + const key = `${id}:${item.id}`; + const known = entry.seen.has(item.id); + entry.seen.add(item.id); + const kind = kinds[item.kind]; + if ( + !entry.baseline || + known || + kind === undefined || + item.isUnread === false || + !pref.enabled || + !pref.preferences[kind] || + permission !== 'granted' || + api === undefined + ) + continue; + const bot = remote.snapshot.roster?.bots.find((bot) => bot.slug === item.botSlug); + const title = `${remote.host.hostName} · ${item.channelName ?? bot?.displayName ?? item.botSlug}`; + try { + const handle = api.show(title, { + body: kind === 'private-input' ? options.privateBody() : noticeBody(item.summary), + tag: `botharness-remote:${key}`, + }); + shown.set(key, { hostId: id, handle }); + handle.onclick = (event) => { + event.preventDefault(); + handle.close(); + shown.delete(key); + options.open(target(remote, item)); + }; + } catch (error) { + const code = + error instanceof Error && + (error.name === 'NotAllowedError' || error.name === 'SecurityError') + ? 'remote-notification-show-refused' + : 'remote-notification-show-failed'; + if (!reported.has(code)) { + reported.add(code); + (options.diagnostic ?? reportDiagnostic)(code); + } + } + } + entry.baseline = true; + } + }, + stop() { + cancel(); + state.clear(); + close(); + }, + }; + return owner; +} + +function target(remote: RemoteHost, item: HumanAttentionItem): RemoteConversationTarget { + const bot = remote.snapshot.roster?.bots.find((bot) => bot.slug === item.botSlug); + return { + hostId: remote.host.hostId, + title: item.channelName ?? bot?.displayName ?? item.botSlug, + ...(item.channelId === undefined ? { botSlug: item.botSlug } : { channelId: item.channelId }), + }; +} + +export type RemoteNotifications = ReturnType; diff --git a/packages/client/test/client-observer.test.ts b/packages/client/test/client-observer.test.ts index ca77ca609e..c7307bc754 100644 --- a/packages/client/test/client-observer.test.ts +++ b/packages/client/test/client-observer.test.ts @@ -14,6 +14,32 @@ describe('early real Client diagnostic observer', () => { } ).__BOTHARNESS_CLIENT_DIAGNOSTICS__; + it('records only fixed redacted notification failure codes through the existing diagnostic stream', async () => { + vi.useFakeTimers(); + const diagnostics = createClientDiagnostics(); + vi.stubGlobal( + 'fetch', + vi.fn((url, options) => diagnostics.fetch(new Request('http://localhost' + url, options))), + ); + installClientObserver(window); + window.dispatchEvent( + new CustomEvent('botharness:notification-diagnostic', { detail: 'private payload' }), + ); + window.dispatchEvent( + new CustomEvent('botharness:notification-diagnostic', { + detail: 'remote-notification-show-refused', + }), + ); + await vi.advanceTimersByTimeAsync(300); + const report = await ( + await diagnostics.fetch(new Request('http://localhost/api/botharness/client-diagnostics')) + ).json(); + expect(report.attempts[0].events).toContainEqual( + expect.objectContaining({ code: 'remote-notification-show-refused', source: 'lifecycle' }), + ); + expect(JSON.stringify(report)).not.toContain('private payload'); + expect(report.attempts[0].firstFailure).toBeUndefined(); + }); it('retains a late shell observation when its first report follows a warning flood', async () => { vi.useFakeTimers(); const diagnostics = createClientDiagnostics(); diff --git a/packages/client/test/notification-control.test.ts b/packages/client/test/notification-control.test.ts new file mode 100644 index 0000000000..f27dd153a2 --- /dev/null +++ b/packages/client/test/notification-control.test.ts @@ -0,0 +1,256 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { createAppearanceControl } from '../../core/src/appearance/control.js'; +import { Appearance } from '../src/client/appearance.js'; +import { mountAppearanceControl } from '../src/client/appearance-control.js'; +import { createRemoteNotifications } from '../src/client/remote-notifications.js'; + +it.each(['normal', 'delayed-delivery'] as const)( + 'recovers the same relay after cancellation and expiry with %s transport', + async (transport) => { + vi.useFakeTimers(); + const host = createAppearanceControl(); + const data = new Map(); + const writes = vi.fn((key: string, value: string) => data.set(key, value)); + const finishes: (() => void)[] = []; + const prepareEnable = vi.fn(() => new Promise((resolve) => finishes.push(resolve))); + const notices = createRemoteNotifications({ + storage: { + getItem: (key) => data.get(key) ?? null, + setItem: writes, + removeItem(key) { + data.delete(key); + }, + }, + api: { permission: () => 'granted', requestPermission: async () => 'granted', show: vi.fn() }, + open() {}, + privateBody: () => 'private', + prepareEnable, + }); + shared.set('sources', { + notifications: notices, + getSnapshot: () => ({ hosts: [{ host: { hostId: 'remote' } }] }), + }); + let holdDelivery = false; + let deliver: (() => void) | undefined; + let polls = 0; + let registrations = 0; + vi.stubGlobal('location', { protocol: 'http:' }); + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + const { action } = JSON.parse(String(init?.body)); + if (action === 'register') registrations++; + const response = host.fetch( + new Request('http://localhost/api/botharness/appearance-control', init), + ); + if (action === 'poll') polls++; + const result = await response; + if (action === 'poll' && holdDelivery) { + holdDelivery = false; + await new Promise((resolve) => { + deliver = resolve; + }); + } + return result; + }); + let release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + const targets = async () => + (await (await host.fetch(new Request('http://localhost'))).json()).clients; + const flush = () => vi.advanceTimersByTimeAsync(0); + const request = (clientId: string, enable = false) => + host.fetch( + new Request('http://localhost/api/botharness/appearance-control', { + method: 'POST', + body: JSON.stringify({ + action: 'request', + clientId, + notifications: { + hostId: 'remote', + ...(enable + ? { patch: { enabled: true, preferences: notices.choice('remote').preferences } } + : {}), + }, + ...(enable ? { expectedRevision: JSON.stringify(notices.choice('remote')) } : {}), + }), + }), + ); + try { + await flush(); + const [{ id }] = await targets(); + for (const [index, reason] of ['cancel', 'deadline', 'cancel', 'deadline'].entries()) { + const before = polls; + const response = request(id, true); + await flush(); + expect(prepareEnable).toHaveBeenCalledTimes(index + 1); + if (reason === 'cancel') notices.cancel('remote'); + else await vi.advanceTimersByTimeAsync(8000); + await flush(); + expect(await (await response).json()).toMatchObject({ + error: { + code: + reason === 'cancel' + ? 'notification-activation-cancelled' + : 'appearance-client-timeout', + }, + }); + expect(writes).not.toHaveBeenCalled(); + expect(polls).toBe(before + 1); + const read = request(id); + await flush(); + expect(await (await read).json()).toMatchObject({ enabled: false }); + finishes.at(-1)!(); + await flush(); + expect(writes).not.toHaveBeenCalled(); + } + if (transport === 'delayed-delivery') { + holdDelivery = true; + const calls = prepareEnable.mock.calls.length; + const response = request(id, true); + await flush(); + expect(deliver).toBeTypeOf('function'); + await vi.advanceTimersByTimeAsync(8000); + expect(await (await response).json()).toMatchObject({ + error: { code: 'appearance-client-timeout' }, + }); + expect(await (await request(id)).json()).toMatchObject({ + error: { code: 'appearance-client-unavailable' }, + }); + expect(prepareEnable).toHaveBeenCalledTimes(calls); + await vi.advanceTimersByTimeAsync(1000); + deliver!(); + await flush(); + const read = request(id); + await flush(); + expect(await (await read).json()).toMatchObject({ enabled: false }); + } + expect(registrations).toBe(1); + expect(await targets()).toEqual([{ id, platform: 'web' }]); + expect(writes).not.toHaveBeenCalled(); + const pending = request(id, true); + await flush(); + release(); + await flush(); + expect(await (await pending).json()).toMatchObject({ + error: { code: 'appearance-client-stale' }, + }); + expect(await targets()).toEqual([]); + finishes.at(-1)!(); + await flush(); + expect(writes).not.toHaveBeenCalled(); + release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + await flush(); + const [{ id: next }] = await targets(); + expect(next).not.toBe(id); + const accepted = request(next, true); + await flush(); + finishes.at(-1)!(); + await flush(); + expect(await (await accepted).json()).toMatchObject({ enabled: true }); + expect(writes).toHaveBeenCalledTimes(1); + } finally { + deliver?.(); + release(); + notices.stop(); + host.dispose(); + } + }, +); + +const shared = vi.hoisted(() => new Map()); +vi.mock('../src/client/remote-hosts.js', () => ({ remoteHosts: () => shared.get('sources') })); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + shared.clear(); +}); + +it.each(['deadline', 'unmount', 'on-time'] as const)( + 'enforces the relay %s boundary on browser preferences and storage', + async (reason) => { + vi.useFakeTimers(); + const host = createAppearanceControl(); + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + }; + let finish!: () => void; + const prepareEnable = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const notices = createRemoteNotifications({ + storage, + api: { permission: () => 'granted', requestPermission: async () => 'granted', show: vi.fn() }, + open() {}, + privateBody: () => 'private', + prepareEnable, + }); + shared.set('sources', { + notifications: notices, + getSnapshot: () => ({ hosts: [{ host: { hostId: 'remote' } }] }), + }); + const actions: string[] = []; + vi.stubGlobal('location', { protocol: 'http:' }); + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + actions.push(JSON.parse(String(init?.body)).action); + return host.fetch(new Request('http://localhost/api/botharness/appearance-control', init)); + }); + const release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + try { + await vi.waitFor(() => expect(actions).toContain('poll')); + const { clients } = await (await host.fetch(new Request('http://localhost'))).json(); + const before = notices.choice('remote'); + const response = host.fetch( + new Request('http://localhost/api/botharness/appearance-control', { + method: 'POST', + body: JSON.stringify({ + action: 'request', + clientId: clients[0].id, + expectedRevision: JSON.stringify(before), + notifications: { + hostId: 'remote', + patch: { enabled: true, preferences: before.preferences }, + }, + }), + }), + ); + await vi.waitFor(() => expect(prepareEnable).toHaveBeenCalledTimes(1)); + if (reason === 'on-time') { + await vi.advanceTimersByTimeAsync(7000); + finish(); + await vi.advanceTimersByTimeAsync(0); + expect(await (await response).json()).toMatchObject({ enabled: true }); + expect(notices.choice('remote').enabled).toBe(true); + expect( + JSON.parse(data.get('botharness.remoteNotifications.v1') ?? '{}').remote.enabled, + ).toBe(true); + expect(actions.filter((action) => action === 'reply')).toEqual(['reply']); + return; + } + if (reason === 'unmount') release(); + await vi.advanceTimersByTimeAsync(8000); + expect(await (await response).json()).toMatchObject({ + error: { + code: reason === 'deadline' ? 'appearance-client-timeout' : 'appearance-client-stale', + }, + }); + await vi.advanceTimersByTimeAsync(1000); + finish(); + await vi.advanceTimersByTimeAsync(0); + expect(notices.choice('remote')).toEqual(before); + expect(data.size).toBe(0); + expect(actions.filter((action) => action === 'reply')).toEqual([]); + } finally { + release(); + notices.stop(); + host.dispose(); + } + }, +); diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 882d84b7d7..e90034eec1 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -1,10 +1,12 @@ import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import type { HostCarrier } from '../../../apps/mobile/src/host/carrier.js'; +import type { HumanAttentionItem as HostAttentionItem } from '../../../apps/mobile/src/host/wire.js'; import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; import { createHostActivityActions } from '../src/client/host-activity-actions.js'; import type { HumanAttentionItem } from '../src/client/store.js'; import { pairingLink } from '../../core/src/devices/http.js'; +import { en, zh } from '../src/client/locale.js'; import { ALL_HOSTS, HOST_TAB_STORAGE_KEY, @@ -169,11 +171,14 @@ describe('remote hosts', () => { const releases: Array<() => void> = []; afterEach(() => { for (const release of releases.splice(0)) release(); + vi.unstubAllGlobals(); }); function liveHost() { const host = new FakeHost(); - host.methods.set('humanAttention', () => ({ items: [], queryVersion: 1 })); + host.methods.set('humanAttention', (args) => + args.notifications ? { items: [], notificationVersion: 1 } : { items: [], queryVersion: 1 }, + ); host.methods.set('channelUnreadCounts', () => ({ channels: [{ channelId: 'dm-ada', unreadCount: 2 }], })); @@ -201,6 +206,177 @@ function liveHost() { } describe('Host Feed lifecycle', () => { + it.each(['Activity', 'notification page'] as const)( + 'preserves a completed notification baseline when %s alone fails on a connected Host', + async (failure) => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + const { host, storage } = liveHost(); + const old: HostAttentionItem = { + id: 'old', + kind: 'user-question', + category: 'action', + botSlug: 'ada', + createdAt: '2026-10-22T12:00:00.000Z', + summary: 'Old question', + }; + const items = [old]; + let fail = false; + host.methods.set('humanAttention', (args) => { + if (args.notifications) { + if (args.cursor) { + if (fail && failure === 'notification page') + throw new Error('notification-page-unavailable'); + return { notificationVersion: 1, items: items.slice(1) }; + } + return { notificationVersion: 1, items: items.slice(0, 1), nextCursor: 'tail' }; + } + if (fail && failure === 'Activity' && args.category === 'action') + throw new Error('activity-unavailable'); + return { queryVersion: 1, items: [] }; + }); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true }); + const refresh = async () => { + const before = sources.getSnapshot().hosts[0]?.inbox; + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => { + const remote = sources.getSnapshot().hosts[0]; + expect(remote?.inbox).not.toBe(before); + expect(remote?.snapshot.connection.status).toBe('connected'); + expect(remote?.host.credential).toBe(host.credential); + }); + }; + fail = true; + await refresh(); + expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + ...(failure === 'Activity' + ? { stale: expect.any(String) } + : { notificationItems: undefined }), + }); + items.push({ ...old, id: 'new', createdAt: '2026-01-01T00:00:00.000Z' }); + await refresh(); + expect(show).not.toHaveBeenCalled(); + fail = false; + await refresh(); + expect(show).toHaveBeenCalledExactlyOnceWith('Studio · Ada', { + body: 'Old question', + tag: `botharness-remote:${host.hostId}:new`, + }); + await refresh(); + expect(show).toHaveBeenCalledTimes(1); + await sources.notifications.set(host.hostId, { enabled: false }); + const saved = storage.getItem('botharness.remoteNotifications.v1'); + fail = true; + await expect(sources.notifications.set(host.hostId, { enabled: true })).rejects.toThrow( + 'notification-baseline-unavailable', + ); + expect(sources.notifications.choice(host.hostId).enabled).toBe(false); + expect(storage.getItem('botharness.remoteNotifications.v1')).toBe(saved); + items.push({ ...old, id: 'while-disabled' }); + fail = false; + await sources.notifications.set(host.hostId, { enabled: true }); + await refresh(); + expect(show).toHaveBeenCalledTimes(1); + items.push({ ...old, id: 'after-enable', createdAt: '2025-01-01T00:00:00.000Z' }); + await refresh(); + expect(show).toHaveBeenCalledTimes(2); + expect(show.mock.calls[1]?.[1]).toMatchObject({ + tag: `botharness-remote:${host.hostId}:after-enable`, + }); + }, + ); + it('keeps notification identities across reconnect and active resubscription, but releases them on final disposal', async () => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + const { host, storage } = liveHost(); + let items: HostAttentionItem[] = []; + host.methods.set('humanAttention', (args) => + args.notifications ? { notificationVersion: 1, items } : { queryVersion: 1, items: [] }, + ); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + const primary = sources.subscribe(() => {}); + releases.push(primary); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true }); + const received: HostAttentionItem = { + id: 'received', + kind: 'user-question', + category: 'action', + botSlug: 'ada', + createdAt: '2026-01-01', + summary: 'Question', + }; + const refresh = async (next: typeof items) => { + const before = sources.getSnapshot().hosts[0]?.inbox; + items = next; + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => { + const after = sources.getSnapshot().hosts[0]?.inbox; + expect(after).not.toBe(before); + expect(after).toMatchObject({ status: 'ready', notificationItems: next }); + }); + }; + await refresh([received]); + expect(show).toHaveBeenCalledTimes(1); + await refresh([]); + const secondary = sources.subscribe(() => {}); + primary(); + const resubscribed = sources.subscribe(() => {}); + releases.push(resubscribed); + secondary(); + await refresh([]); + await refresh([received]); + expect(show).toHaveBeenCalledTimes(1); + await refresh([]); + host.kill(); + host.restart(); + await vi.waitFor( + () => { + expect(sources.getSnapshot().hosts[0]?.snapshot.connection.status).toBe('connected'); + expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + notificationItems: [], + }); + }, + { timeout: 3000 }, + ); + await refresh([received]); + expect(show).toHaveBeenCalledTimes(1); + await refresh([]); + resubscribed(); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => { + expect(sources.getSnapshot().hosts[0]?.snapshot.connection.status).toBe('connected'); + expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + notificationItems: [], + }); + }); + await refresh([received]); + expect(show).toHaveBeenCalledTimes(2); + }); it('marks the remote DM channel read through its own credential and refreshes its Inbox', async () => { const { host, storage } = liveHost(); host.methods.set('channels', () => ({ @@ -241,6 +417,201 @@ describe('Host Feed lifecycle', () => { expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ unread: { 'dm-ada': 2 } }); }); + it('uses the bound translator for private notification bodies after the locale changes', async () => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + vi.stubGlobal('document', { documentElement: { lang: 'en' } }); + let dictionary = en; + const translate = (key: 'remoteHosts.notifications.privateBody') => dictionary[key]; + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage, translate }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true }); + dictionary = zh; + host.methods.set('humanAttention', (args) => + args.notifications + ? { + notificationVersion: 1, + items: [ + { + id: 'private', + kind: 'private-input', + botSlug: 'ada', + createdAt: 'now', + summary: 'must not appear', + }, + ], + } + : { queryVersion: 1, items: [] }, + ); + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1)); + expect(show.mock.calls[0]?.[1]).toMatchObject({ + body: zh['remoteHosts.notifications.privateBody'], + }); + }); + it('establishes an enable boundary through the shared paginated Inbox despite concurrent live frames and page failure', async () => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true, preferences: { reply: false } }); + const items = Array.from({ length: 35 }, (_, index) => ({ + id: `old-${index}`, + kind: 'channel-mention', + category: 'replies', + botSlug: 'ada', + createdAt: '2026-10-22T12:00:00.000Z', + summary: `old-${index}`, + })); + let finish!: () => void; + let gate: Promise | undefined = new Promise((resolve) => { + finish = resolve; + }); + let tails = 0; + let fail = false; + host.methods.set('humanAttention', async (args) => { + if (!args.notifications) return { queryVersion: 1, items: [] }; + if (!args.cursor) + return { notificationVersion: 1, items: items.slice(0, 30), nextCursor: 'tail' }; + tails++; + await gate; + if (fail) throw new Error('page unavailable'); + return { notificationVersion: 1, items: items.slice(30) }; + }); + const live = () => + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + live(); + await vi.waitFor(() => expect(tails).toBe(1)); + const enabled = sources.notifications.set(host.hostId, { preferences: { reply: true } }); + live(); + expect(sources.notifications.choice(host.hostId).preferences.reply).toBe(false); + finish(); + gate = undefined; + await enabled; + expect(tails).toBeGreaterThan(1); + expect(show).not.toHaveBeenCalled(); + items.push( + ...['group', 'dm'].map((id) => ({ + id, + kind: 'channel-reply', + category: 'replies', + botSlug: 'ada', + createdAt: '2026-10-22T12:00:00.000Z', + summary: id, + })), + ); + live(); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(2)); + await sources.notifications.set(host.hostId, { preferences: { reply: false } }); + fail = true; + await expect( + sources.notifications.set(host.hostId, { preferences: { reply: true } }), + ).rejects.toThrow('notification-baseline-unavailable'); + expect(sources.notifications.choice(host.hostId).preferences.reply).toBe(false); + fail = false; + await sources.notifications.set(host.hostId, { preferences: { reply: true } }); + expect(show).toHaveBeenCalledTimes(2); + items.push({ + id: 'later', + kind: 'channel-reply', + category: 'replies', + botSlug: 'ada', + createdAt: '2026-10-22T12:00:00.000Z', + summary: 'later', + }); + live(); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(3)); + items.push( + ...[ + 'tool-approval', + 'user-question', + 'private-input', + 'channel-mention', + 'channel-reply', + 'channel-reply', + ].map((kind, index) => ({ + id: `late-attention-${index}`, + kind, + category: index < 3 ? 'action' : 'replies', + botSlug: 'ada', + createdAt: `2026-01-0${6 - index}T00:00:00.000Z`, + summary: `late-attention-${index}`, + })), + ); + live(); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(9)); + for (let index = 0; index < 6; index++) + expect( + show.mock.calls.filter( + ([, options]) => + options.tag === `botharness-remote:${host.hostId}:late-attention-${index}`, + ), + ).toHaveLength(1); + await sources.notifications.set(host.hostId, { enabled: false }); + await sources.notifications.set(host.hostId, { enabled: true }); + expect(show).toHaveBeenCalledTimes(9); + }); + + it.each(['switch', 'remove', 'dispose'] as const)( + 'cancels pending activation on Host %s without applying a late scan', + async (operation) => { + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + }, + }); + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + const release = sources.subscribe(() => {}); + releases.push(release); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + let entered = false; + host.methods.set('humanAttention', async (args) => { + if (!args.notifications) return { queryVersion: 1, items: [] }; + entered = true; + await gate; + return { notificationVersion: 1, items: [] }; + }); + const enabled = sources.notifications.set(host.hostId, { enabled: true }); + const refused = expect(enabled).rejects.toThrow('notification-activation-cancelled'); + await vi.waitFor(() => expect(entered).toBe(true)); + if (operation === 'switch') sources.select('local'); + else if (operation === 'remove') await sources.remove(host.hostId); + else release(); + await refused; + finish(); + await Promise.resolve(); + expect(sources.notifications.choice(host.hostId).enabled).toBe(false); + expect(storage.values.has('botharness.remoteNotifications.v1')).toBe(false); + }, + ); + it('retains legacy pairings when browser storage refuses the migration write', () => { const { host, saved } = liveHost(); const storage = memoryStorage({ @@ -291,6 +662,7 @@ describe('Host Feed lifecycle', () => { { category: 'action', limit: 100, sort: 'newest', queryVersion: 1 }, { category: 'replies', limit: 30, sort: 'mentions-first', queryVersion: 1 }, { category: 'unread', limit: 30, queryVersion: 1 }, + { notifications: true, limit: 100 }, ]); first(); host.methods.set('channelUnreadCounts', () => ({ diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts new file mode 100644 index 0000000000..37c769bbb4 --- /dev/null +++ b/packages/client/test/remote-notifications.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRemoteNotifications } from '../src/client/remote-notifications.js'; +import type { RemoteHost } from '../src/client/remote-hosts.js'; +import type { DesktopNotificationHandle } from '../src/client/desktop-notifications.js'; +import type { HumanAttentionItem } from '../../../apps/mobile/src/host/wire.js'; + +const item = (id: string, kind = 'user-question', at = id): HumanAttentionItem => ({ + id, + kind, + category: 'action', + botSlug: 'ada', + createdAt: at, + summary: 'Request preview', +}); +function remote(id: string, items: HumanAttentionItem[]): RemoteHost { + return { + host: { hostId: id, hostName: id, deviceId: id, credential: id, routes: [] }, + snapshot: { connection: { status: 'connected', route: '' } }, + inbox: { + status: 'ready', + notificationItems: items, + sections: { action: items, replies: [], unread: [], handled: [] }, + }, + }; +} +function fixture() { + let permission: 'default' | 'granted' | 'denied' = 'default'; + const handles: DesktopNotificationHandle[] = []; + const show = vi.fn(() => { + const handle = { onclick: null, close: vi.fn() }; + handles.push(handle); + return handle; + }); + const request = vi.fn(async (): Promise<'granted'> => (permission = 'granted')); + const open = vi.fn(); + const storage = new Map(); + const options = { + storage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + api: { permission: () => permission, requestPermission: request, show }, + open, + privateBody: () => 'Private input needed', + prepareEnable: async () => {}, + }; + return { + notices: createRemoteNotifications(options), + options, + show, + request, + handles, + open, + permission: (next: typeof permission) => { + permission = next; + }, + }; +} + +describe('Remote Host browser alerts', () => { + it.each(['idle', 'loading', 'error', 'stale', 'partial'] as const)( + 'does not establish or erase a notification baseline from a connected %s read', + async (status) => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = item('old', 'channel-mention', '2026-10-22'); + const fresh = item('new', 'user-question', '2026-01-01'); + const failed = () => { + const host = remote('a', [fresh]); + if (status === 'idle' || status === 'loading') host.inbox = { status }; + else if (status === 'error') host.inbox = { status, error: 'read-unavailable' }; + else if (host.inbox.status === 'ready') { + if (status === 'stale') host.inbox.stale = 'read-unavailable'; + else host.inbox.notificationItems = undefined; + } + return host; + }; + f.notices.update([failed()]); + f.notices.update([remote('a', [old])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([failed()]); + f.notices.update([remote('a', [old, fresh])]); + f.notices.update([remote('a', [old, fresh])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Request preview', + tag: 'botharness-remote:a:new', + }); + }, + ); + it('rechecks the relay deadline at commit even when its timer has not run', async () => { + vi.useFakeTimers(); + const f = fixture(); + f.permission('granted'); + const expiresAt = Date.now() + 8000; + const notices = createRemoteNotifications({ + ...f.options, + prepareEnable: async () => { + vi.setSystemTime(expiresAt); + }, + }); + const before = notices.choice('a'); + const saved = f.options.storage.getItem('botharness.remoteNotifications.v1'); + try { + await expect( + notices.control( + { + hostId: 'a', + patch: { enabled: true, preferences: before.preferences }, + }, + JSON.stringify(before), + { expiresAt, signal: new AbortController().signal }, + ), + ).rejects.toThrow('appearance-request-stale'); + expect(notices.choice('a')).toEqual(before); + expect(f.options.storage.getItem('botharness.remoteNotifications.v1')).toBe(saved); + } finally { + vi.useRealTimers(); + } + }); + it('waits for a complete reconnect baseline across read failures without losing seen identities', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = item('old', 'user-question', '2026-10-22'); + f.notices.update([remote('a', [old])]); + const disconnected = remote('a', [old]); + disconnected.snapshot = { connection: { status: 'stopped', route: '' } }; + f.notices.update([disconnected]); + const failed = remote('a', [item('offline')]); + if (failed.inbox.status === 'ready') failed.inbox.stale = 'read-unavailable'; + f.notices.update([failed]); + const partial = remote('a', []); + if (partial.inbox.status === 'ready') partial.inbox.notificationItems = undefined; + f.notices.update([partial]); + f.notices.update([remote('a', [item('offline')])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([remote('a', [old, item('offline'), item('new', 'user-question', '2025')])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Request preview', + tag: 'botharness-remote:a:new', + }); + }); + + it('refuses a timed-out enable without changing the persisted choice and allows a fresh retry', async () => { + vi.useFakeTimers(); + const f = fixture(); + f.permission('granted'); + let finish!: () => void; + const prepareEnable = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const notices = createRemoteNotifications({ ...f.options, prepareEnable }); + try { + const enabling = notices.set('a', { enabled: true }); + const refused = expect(enabling).rejects.toThrow('notification-baseline-timeout'); + await vi.advanceTimersByTimeAsync(10_000); + await refused; + finish(); + expect(notices.choice('a').enabled).toBe(false); + prepareEnable.mockResolvedValue(undefined); + await notices.set('a', { enabled: true }); + notices.update([remote('a', [])]); + notices.update([remote('a', [item('new')])]); + expect(f.show).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels activation when permission is revoked, and never applies a late completion after disable', async () => { + const f = fixture(); + f.permission('granted'); + let finish!: () => void; + const prepareEnable = () => + new Promise((resolve) => { + finish = resolve; + }); + const notices = createRemoteNotifications({ ...f.options, prepareEnable }); + notices.update([remote('a', [])]); + const enabling = notices.set('a', { enabled: true }); + const refused = expect(enabling).rejects.toThrow('notification-activation-cancelled'); + f.permission('denied'); + notices.update([remote('a', [])]); + await refused; + finish(); + expect(notices.choice('a').enabled).toBe(false); + f.permission('granted'); + const again = notices.set('a', { enabled: true }); + const disabled = expect(again).rejects.toThrow('notification-activation-cancelled'); + await notices.set('a', { enabled: false }); + finish(); + await disabled; + expect(notices.choice('a').enabled).toBe(false); + }); + + it('does not replay a pending 35-mention scan when enabling replies, then alerts new group and DM replies', async () => { + const f = fixture(); + let finish!: () => void; + const prepareEnable = vi.fn(async () => {}); + const notices = createRemoteNotifications({ ...f.options, prepareEnable }); + f.permission('granted'); + await notices.set('a', { enabled: true, preferences: { reply: false } }); + notices.update([remote('a', [])]); + prepareEnable.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const enabling = notices.set('a', { preferences: { reply: true } }); + const old = Array.from({ length: 35 }, (_, index) => + item(`old-${index}`, 'channel-mention', 'same-time'), + ); + notices.update([remote('a', old)]); + expect(f.show).not.toHaveBeenCalled(); + finish(); + await enabling; + const fresh = [ + item('group', 'channel-reply', 'same-time'), + item('dm', 'channel-reply', 'same-time'), + ]; + notices.update([remote('a', [...old, ...fresh])]); + expect(f.show).toHaveBeenCalledTimes(2); + notices.update([remote('a', [...old, ...fresh])]); + expect(f.show).toHaveBeenCalledTimes(2); + }); + it('retains dedupe and the complete baseline when a later notification page fails', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + f.notices.update([remote('a', [item('old', 'channel-reply', 'same-time')])]); + const incomplete = remote('a', []); + if (incomplete.inbox.status === 'ready') incomplete.inbox.notificationItems = undefined; + f.notices.update([incomplete]); + f.notices.update([ + remote('a', [ + item('old', 'channel-reply', 'same-time'), + item('new', 'channel-reply', 'same-time'), + ]), + ]); + expect(f.show).toHaveBeenCalledTimes(1); + f.notices.update([ + remote('a', [ + item('old', 'channel-reply', 'same-time'), + item('new', 'channel-reply', 'same-time'), + ]), + ]); + expect(f.show).toHaveBeenCalledTimes(1); + }); + it('reports bounded redacted show failures without replaying the failed item', async () => { + const f = fixture(); + const diagnostic = vi.fn(); + const notices = createRemoteNotifications({ ...f.options, diagnostic }); + await notices.set('a', { enabled: true }); + notices.update([remote('a', [])]); + f.show.mockImplementation(() => { + throw new Error('private payload and credentials'); + }); + notices.update([remote('a', [item('1')])]); + notices.update([remote('a', [item('1'), item('2')])]); + notices.update([remote('a', [item('1'), item('2')])]); + expect(diagnostic).toHaveBeenCalledExactlyOnceWith('remote-notification-show-failed'); + expect(f.show).toHaveBeenCalledTimes(2); + }); + it('covers all Host-decided kinds including DM beyond the Activity window without replay', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = Array.from({ length: 35 }, (_, index) => + item(`old-${index}`, 'channel-mention', '2026-01-01'), + ); + f.notices.update([remote('a', old)]); + const fresh = [ + 'tool-approval', + 'user-question', + 'private-input', + 'channel-mention', + 'channel-reply', + 'channel-reply', + ].map((kind, index) => item(`new-${index}`, kind, '2026-01-02')); + const next = remote('a', [...old, ...fresh]); + if (next.inbox.status === 'ready') + next.inbox.sections = { action: [], replies: old.slice(0, 30), unread: [], handled: [] }; + f.notices.update([next]); + f.notices.update([remote('a', [...old, ...fresh])]); + expect(f.show).toHaveBeenCalledTimes(6); + }); + it('CLI uses the same choice with revision checks and never prompts for permission', async () => { + const f = fixture(); + const request = { expiresAt: Date.now() + 8000, signal: new AbortController().signal }; + const before = await f.notices.control({ hostId: 'a' }, undefined, request); + const patch = { enabled: true, preferences: before.preferences }; + await expect( + f.notices.control({ hostId: 'a', patch }, before.revision, request), + ).rejects.toThrow('notification-permission-required'); + expect(f.request).not.toHaveBeenCalled(); + f.permission('granted'); + const after = await f.notices.control({ hostId: 'a', patch }, before.revision, request); + expect(after.enabled).toBe(true); + await expect( + f.notices.control({ hostId: 'a', patch }, before.revision, request), + ).rejects.toThrow('notification-revision-stale'); + }); + it('requests permission only on enable, skips initial snapshots, dedupes and scopes identical IDs', async () => { + const f = fixture(); + f.notices.update([remote('a', [item('1')]), remote('b', [item('1')])]); + expect(f.request).not.toHaveBeenCalled(); + await f.notices.set('a', { enabled: true }); + await f.notices.set('b', { enabled: true }); + f.notices.update([remote('a', [item('1'), item('2')]), remote('b', [item('1'), item('2')])]); + expect(f.show).toHaveBeenCalledTimes(2); + f.notices.update([remote('a', [item('1'), item('2')]), remote('b', [item('1'), item('2')])]); + expect(f.show).toHaveBeenCalledTimes(2); + f.handles[1]!.onclick!(new Event('click')); + expect(f.open).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'b' })); + expect(createRemoteNotifications(f.options).choice('a').enabled).toBe(true); + }); + it('honors kinds, never leaks private input and never replays suppressed items', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true, preferences: { question: false } }); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', [item('1'), item('2', 'private-input')])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Private input needed', + tag: 'botharness-remote:a:2', + }); + await f.notices.set('a', { preferences: { question: true } }); + f.notices.update([remote('a', [item('1'), item('2', 'private-input')])]); + expect(f.show).toHaveBeenCalledTimes(1); + }); + it('retains identities beyond 2000 after they leave attention, without suppressing new older IDs', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = Array.from({ length: 2001 }, (_, index) => + item(`old-${index}`, 'channel-mention', '2026-10-22'), + ); + f.notices.update([remote('a', old)]); + f.notices.update([remote('a', [...old])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', old)]); + expect(f.show).not.toHaveBeenCalled(); + const fresh = item('new-earlier', 'user-question', '2026-01-01'); + f.notices.update([remote('a', [...old, fresh])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Request preview', + tag: 'botharness-remote:a:new-earlier', + }); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', [...old, fresh])]); + expect(f.show).toHaveBeenCalledTimes(1); + }); + it('suppresses reconnect backlogs and seen identities that reenter, closes on disposal', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + f.notices.update([remote('a', [item('1'), item('2')])]); + const first = remote('a', [item('2')]); + f.notices.update([first]); + f.notices.update([{ ...first, snapshot: { connection: { status: 'stopped', route: '' } } }]); + f.notices.update([first]); + f.notices.update([remote('a', [item('2'), item('3')])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([remote('a', [item('1'), item('2'), item('3'), item('4')])]); + expect(f.show).toHaveBeenCalledTimes(1); + f.notices.stop(); + expect(f.handles[0]!.close).toHaveBeenCalled(); + expect(f.handles[0]!.onclick).toBeNull(); + }); + it.each(['remove', 'credential', 'stop'] as const)( + 'releases old identities on %s and establishes a silent complete baseline for the new owner', + async (operation) => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', [item('previous')])]); + expect(f.show).toHaveBeenCalledTimes(1); + if (operation === 'remove') f.notices.update([]); + if (operation === 'stop') f.notices.stop(); + const next = (items: HumanAttentionItem[]) => { + const host = remote('a', items); + if (operation === 'credential') host.host.credential = 'replacement'; + return host; + }; + f.notices.update([next([item('baseline')])]); + expect(f.show).toHaveBeenCalledTimes(1); + expect(f.handles[0]!.close).toHaveBeenCalled(); + f.notices.update([next([item('baseline'), item('previous')])]); + f.notices.update([next([item('baseline'), item('previous')])]); + expect(f.show).toHaveBeenCalledTimes(2); + }, + ); + it('keeps denied permission off and handles a late permission result after disabling', async () => { + const f = fixture(); + f.permission('denied'); + await f.notices.set('a', { enabled: true }); + expect(f.notices.choice('a').enabled).toBe(false); + f.permission('default'); + let resolve!: () => void; + f.request.mockImplementation( + () => + new Promise((done) => { + resolve = () => done('granted'); + }), + ); + const enable = f.notices.set('a', { enabled: true }); + const refused = expect(enable).rejects.toThrow('notification-activation-cancelled'); + await f.notices.set('a', { enabled: false }); + resolve(); + await refused; + expect(f.notices.choice('a').enabled).toBe(false); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index a9d4274ab9..4caa79b3d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,7 +21,8 @@ "css-tree": "3.2.1", "saxes": "6.0.0", "silk-wasm": "3.7.1", - "uqr": "0.1.3" + "uqr": "0.1.3", + "ws": "8.21.3" }, "devDependencies": { "@deepseek-ai/cordis": "4.0.5", diff --git a/packages/core/src/appearance/control.ts b/packages/core/src/appearance/control.ts index e923b94f52..8d1b795c15 100644 --- a/packages/core/src/appearance/control.ts +++ b/packages/core/src/appearance/control.ts @@ -7,6 +7,7 @@ interface Command { patch?: unknown; wallpaper?: unknown; recovery?: unknown; + notifications?: unknown; expectedRevision?: string; } interface Client { @@ -69,7 +70,9 @@ export function createAppearanceControl(now = Date.now) { if (!client) return refused('appearance-client-stale', 404); if (input.action === 'request') { if ( - [input.patch, input.wallpaper, input.recovery].filter((v) => v !== undefined).length > 1 + [input.patch, input.wallpaper, input.recovery, input.notifications].filter( + (v) => v !== undefined, + ).length > 1 ) return refused('appearance-invalid-input', 400); if (input.patch !== undefined && typeof input.expectedRevision !== 'string') @@ -79,6 +82,12 @@ export function createAppearanceControl(now = Date.now) { const command: Command = { id: randomUUID(), expiresAt: now() + 8000, + ...(input.notifications === undefined + ? {} + : { + notifications: input.notifications, + expectedRevision: input.expectedRevision as string, + }), ...(input.recovery === undefined ? {} : { recovery: input.recovery, expectedRevision: input.expectedRevision as string }), diff --git a/packages/core/src/bots/cli-browser-notifications.ts b/packages/core/src/bots/cli-browser-notifications.ts new file mode 100644 index 0000000000..dd906a5cc1 --- /dev/null +++ b/packages/core/src/bots/cli-browser-notifications.ts @@ -0,0 +1,69 @@ +import { CliLiveError } from './cli-live-error.js'; + +export const BROWSER_NOTIFICATION_COMMANDS = [ + { + command: 'browser-notification-clients', + description: 'list open Clients for browser notification preferences', + }, + { + command: 'browser-notifications', + description: + 'read or set one Remote Host notification choice on --client-id (--remote-host-id, --patch-stdin, --expected-revision)', + }, +] as const; + +export async function runBrowserNotifications( + command: string, + rest: string[], + values: { + 'client-id'?: string; + 'remote-host-id'?: string; + 'patch-stdin'?: boolean; + 'expected-revision'?: string; + }, + io: { readStdin?: () => Promise }, + connect: () => Promise<{ + http(path: string, body?: Record): Promise<{ bytes: Uint8Array }>; + }>, +) { + const list = command === 'browser-notification-clients'; + if ( + rest.length || + (list + ? values['client-id'] || + values['remote-host-id'] || + values['patch-stdin'] || + values['expected-revision'] + : !values['client-id'] || !values['remote-host-id']) + ) + throw new CliLiveError( + 'usage', + 'Select --client-id and --remote-host-id for browser-notifications.', + ); + if (!!values['patch-stdin'] !== (values['expected-revision'] !== undefined)) + throw new CliLiveError('usage', 'Writes require --patch-stdin and --expected-revision.'); + let patch: unknown; + if (values['patch-stdin']) { + try { + patch = JSON.parse((await io.readStdin?.()) ?? ''); + } catch { + throw new CliLiveError('invalid-input', 'Invalid notification JSON on stdin.'); + } + } + const host = await connect(); + const result = await host.http( + '/api/botharness/appearance-control', + list + ? undefined + : { + action: 'request', + clientId: values['client-id'], + notifications: { + hostId: values['remote-host-id'], + ...(patch === undefined ? {} : { patch }), + }, + ...(patch === undefined ? {} : { expectedRevision: values['expected-revision'] }), + }, + ); + return JSON.parse(new TextDecoder().decode(result.bytes)) as unknown; +} diff --git a/packages/core/src/bots/cli-device-notifications.ts b/packages/core/src/bots/cli-device-notifications.ts new file mode 100644 index 0000000000..ccf1b222ca --- /dev/null +++ b/packages/core/src/bots/cli-device-notifications.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs'; +import { parseSavedHost } from '../../../../apps/mobile/src/host/hosts.js'; +import { parseNotificationPreferences } from '../../../../apps/mobile/src/host/device-notification-preferences.js'; +import { createHostPush, PushRequestFailed } from '../../../../apps/mobile/src/host/push.js'; +import { CliLiveError } from './cli-live-error.js'; + +export const DEVICE_NOTIFICATION_COMMANDS = [ + { + command: 'phone-notifications', + description: 'read or change the phone owner in an assigned Metro development client', + }, + { + command: 'device-notifications', + description: + 'read or copy a paired device notification preference (--device-file, --preferences-stdin)', + }, +] as const; +export const DEVICE_NOTIFICATION_OPTIONS = { + 'metro-port': { type: 'string' }, + 'target-id': { type: 'string' }, + 'device-name': { type: 'string' }, + 'device-file': { type: 'string' }, + 'preferences-stdin': { type: 'boolean' }, +} as const; + +export async function runDeviceNotifications( + rest: string[], + values: { 'device-file'?: string; 'preferences-stdin'?: boolean }, + io: { readStdin?: () => Promise }, +): Promise { + if (rest.length || !values['device-file']) + throw new CliLiveError('usage', 'Provide --device-file with the saved pairing JSON.'); + let saved: unknown; + try { + saved = JSON.parse(readFileSync(values['device-file'], 'utf8')); + } catch { + throw new CliLiveError('invalid-input', 'Cannot read the saved pairing JSON.'); + } + const host = parseSavedHost(saved); + if (!host) throw new CliLiveError('invalid-input', 'Invalid saved pairing JSON.'); + let preferences; + if (values['preferences-stdin']) { + try { + preferences = parseNotificationPreferences(JSON.parse((await io.readStdin?.()) ?? '')); + } catch {} + if (!preferences) + throw new CliLiveError( + 'invalid-input', + 'Provide all four boolean notification kinds on stdin.', + ); + } + const push = createHostPush({ + async request(url, init) { + const response = await fetch(url, { ...init, redirect: 'error' }); + return { status: response.status, text: () => response.text() }; + }, + openSocket() { + throw new Error('unary-only'); + }, + }); + try { + return preferences === undefined + ? await push.settings(host) + : await push.setPreferences(host, preferences); + } catch (error) { + throw new CliLiveError( + error instanceof PushRequestFailed ? error.code : 'unreachable', + 'Device notification request refused.', + ); + } +} diff --git a/packages/core/src/bots/cli-inbox.ts b/packages/core/src/bots/cli-inbox.ts index 2e50aae6cd..a4d5366161 100644 --- a/packages/core/src/bots/cli-inbox.ts +++ b/packages/core/src/bots/cli-inbox.ts @@ -2,6 +2,7 @@ import { watchInbox } from './cli-inbox-watch.js'; import { CliLiveError } from './cli-live-error.js'; export const INBOX_OPTIONS = { + notifications: { type: 'boolean' }, category: { type: 'string' }, 'watch-seconds': { type: 'string' }, channel: { type: 'string' }, @@ -76,6 +77,7 @@ export const INBOX_COMMANDS = [ export const INBOX_HOST_COMMANDS = ['grant-revoke', 'grant-write-set'] as const; export interface InboxValues { + notifications?: boolean; category?: string; 'watch-seconds'?: string; kind?: string; @@ -157,6 +159,8 @@ export async function runInboxCommand( values: InboxValues, connect: () => Promise<{ rpc: Rpc; stream?: (path: string) => Promise }>, ): Promise { + if (command === 'inbox-watch' && values.notifications) + usage('Use inbox --notifications with its returned cursor.'); const call = prepare(command === 'inbox-watch' ? 'inbox' : command, rest, values); if (command === 'inbox-watch') { const seconds = Number(values['watch-seconds'] ?? 10); @@ -184,6 +188,11 @@ export async function runInboxCommand( } const { rpc } = await connect(); const result = await rpc>(call.method, call.args); + if (call.args['notifications'] === true && result['notificationVersion'] !== 1) + throw new CliLiveError( + 'host-query-unsupported', + 'Update the Host to discover notification attention.', + ); if (call.args['dismissed'] === true && result['dismissed'] !== true) throw new CliLiveError('host-query-unsupported', 'Update the Host to list dismissed items.'); if (call.args['queryVersion'] === 1 && result['queryVersion'] !== 1) @@ -232,6 +241,30 @@ function prepare( const limit = values.limit === undefined ? undefined : Number(values.limit); if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) usage('--limit must be 1–100.'); + if (values.notifications) { + if ( + [ + values.category, + values.bot, + values.channel, + values.sort, + values.search, + values.kind, + ].some((value) => value !== undefined) || + values.unread || + values.snoozed || + values.dismissed + ) + usage('--notifications only accepts --limit and --cursor.'); + return { + method: 'humanAttention', + args: { + notifications: true, + ...(limit === undefined ? {} : { limit }), + ...(values.cursor === undefined ? {} : { cursor: values.cursor }), + }, + }; + } return { method: 'humanAttention', args: { diff --git a/packages/core/src/bots/cli-live.ts b/packages/core/src/bots/cli-live.ts index 723ce232ee..7f06fbb1eb 100644 --- a/packages/core/src/bots/cli-live.ts +++ b/packages/core/src/bots/cli-live.ts @@ -1,3 +1,4 @@ +import { runPhoneNotifications } from './cli-phone-notifications.js'; import { WINDOW_ZOOM_COMMANDS, WINDOW_ZOOM_OPTIONS, @@ -5,6 +6,15 @@ import { } from './cli-window-zoom.js'; import { PHONE_ROUTE_COMMANDS, runPhoneRouteCommand } from './cli-phone-route.js'; import { DEVICES_COMMANDS, isDevicesCommand, runDevicesCommand } from './cli-devices.js'; +import { + DEVICE_NOTIFICATION_COMMANDS, + DEVICE_NOTIFICATION_OPTIONS, + runDeviceNotifications, +} from './cli-device-notifications.js'; +import { + BROWSER_NOTIFICATION_COMMANDS, + runBrowserNotifications, +} from './cli-browser-notifications.js'; import { APPEARANCE_COMMANDS, APPEARANCE_OPTIONS, runAppearanceCommand } from './cli-appearance.js'; import { prepareCloud, prepareCloudWork } from './cli-cloud.js'; import { randomUUID } from 'node:crypto'; @@ -122,6 +132,8 @@ import { export { CliLiveError } from './cli-live-error.js'; export const LIVE_OPTIONS = { + 'remote-host-id': { type: 'string' }, + ...DEVICE_NOTIFICATION_OPTIONS, ...WINDOW_ZOOM_OPTIONS, ...CONNECT_OPTIONS, ...APPEARANCE_OPTIONS, @@ -217,6 +229,8 @@ const CONTROL_COMMANDS = [ ] as const; export const LIVE_COMMANDS = [ + ...BROWSER_NOTIFICATION_COMMANDS, + ...DEVICE_NOTIFICATION_COMMANDS, ...WINDOW_ZOOM_COMMANDS, ...PHONE_ROUTE_COMMANDS, ...DEVICES_COMMANDS, @@ -413,6 +427,9 @@ interface LiveValues ApprovalTemplateValues, ApprovalReviewerValues { 'client-id'?: string; + 'device-file'?: string; + 'remote-host-id'?: string; + 'preferences-stdin'?: boolean; host?: string; 'token-file'?: string; 'image-source'?: string; @@ -729,6 +746,12 @@ export async function runLiveCli( values: LiveValues, io: BotCreateCliIo, ): Promise { + if (command === 'device-notifications') return runDeviceNotifications(rest, values, io); + if (command === 'phone-notifications') return runPhoneNotifications(rest, values, io); + if (BROWSER_NOTIFICATION_COMMANDS.some((entry) => entry.command === command)) + return runBrowserNotifications(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); if (IM_COMMANDS.some((entry) => entry.command === command)) { const operation = await prepareImAuthorization(command, rest, values, io); const signal = AbortSignal.timeout(timeoutMs(values.timeout)); diff --git a/packages/core/src/bots/cli-phone-notifications.ts b/packages/core/src/bots/cli-phone-notifications.ts new file mode 100644 index 0000000000..9746e4398f --- /dev/null +++ b/packages/core/src/bots/cli-phone-notifications.ts @@ -0,0 +1,95 @@ +import { connect } from './development-inspector.mjs'; +import { parseNotificationPreferences } from '../../../../apps/mobile/src/host/device-notification-preferences.js'; +import type { PushPreferences } from '../../../../apps/mobile/src/host/push.js'; +import { CliLiveError } from './cli-live-error.js'; + +export function phoneNotificationExpression(preferences?: PushPreferences): string { + return `(async () => { + if (typeof __DEV__ === 'undefined' || !__DEV__) throw Error('DEBUG_ONLY'); + if (typeof globalThis.__r?.getModules !== 'function') throw Error('NOTIFICATION_OWNER_UNAVAILABLE'); + const matches = [...globalThis.__r.getModules().values()].filter(m => m.isInitialized && m.publicModule?.exports?.notificationPreferences); + if (matches.length !== 1) throw Error('NOTIFICATION_OWNER_UNAVAILABLE'); + const owner = matches[0].publicModule.exports.notificationPreferences; + if (!owner.ready || typeof owner.getSnapshot !== 'function' || typeof owner.set !== 'function') throw Error('NOTIFICATION_OWNER_UNAVAILABLE'); + try { await owner.ready; } catch { throw Error('NOTIFICATION_OWNER_NOT_READY'); } + const preferences = ${JSON.stringify(preferences ?? null)}; + if (preferences !== null) { + try { await owner.set(preferences); } catch { throw Error('NOTIFICATION_SAVE_FAILED'); } + } + const current = owner.getSnapshot(); + if (current.preferences === undefined) throw Error('NOTIFICATION_OWNER_NOT_READY'); + return { + owner: 'phone', + developmentClient: true, + saved: preferences !== null, + preferences: current.preferences, + syncing: current.syncing, + failedHostIds: current.failed, + syncStatus: current.syncing || current.failed.length ? 'pending' : 'complete' + }; + })()`; +} + +export async function runPhoneNotifications( + rest: string[], + values: { + 'metro-port'?: string; + 'target-id'?: string; + 'device-name'?: string; + 'preferences-stdin'?: boolean; + }, + io: { readStdin?: () => Promise }, +): Promise { + const port = Number(values['metro-port']); + if ( + rest.length || + !Number.isInteger(port) || + port < 1024 || + port > 65535 || + !values['target-id'] || + !values['device-name'] + ) + throw new CliLiveError( + 'usage', + 'Provide --metro-port, --target-id and --device-name for the assigned development client.', + ); + let preferences: PushPreferences | undefined; + if (values['preferences-stdin']) { + try { + const value: unknown = JSON.parse((await io.readStdin?.()) ?? ''); + preferences = parseNotificationPreferences(value); + if ( + preferences && + Object.keys(value as object).some((key) => !Object.hasOwn(preferences!, key)) + ) + preferences = undefined; + } catch {} + if (!preferences) + throw new CliLiveError( + 'invalid-input', + 'Provide all four boolean notification kinds on stdin.', + ); + } + let client: Awaited> | undefined; + try { + client = await connect(port, values['target-id'], values['device-name']); + const result = await client.send('Runtime.evaluate', { + expression: phoneNotificationExpression(preferences), + awaitPromise: true, + returnByValue: true, + }); + if (result.result?.value === undefined) throw new Error('INSPECTOR_RESULT_REQUIRED'); + return result.result.value; + } catch (error) { + const code = + error instanceof Error && /^[A-Z_]+$/.test(error.message) + ? error.message.toLowerCase().replaceAll('_', '-') + : 'inspector-unreachable'; + throw new CliLiveError( + code, + 'Phone notification operation refused; check the assigned development client.', + ); + } finally { + client?.close(); + } +} diff --git a/packages/core/src/bots/development-inspector.d.mts b/packages/core/src/bots/development-inspector.d.mts new file mode 100644 index 0000000000..3e5af9e572 --- /dev/null +++ b/packages/core/src/bots/development-inspector.d.mts @@ -0,0 +1,13 @@ +export function targetSocket( + target: { deviceName: string; appId: string; webSocketDebuggerUrl: string } | undefined, + port: number, + deviceName: string, +): URL; +export function connect( + port: number, + targetId: string, + deviceName: string, +): Promise<{ + send(method: string, params: Record): Promise<{ result?: { value?: unknown } }>; + close(): void; +}>; diff --git a/packages/core/src/bots/development-inspector.mjs b/packages/core/src/bots/development-inspector.mjs new file mode 100644 index 0000000000..e0b39b62b3 --- /dev/null +++ b/packages/core/src/bots/development-inspector.mjs @@ -0,0 +1,90 @@ +import WebSocket from 'ws'; + +export function targetSocket(target, port, simulatorName) { + if ( + !target || + target.deviceName !== simulatorName || + target.appId !== 'ai.botharness.deepseekbot.dev' + ) + throw Error('EXPLICIT_SIMULATOR_APP_TARGET_REQUIRED'); + const url = new URL(target.webSocketDebuggerUrl); + if ( + !['127.0.0.1', 'localhost'].includes(url.hostname) || + url.port !== String(port) || + url.protocol !== 'ws:' + ) + throw Error('LOOPBACK_DEBUGGER_REQUIRED'); + url.hostname = '127.0.0.1'; + return url; +} +export async function connect(port, targetId, simulatorName) { + if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error('INVALID_METRO_PORT'); + const targets = await ( + await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) }) + ).json(); + const matches = targets.filter((t) => t.id === targetId); + if (matches.length !== 1) throw Error('EXPLICIT_UNIQUE_TARGET_REQUIRED'); + const target = matches[0]; + const url = targetSocket(target, port, simulatorName); + const ws = new WebSocket(url, { origin: `http://127.0.0.1:${port}` }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.terminate(); + reject(Error('INSPECTOR_TIMEOUT')); + }, 5000); + ws.once('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.once('error', () => { + clearTimeout(timer); + reject(Error('INSPECTOR_CONNECTION_FAILED')); + }); + }); + let id = 0; + const pending = new Map(); + ws.on('message', (raw) => { + const r = JSON.parse(String(raw)); + const p = pending.get(r.id); + if (!p) return; + clearTimeout(p.timer); + pending.delete(r.id); + if (r.error || r.result?.exceptionDetails) { + const description = r.result?.exceptionDetails?.exception?.description ?? ''; + const code = [ + 'DEBUG_ONLY', + 'NOTIFICATION_OWNER_UNAVAILABLE', + 'NOTIFICATION_OWNER_NOT_READY', + 'NOTIFICATION_SAVE_FAILED', + 'CLOUD_MODULE_AMBIGUOUS', + 'SESSION_BUSY', + 'INVALID_VAULT', + 'TRUSTED_IMPORT_REQUIRED', + 'EXISTING_SESSION_REFUSED', + 'LOCAL_SESSION_REMAINS', + 'VERIFIED_NATIVE_IDENTITY_REQUIRED', + ].find((v) => description.includes(v)); + p.reject(Error(code ?? 'INSPECTOR_EVALUATION_FAILED')); + } else p.resolve(r.result); + }); + ws.on('close', () => { + for (const p of pending.values()) { + clearTimeout(p.timer); + p.reject(Error('INSPECTOR_CLOSED')); + } + pending.clear(); + }); + return { + send: (method, params) => + new Promise((resolve, reject) => { + const n = ++id; + const timer = setTimeout(() => { + pending.delete(n); + reject(Error('INSPECTOR_TIMEOUT')); + }, 20000); + pending.set(n, { resolve, reject, timer }); + ws.send(JSON.stringify({ id: n, method, params })); + }), + close: () => ws.close(), + }; +} diff --git a/packages/core/src/bridge/methods.ts b/packages/core/src/bridge/methods.ts index 5e06986aa7..697fc95eaa 100644 --- a/packages/core/src/bridge/methods.ts +++ b/packages/core/src/bridge/methods.ts @@ -2,6 +2,7 @@ import { approveGrantRequest, type GrantRequestApprovalResult, } from '../workspaces/grant-requests.js'; +import { notificationAttentionPage } from '../runtime/notification-attention.js'; import { MemoryImageError } from '../memory/image.js'; import { MIN_IDLE_MS, @@ -5163,6 +5164,8 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { }, humanAttention(payload) { const source = asObject(payload); + if (source['notifications'] !== undefined && typeof source['notifications'] !== 'boolean') + return invalidInput('notifications must be a boolean'); if (source['queryVersion'] !== undefined && source['queryVersion'] !== 1) return invalidInput('unsupported Activity query version'); if (source['queryVersion'] === 1 && deps.humanAttention === undefined) @@ -5224,6 +5227,24 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { ) return invalidInput('invalid cursor'); try { + if (source['notifications'] === true) { + if (deps.humanAttention === undefined) + return invalidInput('notification queries are unavailable'); + if ( + Object.keys(source).some( + (key) => + !['notifications', 'limit', 'cursor'].includes(key) && source[key] !== undefined, + ) + ) + return invalidInput('notification discovery does not accept Activity filters'); + return { + ok: true, + value: notificationAttentionPage(deps.humanAttention, { + ...(limit === undefined ? {} : { limit: limit as number }), + ...(cursor === undefined ? {} : { cursor: cursor as string }), + }), + }; + } return { ok: true, value: { diff --git a/packages/core/src/bridge/rpc.ts b/packages/core/src/bridge/rpc.ts index 44a411ddfe..08359ca7c8 100644 --- a/packages/core/src/bridge/rpc.ts +++ b/packages/core/src/bridge/rpc.ts @@ -1380,6 +1380,7 @@ export class BotharnessBridgeService extends TypertRemoteService { unreadOnly?: boolean, queryVersion?: number, dismissed?: boolean, + notifications?: boolean, ): HumanAttentionPage { return unwrap( this.methods.humanAttention({ @@ -1395,6 +1396,7 @@ export class BotharnessBridgeService extends TypertRemoteService { unreadOnly, queryVersion, dismissed, + notifications, }), ); } diff --git a/packages/core/src/database/schema-plan.ts b/packages/core/src/database/schema-plan.ts index 8b6a836c27..52b4466905 100644 --- a/packages/core/src/database/schema-plan.ts +++ b/packages/core/src/database/schema-plan.ts @@ -2466,6 +2466,21 @@ const PUSH_APP_RENDER_MIGRATION: SchemaMigration = { }, }; +const DEVICE_NOTIFICATION_PREFERENCE_MIGRATION: SchemaMigration = { + generation: 106, + module: 'push', + description: 'Keep the device-owned notification preference copy without a push token', + migrate(database) { + database.exec(` + CREATE TABLE device_notification_preferences ( + device_id TEXT PRIMARY KEY, + preferences_json TEXT NOT NULL + ); + INSERT INTO device_notification_preferences SELECT device_id, preferences_json FROM push_devices; + `); + }, +}; + export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ SESSION_OWNERSHIP_MIGRATION, MESSAGING_TRACER_MIGRATION, @@ -2571,4 +2586,5 @@ export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ USAGE_IDLE_COMPACTION_MIGRATION, THREE_ACTION_APPROVAL_MIGRATION, PUSH_APP_RENDER_MIGRATION, + DEVICE_NOTIFICATION_PREFERENCE_MIGRATION, ]); diff --git a/packages/core/src/diagnostics/client-diagnostics.ts b/packages/core/src/diagnostics/client-diagnostics.ts index 4533af99b1..99002d548e 100644 --- a/packages/core/src/diagnostics/client-diagnostics.ts +++ b/packages/core/src/diagnostics/client-diagnostics.ts @@ -5,6 +5,8 @@ import { openLogDatabase, type LogDatabase, type LogEntryInput } from '../logs/l export const CLIENT_DIAGNOSTICS_PATH = '/api/botharness/client-diagnostics'; const CODES = new Set([ + 'remote-notification-show-refused', + 'remote-notification-show-failed', 'observation-deferred', 'resource-load-failed', 'observer-installed', diff --git a/packages/core/src/diagnostics/client-observer.ts b/packages/core/src/diagnostics/client-observer.ts index 196930b13a..36398b2920 100644 --- a/packages/core/src/diagnostics/client-observer.ts +++ b/packages/core/src/diagnostics/client-observer.ts @@ -141,6 +141,13 @@ export function installClientObserver(win: Window & typeof globalThis): void { const onRejection = (event: PromiseRejectionEvent): void => { record(classify([event.reason]), 'unhandledrejection', true); }; + const onNotificationDiagnostic = (event: Event): void => { + if (!(event instanceof win.CustomEvent)) return; + const code: unknown = event.detail; + if (code === 'remote-notification-show-refused' || code === 'remote-notification-show-failed') + record(code, 'lifecycle'); + }; + win.addEventListener('botharness:notification-diagnostic', onNotificationDiagnostic); win.addEventListener('error', onError, true); win.addEventListener('unhandledrejection', onRejection); const shellPresent = (): boolean => { @@ -207,6 +214,7 @@ export function installClientObserver(win: Window & typeof globalThis): void { win.cancelAnimationFrame(frame); win.removeEventListener('error', onError, true); win.removeEventListener('unhandledrejection', onRejection); + win.removeEventListener('botharness:notification-diagnostic', onNotificationDiagnostic); win.removeEventListener('pagehide', onHide); if (win.console.error === errorConsole) win.console.error = originalError; if (win.console.warn === warnConsole) win.console.warn = originalWarn; diff --git a/packages/core/src/push/delivery.ts b/packages/core/src/push/delivery.ts index ed1b7c9043..052856076e 100644 --- a/packages/core/src/push/delivery.ts +++ b/packages/core/src/push/delivery.ts @@ -301,10 +301,32 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { | undefined, ); + const preferenceCopy = (deviceId: string): string | undefined => + database.read( + (db) => + ( + db + .prepare( + 'SELECT preferences_json FROM device_notification_preferences WHERE device_id = ?', + ) + .get(deviceId) as { preferences_json: string } | undefined + )?.preferences_json, + ); + + const settings = (deviceId: string): PushSettings => { + const current = row(deviceId); + const copy = preferenceCopy(deviceId); + return { + ...settingsOf(current), + preferences: preferencesOf(copy ?? current?.preferences_json), + }; + }; + const drop = (deviceId: string): boolean => database.transaction( (db) => { db.prepare('DELETE FROM push_sent WHERE device_id = ?').run(deviceId); + db.prepare('DELETE FROM device_notification_preferences WHERE device_id = ?').run(deviceId); return Number( db.prepare('DELETE FROM push_devices WHERE device_id = ?').run(deviceId).changes, ); @@ -322,7 +344,10 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { ['push'], ); record({ phase: 'preferences', deviceId: current.device_id }); - return settingsOf({ ...current, preferences_json: JSON.stringify(next) }); + return { + ...settingsOf({ ...current, preferences_json: JSON.stringify(next) }), + preferences: preferencesOf(preferenceCopy(current.device_id) ?? JSON.stringify(next)), + }; }; const dropToken = (deviceId: string, code: 'device-revoked' | 'device-not-registered'): void => { @@ -385,7 +410,9 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { dropToken(device.device_id, 'device-revoked'); continue; } - const preferences = preferencesOf(device.preferences_json); + const preferences = preferencesOf( + preferenceCopy(device.device_id) ?? device.preferences_json, + ); const enabled = enabledOf(device.preferences_json); const quiet = new Set(quietOf(device.preferences_json)); const registeredAt = Date.parse(device.registered_at); @@ -523,22 +550,29 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { }, settings(deviceId) { - return settingsOf(row(deviceId)); + return settings(deviceId); }, setPreferences(deviceId, input, enabled) { const current = row(deviceId); - if (current === undefined) return settingsOf(undefined); - const next = preferencesOf(current.preferences_json); + const next = settings(deviceId).preferences; const patch = typeof input === 'object' && input !== null ? input : {}; for (const kind of PUSH_KINDS) { const value = (patch as Record)[kind]; if (typeof value === 'boolean') next[kind] = value; } - return save(current, { - ...next, - enabled: typeof enabled === 'boolean' ? enabled : enabledOf(current.preferences_json), - }); + database.transaction( + (db) => + db + .prepare( + 'INSERT INTO device_notification_preferences (device_id, preferences_json) VALUES (?, ?) ON CONFLICT (device_id) DO UPDATE SET preferences_json = excluded.preferences_json', + ) + .run(deviceId, JSON.stringify(next)), + ['push'], + ); + if (current !== undefined && typeof enabled === 'boolean') save(current, { enabled }); + else record({ phase: 'preferences', deviceId }); + return settings(deviceId); }, setQuiet(deviceId, input) { diff --git a/packages/core/src/push/http.ts b/packages/core/src/push/http.ts index a34f8cfc9b..dd03f44f44 100644 --- a/packages/core/src/push/http.ts +++ b/packages/core/src/push/http.ts @@ -34,10 +34,25 @@ export function createDevicePushHttp( : { status: 400, body: { code: result.code } }; } case `${DEVICE_PUSH_PATH}/preferences`: { + const preferences = body['preferences']; + if ( + preferences !== undefined && + (typeof preferences !== 'object' || + preferences === null || + Array.isArray(preferences) || + Object.entries(preferences).some( + ([key, value]) => + !['approval', 'question', 'private-input', 'reply'].includes(key) || + typeof value !== 'boolean', + )) + ) + return { status: 400, body: { code: 'invalid-input' } }; + if (body['enabled'] !== undefined && typeof body['enabled'] !== 'boolean') + return { status: 400, body: { code: 'invalid-input' } }; + if (preferences === undefined && !delivery.settings(deviceId).registered) + return { status: 409, body: { code: 'push-not-registered' } }; const settings = delivery.setPreferences(deviceId, body['preferences'], body['enabled']); - return settings.registered - ? { status: 200, body: settings } - : { status: 409, body: { code: 'push-not-registered' } }; + return { status: 200, body: settings }; } case `${DEVICE_PUSH_PATH}/quiet`: { const result = delivery.setQuiet(deviceId, { diff --git a/packages/core/src/runtime/human-attention.ts b/packages/core/src/runtime/human-attention.ts index 8d933f6725..71bf53379f 100644 --- a/packages/core/src/runtime/human-attention.ts +++ b/packages/core/src/runtime/human-attention.ts @@ -53,6 +53,7 @@ export interface HumanAttentionItem { export interface HumanAttentionPage { queryVersion?: 1; + notificationVersion?: 1; items: HumanAttentionItem[]; nextCursor?: string; } @@ -87,7 +88,10 @@ export interface HumanAttentionQuery { }): HumanAttentionPage; status(messageCounts?: boolean): HumanAttentionStatus; unreadByChannel(): Array<{ channelId: string; unreadCount: number }>; - dmReplies(limit: number): HumanAttentionItem[]; + dmReplies( + limit: number, + before?: { createdAt: string; sourceEventId: string }, + ): HumanAttentionItem[]; durableAttention(): Array<{ botSlug: string; waitingHumanCount: number; @@ -1045,7 +1049,7 @@ SELECT 'handled:' || request.source_event_id AS id, : {}), }; }, - dmReplies(limit) { + dmReplies(limit, before) { const rows = database.read( (db) => db @@ -1058,6 +1062,7 @@ SELECT 'handled:' || request.source_event_id AS id, AND json_extract(e.payload_json, '$.author.kind') = 'bot' AND ${DM_CARD_FIELDS.map((field) => `json_extract(e.payload_json, '$.${field}') IS NULL`).join(' AND ')} AND NOT EXISTS (SELECT 1 FROM human_inbox_dismissals d WHERE d.human_id = '${LOCAL_HUMAN_ID}' AND d.item_id = 'unread:' || v.channel_id AND v.revision <= d.through_revision) + AND (? IS NULL OR v.created_at < ? OR (v.created_at = ? AND v.source_event_id < ?)) ORDER BY v.created_at DESC, v.source_event_id DESC LIMIT ? `) @@ -1065,6 +1070,10 @@ SELECT 'handled:' || request.source_event_id AS id, LOCAL_HUMAN_ID, LOCAL_HUMAN_ID, JSON.stringify(mutedChannelIds()), + before?.createdAt ?? null, + before?.createdAt ?? null, + before?.createdAt ?? null, + before?.sourceEventId ?? null, limit, ) as Array<{ channel_id: string; diff --git a/packages/core/src/runtime/notification-attention.ts b/packages/core/src/runtime/notification-attention.ts new file mode 100644 index 0000000000..a6cd49a4d9 --- /dev/null +++ b/packages/core/src/runtime/notification-attention.ts @@ -0,0 +1,65 @@ +import type { HumanAttentionPage, HumanAttentionQuery } from './human-attention.js'; + +type Cursor = { + section: number; + page?: string; + before?: { createdAt: string; sourceEventId: string }; +}; + +export function notificationAttentionPage( + attention: Pick, + input: { limit?: number; cursor?: string }, +): HumanAttentionPage { + const limit = input.limit ?? 100; + let cursor: Cursor = { section: 0 }; + if (input.cursor !== undefined) { + const value: unknown = JSON.parse(Buffer.from(input.cursor, 'base64url').toString()); + if (typeof value !== 'object' || value === null) throw new Error('invalid notification cursor'); + const parsed = value as Cursor & { version?: number }; + if ( + parsed.version !== 1 || + ![0, 1, 2].includes(parsed.section) || + (parsed.page !== undefined && typeof parsed.page !== 'string') || + (parsed.before !== undefined && + (typeof parsed.before?.createdAt !== 'string' || + typeof parsed.before?.sourceEventId !== 'string')) + ) + throw new Error('invalid notification cursor'); + cursor = parsed; + } + const encode = (next: Cursor) => + Buffer.from(JSON.stringify({ version: 1, ...next })).toString('base64url'); + if (cursor.section < 2) { + const page = attention.list({ + category: cursor.section === 0 ? 'action' : 'replies', + sort: 'newest', + unreadOnly: true, + limit, + ...(cursor.page === undefined ? {} : { cursor: cursor.page }), + }); + return { + notificationVersion: 1, + items: page.items, + nextCursor: encode( + page.nextCursor + ? { section: cursor.section, page: page.nextCursor } + : { section: cursor.section + 1 }, + ), + }; + } + const rows = attention.dmReplies(limit + 1, cursor.before); + const items = rows.slice(0, limit); + const last = items.at(-1); + return { + notificationVersion: 1, + items, + ...(rows.length > limit && last?.sourceEventId !== undefined + ? { + nextCursor: encode({ + section: 2, + before: { createdAt: last.createdAt, sourceEventId: last.sourceEventId }, + }), + } + : {}), + }; +} diff --git a/packages/core/test/appearance-control.test.ts b/packages/core/test/appearance-control.test.ts index cd5585a9e1..b39101724b 100644 --- a/packages/core/test/appearance-control.test.ts +++ b/packages/core/test/appearance-control.test.ts @@ -7,6 +7,33 @@ const request = (body: unknown) => }); afterEach(() => vi.useRealTimers()); describe('explicit Client appearance delivery', () => { + it('forwards notification choices only to the named Client without persisting them', async () => { + const host = createAppearanceControl(); + const lease = await (await host.fetch(request({ action: 'register', platform: 'web' }))).json(); + const poll = host.fetch(request({ action: 'poll', ...lease })); + await Promise.resolve(); + const notifications = { hostId: 'remote', patch: { enabled: false } }; + const response = host.fetch( + request({ + action: 'request', + clientId: lease.clientId, + notifications, + expectedRevision: 'before', + }), + ); + const { command } = await (await poll).json(); + expect(command).toMatchObject({ notifications, expectedRevision: 'before' }); + await host.fetch( + request({ + action: 'reply', + ...lease, + requestId: command.id, + result: { error: { code: 'notification-revision-stale' } }, + }), + ); + expect((await response).status).toBe(409); + host.dispose(); + }); it('delivers only to the selected leased Client, relays storage refusal, and drops targets on disposal', async () => { const host = createAppearanceControl(); const a = await (await host.fetch(request({ action: 'register', platform: 'web' }))).json(); diff --git a/packages/core/test/bridge-rpc.test.ts b/packages/core/test/bridge-rpc.test.ts index e7069fd810..f8b5303ba0 100644 --- a/packages/core/test/bridge-rpc.test.ts +++ b/packages/core/test/bridge-rpc.test.ts @@ -400,8 +400,9 @@ describe('bridge typert service', () => { assignmentModels, }); }); - it('exposes Activity query arguments through the gateway service and refuses an unavailable owner', () => { - const { service } = setup(); + it('exposes Activity and notification query arguments and refuses an unavailable owner', () => { + const { service, methods } = setup(); + const query = vi.spyOn(methods, 'humanAttention'); expect(parameterNames(service.humanAttention)).toEqual([ 'category', 'botSlug', @@ -415,6 +416,7 @@ describe('bridge typert service', () => { 'unreadOnly', 'queryVersion', 'dismissed', + 'notifications', ]); expect(parameterNames(service.humanAttentionRestore)).toEqual(['itemId', 'sourceKey']); expect(() => @@ -432,6 +434,38 @@ describe('bridge typert service', () => { 1, ), ).toThrow('Activity queries are unavailable'); + expect(() => + service.humanAttention( + undefined, + undefined, + undefined, + 100, + 'notification-cursor', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ), + ).toThrow('notification queries are unavailable'); + expect(query).toHaveBeenLastCalledWith({ + category: undefined, + botSlug: undefined, + channelId: undefined, + limit: 100, + cursor: 'notification-cursor', + sort: undefined, + snoozed: undefined, + kind: undefined, + search: undefined, + unreadOnly: undefined, + queryVersion: undefined, + dismissed: undefined, + notifications: true, + }); }); it('exposes and forwards model revision and advanced preset arguments through the gateway façade', async () => { diff --git a/packages/core/test/cli-browser-notifications.test.ts b/packages/core/test/cli-browser-notifications.test.ts new file mode 100644 index 0000000000..e942d58200 --- /dev/null +++ b/packages/core/test/cli-browser-notifications.test.ts @@ -0,0 +1,36 @@ +import { expect, it, vi } from 'vitest'; +import { runBrowserNotifications } from '../src/bots/cli-browser-notifications.js'; + +it('targets the selected live Client and Host with a revision, without requesting OS permission', async () => { + const http = vi.fn(async () => ({ bytes: new TextEncoder().encode('{"enabled":false}') })); + const connect = async () => ({ http }); + await runBrowserNotifications('browser-notification-clients', [], {}, {}, connect); + expect(http).toHaveBeenLastCalledWith('/api/botharness/appearance-control', undefined); + const target = { 'client-id': 'browser-a', 'remote-host-id': 'host-b' }; + const patch = { + enabled: false, + preferences: { approval: false, question: true, 'private-input': true, reply: true }, + }; + await runBrowserNotifications( + 'browser-notifications', + [], + { ...target, 'patch-stdin': true, 'expected-revision': 'rev' }, + { readStdin: async () => JSON.stringify(patch) }, + connect, + ); + expect(http).toHaveBeenLastCalledWith('/api/botharness/appearance-control', { + action: 'request', + clientId: 'browser-a', + notifications: { hostId: 'host-b', patch }, + expectedRevision: 'rev', + }); + await expect( + runBrowserNotifications( + 'browser-notifications', + [], + { ...target, 'patch-stdin': true }, + {}, + connect, + ), + ).rejects.toMatchObject({ code: 'usage' }); +}); diff --git a/packages/core/test/cli-device-notifications.test.ts b/packages/core/test/cli-device-notifications.test.ts new file mode 100644 index 0000000000..754502c899 --- /dev/null +++ b/packages/core/test/cli-device-notifications.test.ts @@ -0,0 +1,73 @@ +import { createServer } from 'node:http'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { runBotCreateCli } from '../src/bots/bot-create-cli.js'; +import { createTempRoot } from './helpers.js'; + +it('reads and writes the admitted device copy with secrets only in a file, and reports coded refusals', async () => { + const calls: Array<{ method: string | undefined; url: string | undefined; body: string }> = []; + let refused = false; + const server = createServer(async (req, res) => { + expect(req.headers.authorization).toBe('Device paired.private-secret'); + expect(req.headers.cookie).toBeUndefined(); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk); + calls.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString() }); + res.writeHead(refused ? 401 : 200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify( + refused + ? { code: 'device-revoked' } + : { + registered: false, + preferences: { approval: false, question: true, 'private-input': true, reply: true }, + }, + ), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + const file = join(createTempRoot('device-cli-'), 'pairing.json'); + writeFileSync( + file, + JSON.stringify({ + hostId: 'host', + hostName: 'Host', + deviceId: 'paired', + credential: 'paired.private-secret', + routes: [`http://127.0.0.1:${address.port}`], + }), + ); + const preferences = { approval: false, question: true, 'private-input': true, reply: true }; + const run = async (args: string[], input = '') => { + const output: string[] = []; + const errors: string[] = []; + const code = await runBotCreateCli(['device-notifications', '--device-file', file, ...args], { + env: {}, + readStdin: async () => input, + stdout: (text) => output.push(text), + stderr: (text) => errors.push(text), + }); + expect(output).toHaveLength(1); + expect(output[0]).not.toContain('private-secret'); + expect(errors.join('')).not.toContain('private-secret'); + return { code, value: JSON.parse(output[0]!) }; + }; + try { + expect((await run([])).value.preferences).toEqual(preferences); + expect((await run(['--preferences-stdin'], JSON.stringify(preferences))).code).toBe(0); + expect(calls[1]).toEqual({ + method: 'POST', + url: '/botharness-device/push/preferences', + body: JSON.stringify({ preferences }), + }); + expect((await run(['--preferences-stdin'], '{}')).code).toBe(1); + refused = true; + expect((await run([])).value).toMatchObject({ error: { code: 'device-revoked' } }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/core/test/cli-inbox.test.ts b/packages/core/test/cli-inbox.test.ts index 5c74dfced2..4c48ce67c7 100644 --- a/packages/core/test/cli-inbox.test.ts +++ b/packages/core/test/cli-inbox.test.ts @@ -423,3 +423,23 @@ describe('Human Inbox, assignment, Session and approval-rule commands', () => { expect(live.calls).toHaveLength(0); }); }); + +it('CLI requests Host notification discovery and refuses unsupported Hosts', async () => { + const { runInboxCommand } = await import('../src/bots/cli-inbox.js'); + const rpc = vi.fn(async () => ({ notificationVersion: 1, items: [], nextCursor: 'next' })); + const connect = async () => ({ + rpc: rpc as (method: string, args: Record) => Promise, + }); + await expect( + runInboxCommand('inbox', [], { notifications: true, limit: '20', cursor: 'page' }, connect), + ).resolves.toMatchObject({ nextCursor: 'next' }); + expect(rpc).toHaveBeenCalledExactlyOnceWith('humanAttention', { + notifications: true, + limit: 20, + cursor: 'page', + }); + rpc.mockResolvedValue({ items: [] } as any); + await expect(runInboxCommand('inbox', [], { notifications: true }, connect)).rejects.toThrow( + 'Update the Host', + ); +}); diff --git a/packages/core/test/cli-phone-notifications.test.ts b/packages/core/test/cli-phone-notifications.test.ts new file mode 100644 index 0000000000..ee7dea8024 --- /dev/null +++ b/packages/core/test/cli-phone-notifications.test.ts @@ -0,0 +1,305 @@ +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { runInNewContext } from 'node:vm'; +import { expect, it, vi } from 'vitest'; +import { runBotCreateCli } from '../src/bots/bot-create-cli.js'; +import { phoneNotificationExpression } from '../src/bots/cli-phone-notifications.js'; +import { + createDeviceNotificationPreferences, + DEFAULT_NOTIFICATION_PREFERENCES, +} from '../../../apps/mobile/src/host/device-notification-preferences.js'; +import type { PushPreferences } from '../../../apps/mobile/src/host/push.js'; + +const { WebSocketServer, WebSocket } = createRequire(import.meta.url)('ws'); +const schedule = globalThis.setTimeout; +const cancel = globalThis.clearTimeout; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((ready) => { + resolve = ready; + }); + return { promise, resolve }; +} + +async function bounded(pending: Promise, phase: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + pending, + new Promise((_, reject) => { + timer = schedule(() => reject(Error(`inspector fixture stalled: ${phase}`)), 2000); + }), + ]); + } finally { + cancel(timer); + } +} + +function context(owner: unknown, dev = true, copies = 1) { + return { + __DEV__: dev, + __r: { + getModules: () => + new Map( + Array.from({ length: copies }, (_, i) => [ + i, + { isInitialized: true, publicModule: { exports: { notificationPreferences: owner } } }, + ]), + ), + }, + }; +} + +it('the development CLI and UI use one persistent owner and distinguish pending Host copies', async () => { + let disk: PushPreferences = DEFAULT_NOTIFICATION_PREFERENCES; + const copies = new Map(); + const offline = new Set(['b']); + const options = { + storage: { + read: async () => disk, + write: (value: PushPreferences) => { + disk = value; + }, + }, + push: { + settings: vi.fn(), + setPreferences: vi.fn(async (host, preferences) => { + if (offline.has(host.hostId)) throw Error('offline'); + copies.set(host.hostId, preferences); + return { registered: false, enabled: true, preferences, quietChannelIds: [] }; + }), + }, + }; + const owner = createDeviceNotificationPreferences(options); + const hosts = ['a', 'b'].map((id) => ({ + hostId: id, + hostName: id, + deviceId: id, + credential: 'private-secret', + routes: [], + })); + await owner.sync(hosts); + const ui = vi.fn(); + owner.subscribe(ui); + const result = await runInNewContext( + phoneNotificationExpression({ ...disk, reply: false }), + context(owner), + ); + expect(result).toMatchObject({ + owner: 'phone', + saved: true, + syncStatus: 'pending', + failedHostIds: ['b'], + preferences: { reply: false }, + }); + expect(ui).toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('private-secret'); + expect(owner.getSnapshot().preferences?.reply).toBe(false); + offline.clear(); + const reloaded = createDeviceNotificationPreferences(options); + await reloaded.sync(hosts); + expect(copies.get('b')?.reply).toBe(false); + expect(await runInNewContext(phoneNotificationExpression(), context(reloaded))).toMatchObject({ + saved: false, + syncStatus: 'complete', + preferences: { reply: false }, + }); + for (const runtime of [context(owner, false), context(owner, true, 0), context(owner, true, 2)]) + await expect(runInNewContext(phoneNotificationExpression(), runtime)).rejects.toThrow(); + await expect( + runInNewContext( + phoneNotificationExpression(), + context({ ...owner, ready: Promise.reject(Error('private error')) }), + ), + ).rejects.toThrow('NOTIFICATION_OWNER_NOT_READY'); +}); + +async function inspectorFixture( + options: { + duplicates?: boolean; + deviceName?: string; + release?: boolean; + disconnect?: boolean; + stall?: boolean; + discovery?: Promise; + } = {}, +) { + const received = deferred(); + const discovered = deferred(); + const server = createServer((_request, response) => { + const target = { + id: 'target', + deviceName: options.deviceName ?? 'Assigned phone', + appId: options.release ? 'ai.botharness.deepseekbot' : 'ai.botharness.deepseekbot.dev', + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/inspector`, + }; + discovered.resolve(); + response.setHeader('content-type', 'application/json'); + const reply = () => + response.end(JSON.stringify(options.duplicates ? [target, target] : [target])); + if (options.discovery) void options.discovery.then(reply); + else reply(); + }); + const sockets = new WebSocketServer({ server }); + sockets.on('connection', (socket: any) => { + socket.on('message', (raw: Buffer) => { + const request = JSON.parse(String(raw)); + received.resolve(request); + if (options.disconnect) { + socket.close(); + return; + } + if (options.stall) return; + socket.send( + JSON.stringify({ + id: request.id, + result: { result: { value: { owner: 'phone', saved: false } } }, + }), + ); + }); + }); + await bounded(new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)), 'listen'); + const address = server.address(); + if (!address || typeof address === 'string') throw Error('address required'); + const port = address.port; + const peersClosed: Promise[] = []; + const originalClose = WebSocket.prototype.close; + const closeSpy = vi.spyOn(WebSocket.prototype, 'close').mockImplementation(function ( + this: any, + ...args: any[] + ) { + if (this.readyState !== WebSocket.CLOSED) + peersClosed.push(new Promise((resolve) => this.once('close', resolve))); + return originalClose.apply(this, args); + }); + const run = async (target = 'target') => { + const output: string[] = []; + await runBotCreateCli( + [ + 'phone-notifications', + '--metro-port', + String(port), + '--target-id', + target, + '--device-name', + 'Assigned phone', + ], + { env: {}, stdout: (text) => output.push(text), stderr: () => {} }, + ); + return JSON.parse(output[0]!); + }; + return { + run, + received: received.promise, + discovered: discovered.promise, + async idleConnection() { + const socket = new WebSocket(`ws://127.0.0.1:${port}/inspector`); + peersClosed.push(new Promise((resolve) => socket.once('close', resolve))); + await bounded( + new Promise((resolve) => socket.once('open', resolve)), + 'idle connection', + ); + return socket; + }, + async close() { + try { + for (const socket of sockets.clients) socket.terminate(); + const closed = new Promise((resolve) => sockets.close(() => resolve())); + server.closeAllConnections(); + await bounded( + Promise.all([closed, new Promise((resolve) => server.close(() => resolve()))]), + 'server cleanup', + ); + await bounded(Promise.all(peersClosed), 'inspector peers close'); + expect(sockets.clients.size).toBe(0); + expect(server.listening).toBe(false); + } finally { + closeSpy.mockRestore(); + } + }, + }; +} + +it.each([ + { + name: 'assigned development target', + options: {}, + target: 'target', + expected: { owner: 'phone' }, + }, + { + name: 'missing target', + options: {}, + target: 'missing', + expected: { error: { code: 'explicit-unique-target-required' } }, + }, + { + name: 'duplicate targets', + options: { duplicates: true }, + target: 'target', + expected: { error: { code: 'explicit-unique-target-required' } }, + }, + { + name: 'wrong device', + options: { deviceName: 'Wrong phone' }, + target: 'target', + expected: { error: { code: 'explicit-simulator-app-target-required' } }, + }, + { + name: 'release app', + options: { release: true }, + target: 'target', + expected: { error: { code: 'explicit-simulator-app-target-required' } }, + }, + { + name: 'disconnected inspector', + options: { disconnect: true }, + target: 'target', + expected: { error: { code: 'inspector-closed' } }, + }, +])('$name returns its exact CLI result', async ({ name, options, target, expected }) => { + const fixture = await inspectorFixture(options); + try { + expect(await bounded(fixture.run(target), name)).toMatchObject(expected); + } finally { + await fixture.close(); + } +}); + +it('times out the received evaluation at 20 seconds despite an idle socket and delayed discovery', async () => { + const discovery = deferred(); + const fixture = await inspectorFixture({ stall: true, discovery: discovery.promise }); + let pending: Promise | undefined; + try { + await fixture.idleConnection(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + pending = fixture.run(); + const settled = vi.fn(); + void pending.then(settled); + await bounded(fixture.discovered, 'target discovery'); + await vi.advanceTimersByTimeAsync(20_100); + expect(settled).not.toHaveBeenCalled(); + discovery.resolve(); + expect(await bounded(fixture.received, 'Runtime.evaluate delivery')).toMatchObject({ + id: 1, + method: 'Runtime.evaluate', + params: { awaitPromise: true, returnByValue: true }, + }); + await vi.advanceTimersByTimeAsync(19_999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(await bounded(pending, 'evaluation deadline result')).toMatchObject({ + error: { code: 'inspector-timeout' }, + }); + } finally { + discovery.resolve(); + try { + await fixture.close(); + if (pending) await bounded(pending, 'pending CLI cleanup'); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + } +}); diff --git a/packages/core/test/human-attention-query.test.ts b/packages/core/test/human-attention-query.test.ts index 4565b6495b..8bbeadef45 100644 --- a/packages/core/test/human-attention-query.test.ts +++ b/packages/core/test/human-attention-query.test.ts @@ -18,6 +18,80 @@ function adapter(): BotAgentAdapter { } describe('Human attention projection', () => { + it('discovers notifications past 30 old mentions and includes paginated ordinary DM replies', async () => { + const core = createCore({ + dshHome: createTempRoot('notification-discovery-'), + agents: adapter(), + }); + try { + core.registry.create({ slug: 'ada', displayName: 'Ada' }); + const group = core.channels.createGroup({ name: 'Team', members: ['ada'] }); + const dm = core.channels.getOrCreateDm('ada', 'Ada')!; + await core.channels.appendMessage(group.id, { + id: 'human', + at: '2026-10-01T00:00:00Z', + author: { kind: 'human' }, + body: 'Review', + }); + for (let i = 0; i < 35; i++) + await core.channels.appendMessage(group.id, { + id: `mention-${i}`, + at: '2026-10-01T00:01:00Z', + author: { kind: 'bot', slug: 'ada' }, + body: '@Human review', + botCausation: { rootSourceEventId: 'root', parentSourceEventId: 'parent', hop: 1 }, + humanMentions: [{ humanId: 'local-human', label: 'Human', start: 0, end: 6 }], + replyTo: 'human', + }); + await core.channels.appendMessage(group.id, { + id: 'new-reply', + at: '2026-10-01T00:02:00Z', + author: { kind: 'bot', slug: 'ada' }, + body: 'Ready', + replyTo: 'human', + }); + for (let i = 0; i < 4; i++) + await core.channels.appendMessage(dm.id, { + id: `dm-${i}`, + at: '2026-10-01T00:03:00Z', + author: { kind: 'bot', slug: 'ada' }, + body: 'Ordinary DM', + }); + expect( + core.humanAttention + .list({ category: 'replies', sort: 'mentions-first', limit: 30 }) + .items.some((item) => item.messageId === 'new-reply'), + ).toBe(false); + const methods = createBridgeMethods({ ...core }); + const found: string[] = []; + let cursor: string | undefined; + do { + const page = await methods.humanAttention({ + notifications: true, + limit: 3, + ...(cursor ? { cursor } : {}), + }); + if (!page.ok) throw new Error('notification-query-refused'); + expect(page.value.notificationVersion).toBe(1); + found.push(...page.value.items.map((item) => item.messageId!)); + cursor = page.value.nextCursor; + } while (cursor); + expect(found).toHaveLength(40); + expect(new Set(found).size).toBe(40); + expect(found).toContain('new-reply'); + expect(found).toContain('dm-0'); + expect(found).toContain('dm-3'); + expect( + await methods.humanAttention({ notifications: true, cursor: 'invalid' }), + ).toMatchObject({ ok: false }); + expect( + await methods.humanAttention({ notifications: true, sort: 'mentions-first' }), + ).toMatchObject({ ok: false }); + } finally { + await core.runtime.close(); + core.operationalDatabase.close(); + } + }); it('pages mention priority across replies and applies unread-only before pagination', async () => { const core = createCore({ dshHome: createTempRoot('activity-sort-'), agents: adapter() }); try { diff --git a/packages/core/test/managed-git.test.ts b/packages/core/test/managed-git.test.ts index 6716e89f18..0e58e1f9c9 100644 --- a/packages/core/test/managed-git.test.ts +++ b/packages/core/test/managed-git.test.ts @@ -11,7 +11,7 @@ import { } from 'node:fs'; import { delimiter, join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createBridgeMethods } from '../src/bridge/methods.js'; import { probeGit, type GitAvailability } from '../src/memory/git-probe.js'; @@ -184,9 +184,13 @@ describe.skipIf(process.platform === 'win32')('Managed Git install', () => { }); it('gives up on a source that stops sending data and tries the next one', async () => { + const requested: string[] = []; + const aborted: string[] = []; const { dist, logs, options } = setup({ stallTimeoutMs: 50, fetchImpl: (async (url: string, init?: RequestInit) => { + requested.push(url); + init?.signal?.addEventListener('abort', () => aborted.push(url)); if (url.startsWith('https://mirror.test/')) { return new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(init.signal?.reason)), @@ -196,12 +200,44 @@ describe.skipIf(process.platform === 'win32')('Managed Git install', () => { }) as typeof fetch, }); const git = createGitService(options); - git.install(); - await git.settled(); - expect(git.status()).toMatchObject({ available: true, source: 'managed' }); - expect(logs).toContainEqual( - expect.stringMatching(/source=mirror\.test error=no data for 0\.05s/u), - ); + const scheduleDeadline = setTimeout; + const cancelDeadline = clearTimeout; + let deadline: ReturnType | undefined; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + git.install(); + await vi.advanceTimersByTimeAsync(49); + const beforeDeadline = { requested: [...requested], aborted: [...aborted] }; + await vi.advanceTimersByTimeAsync(1); + const atDeadline = { requested: [...requested], aborted: [...aborted] }; + await Promise.race([ + git.settled(), + new Promise((_resolve, reject) => { + deadline = scheduleDeadline( + () => reject(new Error('Managed Git fallback did not settle')), + 2_000, + ); + }), + ]); + expect(beforeDeadline).toEqual({ requested: [`https://mirror.test/${ASSET}`], aborted: [] }); + expect(atDeadline).toEqual({ + requested: [`https://mirror.test/${ASSET}`, `https://github.test/${ASSET}`], + aborted: [`https://mirror.test/${ASSET}`], + }); + expect(requested).toEqual([`https://mirror.test/${ASSET}`, `https://github.test/${ASSET}`]); + expect(aborted).toEqual([`https://mirror.test/${ASSET}`]); + expect(git.status()).toMatchObject({ available: true, source: 'managed' }); + expect(logs).toContainEqual( + expect.stringMatching(/source=mirror\.test error=no data for 0\.05s/u), + ); + expect(logs).toContainEqual( + expect.stringMatching(/^git phase=downloaded source=github\.test /u), + ); + expect(vi.getTimerCount()).toBe(0); + } finally { + cancelDeadline(deadline); + vi.useRealTimers(); + } }); it('reports a network failure naming every source when all downloads fail', async () => { diff --git a/packages/core/test/push-delivery.test.ts b/packages/core/test/push-delivery.test.ts index 82d94f3150..5b19ea81ee 100644 --- a/packages/core/test/push-delivery.test.ts +++ b/packages/core/test/push-delivery.test.ts @@ -124,6 +124,48 @@ const approval = (id: string, createdAt: string): PushCandidate => ({ }); describe('Push delivery', () => { + it('accepts a device preference before token registration and uses the copy only for sends', async () => { + const f = fixture(); + const phone = f.device('Phone'); + const http = createDevicePushHttp(f.push); + const answer = await http({ + method: 'POST', + path: '/botharness-device/push/preferences', + deviceId: phone.device.id, + body: { preferences: { approval: false } }, + }); + expect(answer).toMatchObject({ + status: 200, + body: { registered: false, preferences: { approval: false } }, + }); + f.push.register(phone.device.id, { token: TOKEN_A, platform: 'ios' }); + expect(f.push.settings(phone.device.id).preferences.approval).toBe(false); + f.advance(1000); + f.setInbox([approval('blocked', f.at(0))]); + await f.push.reconcile(); + expect(f.expo.flat().filter((message) => message.data.itemId)).toEqual([]); + expect(f.push.settings(phone.device.id).registered).toBe(true); + f.push.setPreferences(phone.device.id, { approval: true }); + f.advance(1000); + f.setInbox([approval('new', f.at(0))]); + await f.push.reconcile(); + expect( + f.expo + .flat() + .filter((message) => message.data.itemId) + .map((message) => message.data.itemId), + ).toEqual(['new']); + expect( + ( + await http({ + method: 'POST', + path: '/botharness-device/push/preferences', + deviceId: phone.device.id, + body: { preferences: { approval: 'false' } }, + }) + ).status, + ).toBe(400); + }); it('sends one notification per enabled device for a new approval', async () => { const f = fixture(); const phone = f.device('Phone'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b27c8c6b3..da71a3e5a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,6 +808,9 @@ importers: uqr: specifier: 0.1.3 version: 0.1.3 + ws: + specifier: 8.21.3 + version: 8.21.3 devDependencies: '@deepseek-ai/cordis': specifier: 4.0.5 diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index 3bb9df83ba..5761a97ca6 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -783,6 +783,23 @@ ], "commands": ["window-zoom-clients", "window-zoom-get", "window-zoom-set", "window-zoom-reset"] }, + { + "id": "remote-host-notifications", + "description": "Browser-owned Remote Host choices; inbox --notifications pages Host-decided attention. Phone owner AX is development-client-only (phone-notifications); release parity and native real runs remain unverified. device-notifications edits Host copies only.", + "scope": "Client / Paired Device", + "surfaces": [ + "storage:packages/client/src/client/remote-notifications.ts:setItem(REMOTE_NOTIFICATION_KEY)", + "setting:packages/client/src/client/remote-notification-settings.tsx:{t('remoteHosts.notifications.enable')}", + "setting:packages/client/src/client/remote-notification-settings.tsx:{t(labels[kind])}" + ], + "commands": [ + "browser-notification-clients", + "browser-notifications", + "device-notifications", + "phone-notifications", + "inbox" + ] + }, { "id": "deepseekbot-local-appearance", "description": "Explicit Client-targeted device-local DeepSeekBot themes; authenticated Host forwards requests without a preference mirror.",