From b4ccf742e2ad29f6271dcaa8e564e0719901a5da Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:54:38 -0400 Subject: [PATCH 01/11] Push & relay polish: actionable payloads, relay advertisement, default-on relay, LA shutdown end, badges (brain/worker/desktop side) - pushPublisherService: approval alerts carry sessionId/itemId/category (ADE_APPROVAL); LA content-state waiting_for_approval rows gain optional itemId; aps.badge = awaiting-attention count with a silent badge-only sync item when the count drops; shutdown() publishes a bounded best-effort Live Activity end (dismissal +60s, active runs stamped stale) wired into the daemon dispose path. - push-relay worker: passes category/badge into aps, sessionId/itemId top-level, and accepts title-less badge-only items. - hello_ok + brain_status advertise cloudRelayWssUrl (optional, backward compatible) so already-paired phones learn the off-LAN route; toggle flips broadcast brain_status immediately; brain ingress fallback advertises it too. - syncCloudRelayStore: default enabled (missing field reads true; explicit false preserved as the kill-switch); desktop Settings control relabeled "ADE relay"; ade sync relay help updated. - docs: sync README security bullet + status table, push-notifications, ios-companion transport race. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/cli.ts | 24 +++- .../push/pushPublisherService.test.ts | 94 ++++++++++++++- .../src/services/push/pushPublisherService.ts | 110 ++++++++++++++++++ .../src/services/push/pushRelayClient.ts | 9 ++ .../sync/brainProjectActionsSyncHandler.ts | 3 + .../services/sync/syncCloudRelayStore.test.ts | 55 +++++++++ .../src/services/sync/syncCloudRelayStore.ts | 13 ++- .../src/services/sync/syncHostService.test.ts | 29 +++++ .../src/services/sync/syncHostService.ts | 22 ++++ apps/ade-cli/src/services/sync/syncService.ts | 5 + .../sync/syncTunnelClientService.test.ts | 6 +- .../settings/SyncDevicesSection.tsx | 6 +- apps/desktop/src/shared/types/sync.ts | 22 +++- apps/push-relay/src/relay.ts | 30 ++++- apps/push-relay/test/relay.test.ts | 93 +++++++++++++++ docs/features/sync-and-multi-device/README.md | 45 ++++--- .../sync-and-multi-device/ios-companion.md | 17 ++- .../push-notifications.md | 33 ++++++ 18 files changed, 573 insertions(+), 43 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index cd864b59e..648033220 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -11727,9 +11727,9 @@ Usage: ade sync name Name this runtime for easy identification ade sync name get ade sync name clear - ade sync relay status Cloud relay (phones dial this machine via a tunnel) + ade sync relay status ADE relay (phones dial this machine via a tunnel; on by default) ade sync relay enable - ade sync relay disable + ade sync relay disable Kill-switch: never route sync through the relay ade sync security status Machine sync security posture (require-DPoP) ade sync security require-dpop `, @@ -13131,6 +13131,7 @@ async function runServe( { resolveMobileProjectIconDataUrl }, { createBrainProjectActionsSyncHandler }, { buildRosterSnapshot, createForeignChatTranscriptResolver }, + { createSyncCloudRelayStore }, ] = await Promise.all([ import("./services/projects/machineLayout"), import("./services/projects/projectRegistry"), @@ -13140,6 +13141,7 @@ async function runServe( import("../../desktop/src/main/services/projects/projectIconThumbnail"), import("./services/sync/brainProjectActionsSyncHandler"), import("./services/sync/rosterBuilder"), + import("./services/sync/syncCloudRelayStore"), ]); const layout = resolveMachineAdeLayout(); @@ -13473,6 +13475,11 @@ async function runServe( const machineCredentialStore = new EncryptedFileCredentialStore({ secretsDir: layout.secretsDir, }); + // Same file the per-scope sync services read; another store instance is fine + // because every read reloads the file. + const machineCloudRelayStore = createSyncCloudRelayStore({ + filePath: path.join(layout.secretsDir, "sync-cloud-relay.json"), + }); sharedSyncListener?.setFallbackConnectionHandler( createBrainProjectActionsSyncHandler({ logger: headlessProjectLogger, @@ -13483,6 +13490,8 @@ async function runServe( pinPath: path.join(layout.secretsDir, "sync-pin.json"), localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"), localSiteIdPath: path.join(layout.secretsDir, "sync-site-id"), + getCloudRelayWssUrl: () => + machineCloudRelayStore.isEnabled() ? machineCloudRelayStore.getRelayWssUrl() : null, }), ); scopeRegistry = new ProjectScopeRegistry(projectRegistry, { @@ -13551,6 +13560,17 @@ async function runServe( return null; }; const disposeServeResources = async () => { + // Before scopes detach (which clears the run map): best-effort Live + // Activity `end` so the lock screen doesn't show dead agents until the + // stale-date dim. Bounded by the publisher's internal timeout. + try { + const { peekSharedPushPublisherService } = await import("./services/push/pushPublisherService"); + await peekSharedPushPublisherService( + path.join(layout.secretsDir, "push-relay.json"), + )?.shutdown(); + } catch { + // Shutdown must never hang or fail on push cleanup. + } await scopeRegistry.disposeAll(); if (sharedSyncListener) { await sharedSyncListener.close().catch(() => {}); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 053fd0696..efa9a6c4f 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -25,6 +25,7 @@ function run(overrides: Partial): AgentRunState { agent: "Codex", phase: "running", detail: null, + itemId: null, startedAt: 0, lastActiveAt: 0, metaResolved: true, @@ -217,7 +218,90 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); - it("suppresses a muted alert but still updates the Live Activity", async () => { + it("carries actionable fields: category + sessionId/itemId on the alert, itemId on the waiting LA row, badge count", async () => { + const { publisher, publish, emit } = makeHarness(); + await publisher.start(); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + const payload = publish.mock.calls[0][0]; + expect(payload.notifications[0].category).toBe("ADE_APPROVAL"); + expect(payload.notifications[0].sessionId).toBe("s-1"); + expect(payload.notifications[0].itemId).toBe("i-1"); + expect(payload.notifications[0].badge).toBe(1); + const laRun = payload.liveActivity[0].contentState.runs[0]; + expect(laRun.phase).toBe("waiting_for_approval"); + expect(laRun.itemId).toBe("i-1"); + + // Resolution clears the pending item id from later content states. + emit({ + sessionId: "s-1", + timestamp: "", + event: { type: "pending_input_resolved", itemId: "i-1", resolution: "accepted" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + const lastPayload = publish.mock.calls.at(-1)?.[0]; + const resolvedRun = lastPayload.liveActivity?.[0]?.contentState.runs[0]; + expect(resolvedRun?.phase).toBe("running"); + expect(resolvedRun?.itemId).toBeUndefined(); + + publisher.dispose(); + }); + + it("sends a silent badge-only item when the awaiting count drops with no alert", async () => { + const { publisher, publish, emit } = makeHarness(); + await publisher.start(); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + expect(publish.mock.calls[0][0].notifications[0].badge).toBe(1); + + emit({ + sessionId: "s-1", + timestamp: "", + event: { type: "pending_input_resolved", itemId: "i-1", resolution: "accepted" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + const lastPayload = publish.mock.calls.at(-1)?.[0]; + const badgeItem = (lastPayload.notifications ?? []).find( + (item: { dedupeKey?: string }) => item.dedupeKey === "alert:badge", + ); + expect(badgeItem).toMatchObject({ title: "", badge: 0, sound: null }); + + publisher.dispose(); + }); + + it("publishes a best-effort Live Activity end on shutdown, marking active runs stale", async () => { + const { publisher, publish, emit } = makeHarness(); + await publisher.start(); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + expect(publish).toHaveBeenCalledTimes(1); + + await publisher.shutdown(); + const lastPayload = publish.mock.calls.at(-1)?.[0]; + expect(lastPayload.liveActivity[0].event).toBe("end"); + expect(lastPayload.liveActivity[0].dismissalDate).toBe( + Math.floor(Date.parse("2026-07-05T12:00:00.000Z") / 1000) + 60, + ); + expect(lastPayload.liveActivity[0].contentState.runs[0].phase).toBe("stale"); + + // Disposed: no further flush can fire. + emit(approval); + await vi.advanceTimersByTimeAsync(5_000); + expect(publish.mock.calls.at(-1)?.[0]).toBe(lastPayload); + }); + + it("shutdown is a no-op publish when no Live Activity start was committed", async () => { + const { publisher, publish } = makeHarness(); + await publisher.start(); + await publisher.shutdown(); + expect(publish).not.toHaveBeenCalled(); + }); + + it("suppresses a muted alert but still updates the Live Activity and badge", async () => { const muted = { ...device, prefs: { ...device.prefs, mutedSessionIds: ["s-1"] } }; const { publisher, publish, emit } = makeHarness(muted); await publisher.start(); @@ -227,7 +311,13 @@ describe("createPushPublisherService flush", () => { expect(publish).toHaveBeenCalledTimes(1); const payload = publish.mock.calls[0][0]; - expect(payload.notifications).toBeUndefined(); + // The muted alert itself is suppressed, but a muted session still awaits + // attention, so a silent badge-only item keeps the app icon honest. + expect(payload.notifications).toHaveLength(1); + expect(payload.notifications[0].title).toBe(""); + expect(payload.notifications[0].badge).toBe(1); + expect(payload.notifications[0].sound).toBeNull(); + expect(payload.notifications[0].dedupeKey).toBe("alert:badge"); expect(payload.liveActivity[0].event).toBe("start"); publisher.dispose(); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index fdfbd5d56..ea416f48f 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -19,6 +19,10 @@ import type { export const AGENT_RUNS_ACTIVITY_ID = "agent-runs"; export const AGENT_RUNS_ATTRIBUTES_TYPE = "ADEAgentRunsAttributes"; export const AGENT_RUNS_DEDUPE_KEY = "la:agent-runs"; +/** UNNotificationCategory id iOS binds Approve/Deny actions to. */ +export const APPROVAL_NOTIFICATION_CATEGORY = "ADE_APPROVAL"; +const BADGE_DEDUPE_KEY = "alert:badge"; +const SHUTDOWN_PUBLISH_TIMEOUT_MS = 2_500; const AGENT_RUNS_MAX = 3; const DETAIL_MAX_CHARS = 160; /** Fixed lock-screen copy for failed runs — never leak error text to the widget. */ @@ -50,6 +54,8 @@ export type AgentRunState = { agent: string | null; phase: AgentRunPhase; detail: string | null; + /** Pending approval item id while phase is waiting_for_approval. */ + itemId: string | null; startedAt: number; lastActiveAt: number; metaResolved: boolean; @@ -71,6 +77,10 @@ type PendingAlert = { threadId?: string | null; phase: "running" | "waiting" | "terminal"; interruptionLevel?: "passive" | "active" | "time-sensitive" | null; + /** Approval item id — rides the payload so iOS can act without opening the app. */ + itemId?: string | null; + /** UNNotificationCategory identifier binding actionable buttons on iOS. */ + category?: string | null; }; type PushAgentChatService = { @@ -215,6 +225,7 @@ export function buildAgentRunsContentState( model: string | null; lane: string | null; detail: string | null; + itemId?: string; }>; } { const activeCount = runs.filter((run) => isActivePhase(run.phase)).length; @@ -229,10 +240,20 @@ export function buildAgentRunsContentState( model: run.model ?? null, lane: run.lane ?? null, detail: run.phase === "failed" ? FAILED_DETAIL : capDetail(run.detail), + // Additive optional field (older widgets ignore it): lets the lock + // screen render Approve/Deny intents against the pending item. + ...(run.phase === "waiting_for_approval" && run.itemId ? { itemId: run.itemId } : {}), })), }; } +/** Waiting runs are what the app icon badge counts — agents blocked on the user. */ +export function countAwaitingAttentionRuns(runs: AgentRunState[]): number { + return runs.filter( + (run) => run.phase === "waiting_for_approval" || run.phase === "waiting_for_input", + ).length; +} + function readOutcomeCount(result: Record | null | undefined, key: string): number { const value = Number(result?.[key]); return Number.isFinite(value) ? value : 0; @@ -286,6 +307,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const lastAlertFingerprintByKey = new Map(); let lastLiveActivityFingerprint: string | null = null; let liveActivityStarted = false; + /** Last app-icon badge count committed to the relay (null = never sent). */ + let lastSentBadgeCount: number | null = null; let flushTimer: NodeJS.Timeout | null = null; let flushFireAt = 0; @@ -325,6 +348,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { agent: null, phase: "starting", detail: null, + itemId: null, startedAt: ts, lastActiveAt: ts, metaResolved: false, @@ -487,6 +511,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const consumedAlerts = pendingAlerts; pendingAlerts = []; + const badgeCount = countAwaitingAttentionRuns([...runs.values()]); + const alertItems: PushRelayAlertItem[] = []; const alertCommits: Array<[string, string]> = []; for (const alert of consumedAlerts) { @@ -512,6 +538,30 @@ export function createPushPublisherService(deps: PushPublisherDeps) { dedupeKey: alert.dedupeKey, phase: alert.phase, interruptionLevel: alert.interruptionLevel ?? null, + sessionId: alert.sessionId ?? null, + itemId: alert.itemId ?? null, + category: alert.category ?? null, + badge: badgeCount, + }); + } + + // The badge must also track *drops* (an approval answered on the Mac), which + // produce no alert. A silent badge-only item keeps the icon honest; the + // relay's content-hash suppression absorbs unchanged resends. + const badgeDeviceIds = devices + .filter((device) => Boolean(device.apnsToken) && device.prefs.enabled) + .map((device) => device.deviceId); + const needsBadgeSync = badgeCount !== lastSentBadgeCount + && alertItems.length === 0 + && badgeDeviceIds.length > 0; + if (needsBadgeSync) { + alertItems.push({ + deviceIds: badgeDeviceIds, + title: "", + sound: null, + dedupeKey: BADGE_DEDUPE_KEY, + phase: "terminal", + badge: badgeCount, }); } @@ -543,6 +593,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return; } for (const [key, fingerprint] of alertCommits) lastAlertFingerprintByKey.set(key, fingerprint); + if (alertItems.length > 0) lastSentBadgeCount = badgeCount; if (laPlan) { // Commit the Live Activity plan only if its own targets landed. A mixed // publish can report delivered>0 from the alert while the LA push-to-start @@ -623,6 +674,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { case "approval_request": { run.phase = "waiting_for_approval"; run.detail = event.description ?? run.detail; + run.itemId = event.itemId || null; enqueueAlert({ sessionId, dedupeKey: `alert:${sessionId}:approval`, @@ -631,6 +683,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { threadId: sessionId, phase: "waiting", interruptionLevel: "time-sensitive", + itemId: event.itemId || null, + category: APPROVAL_NOTIFICATION_CATEGORY, }); immediate = true; break; @@ -654,12 +708,14 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (run.phase === "waiting_for_approval" || run.phase === "waiting_for_input") { run.phase = "running"; } + run.itemId = null; // Allow a later prompt in the same session to alert again. clearAlertDedupe(`alert:${sessionId}:approval`); clearAlertDedupe(`alert:${sessionId}:question`); break; } case "status": { + if (event.turnStatus !== "started") run.itemId = null; if (event.turnStatus === "started") { if (isTerminalPhase(run.phase)) run.phase = "running"; } else if (event.turnStatus === "failed") { @@ -905,6 +961,52 @@ export function createPushPublisherService(deps: PushPublisherDeps) { flushTimer = null; }, + /** + * Daemon-exit path: best-effort `end` for the aggregate Live Activity so + * dead agents don't linger on the lock screen until the stale-date dim. + * Bounded by a short timeout — shutdown must never hang on the relay — + * and only sent when a start was actually committed. Ends with dispose(). + */ + async shutdown(): Promise { + if (!disposed && liveActivityStarted && !isGated()) { + try { + const nowMs = now(); + const deviceIds = deps.store.listDevices() + .filter((device) => Boolean(device.pushToStartToken) && shouldDeliverLiveActivityForPrefs(device.prefs)) + .map((device) => device.deviceId); + if (deviceIds.length > 0) { + // Mark still-active runs stale — the machine is gone, not the agent done. + const finalRuns = [...runs.values()].map((run) => + isActivePhase(run.phase) ? { ...run, phase: "stale" as const } : run, + ); + const item: PushRelayLiveActivityItem = { + deviceIds, + event: "end", + activityId: AGENT_RUNS_ACTIVITY_ID, + contentState: buildAgentRunsContentState(finalRuns, nowMs), + dedupeKey: AGENT_RUNS_DEDUPE_KEY, + phase: "terminal", + dismissalDate: Math.floor(nowMs / 1000) + 60, + }; + let timeout: NodeJS.Timeout | null = null; + try { + await Promise.race([ + deps.relayClient.publish({ liveActivity: [item] }), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("shutdown publish timed out")), SHUTDOWN_PUBLISH_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + } catch (error) { + logWarn("push.shutdown_end_failed", error); + } + } + this.dispose(); + }, + // Exposed for tests / diagnostics. _debug: { runs, @@ -939,3 +1041,11 @@ export function getSharedPushPublisherService( } return existing; } + +/** + * Read-only lookup for shutdown paths: returns the shared publisher only if a + * project scope already created it — never mints one just to dispose it. + */ +export function peekSharedPushPublisherService(key: string): PushPublisherService | undefined { + return sharedPublishers.get(key); +} diff --git a/apps/ade-cli/src/services/push/pushRelayClient.ts b/apps/ade-cli/src/services/push/pushRelayClient.ts index 7fde74de4..3bf471f90 100644 --- a/apps/ade-cli/src/services/push/pushRelayClient.ts +++ b/apps/ade-cli/src/services/push/pushRelayClient.ts @@ -9,6 +9,7 @@ const REQUEST_TIMEOUT_MS = 15_000; /** APNs alert-push item, matching the relay's `parseAlertItems`. */ export type PushRelayAlertItem = { deviceIds?: string[] | null; + /** May be empty for a silent badge-only item (requires `badge`). */ title: string; subtitle?: string | null; body?: string | null; @@ -19,6 +20,14 @@ export type PushRelayAlertItem = { collapseId?: string | null; dedupeKey?: string | null; phase?: "running" | "waiting" | "terminal"; + /** Chat session the alert refers to; passed through top-level to iOS. */ + sessionId?: string | null; + /** Pending approval item id; passed through top-level to iOS. */ + itemId?: string | null; + /** `aps.category` — binds registered notification actions on iOS. */ + category?: string | null; + /** `aps.badge` — awaiting-attention count for the app icon. */ + badge?: number | null; }; /** Live Activity event item, matching the relay's `parseLiveActivityItems`. */ diff --git a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts index 5052e995d..557526b16 100644 --- a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts +++ b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts @@ -54,6 +54,8 @@ type BrainProjectActionsSyncHandlerArgs = { heartbeatIntervalMs?: number; pollIntervalMs?: number; authTimeoutMs?: number; + /** Mirrors SyncHostServiceArgs.getCloudRelayWssUrl for the fallback hello_ok. */ + getCloudRelayWssUrl?: () => string | null; }; type BrainPeerState = { @@ -778,6 +780,7 @@ export function createBrainProjectActionsSyncHandler( remoteCommandDescriptors: [], localCommandDescriptors: [], compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + cloudRelayWssUrl: args.getCloudRelayWssUrl?.() ?? null, }), envelope.requestId); return; } diff --git a/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts b/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts new file mode 100644 index 000000000..f84536606 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createSyncCloudRelayStore, deriveRelayWssConnectUrl, httpToWsUrl } from "./syncCloudRelayStore"; + +describe("syncCloudRelayStore enablement default", () => { + let dir: string; + let filePath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cloud-relay-")); + filePath = path.join(dir, "sync-cloud-relay.json"); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("defaults to enabled on first run (no file)", () => { + const store = createSyncCloudRelayStore({ filePath }); + expect(store.isEnabled()).toBe(true); + }); + + it("treats a legacy file without the enabled field as enabled", () => { + const seeded = createSyncCloudRelayStore({ filePath }); + const { machineKey, secret } = seeded.getMachineIdentity(); + fs.writeFileSync(filePath, `${JSON.stringify({ machineKey, secret })}\n`); + const store = createSyncCloudRelayStore({ filePath }); + expect(store.isEnabled()).toBe(true); + expect(store.getMachineIdentity().machineKey).toBe(machineKey); + }); + + it("preserves an explicit kill-switch false across reads", () => { + const store = createSyncCloudRelayStore({ filePath }); + store.setEnabled(false); + expect(createSyncCloudRelayStore({ filePath }).isEnabled()).toBe(false); + store.setEnabled(true); + expect(createSyncCloudRelayStore({ filePath }).isEnabled()).toBe(true); + }); + + it("keeps the minted identity stable across reloads", () => { + const first = createSyncCloudRelayStore({ filePath }).getMachineIdentity(); + const second = createSyncCloudRelayStore({ filePath }).getMachineIdentity(); + expect(second).toEqual(first); + expect(first.machineKey).toMatch(/^[a-f0-9]{32}$/); + }); + + it("derives the phone-facing wss connect URL", () => { + expect(httpToWsUrl("https://relay.example")).toBe("wss://relay.example"); + expect(deriveRelayWssConnectUrl("https://relay.example/", "abc123")).toBe( + "wss://relay.example/connect/abc123", + ); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts b/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts index 8e5eb0105..1b49472d3 100644 --- a/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts +++ b/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts @@ -6,7 +6,12 @@ import { safeJsonParse, writeTextAtomic } from "../../../../desktop/src/main/ser const DEFAULT_RELAY_URL = "https://ade-tunnel-relay.arulsharma1028.workers.dev"; export type SyncCloudRelayConfig = { - /** When false the tunnel client never claims or connects (default). */ + /** + * When false the tunnel client never claims or connects. Defaults to true + * (relay-everywhere, zero-config): a file without the field reads as + * enabled, while an explicit `false` — the desktop kill-switch or + * `ade sync relay disable` — is preserved. + */ enabled: boolean; /** Per-machine identifier phones dial through the relay (32 hex chars). */ machineKey: string; @@ -95,7 +100,7 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { && typeof raw.secret === "string" && raw.secret.length >= 32 ) { return { - enabled: raw.enabled === true, + enabled: raw.enabled !== false, machineKey: raw.machineKey, secret: raw.secret, relayUrl: typeof raw.relayUrl === "string" && raw.relayUrl.trim() ? raw.relayUrl.trim() : undefined, @@ -118,7 +123,9 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { ? raw.secret : randomBytes(24).toString("hex"); const config: SyncCloudRelayConfig = { - enabled: raw.enabled === true, + // Missing field → enabled (default-on). Only an explicit false — a + // toggle/CLI choice the user made — keeps the relay off. + enabled: raw.enabled !== false, machineKey, secret, relayUrl: typeof raw.relayUrl === "string" && raw.relayUrl.trim() ? raw.relayUrl.trim() : undefined, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index f772d3375..b4232c63f 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -223,6 +223,35 @@ describe("buildSyncHostHelloOkPayload", () => { supportedActions: [remoteCommand.action, localPresenceCommand.action], actions: [remoteCommand, localPresenceCommand], }); + // No relay URL supplied → field omitted for backward-compatible payloads. + expect("cloudRelayWssUrl" in payload).toBe(false); + }); + + it("advertises the cloud relay connect URL so already-paired phones learn the off-LAN route", () => { + const metadata = { + deviceId: "d", + deviceName: "n", + platform: "iOS", + deviceType: "phone", + siteId: "s", + dbVersion: 0, + } satisfies SyncPeerMetadata; + const payload = buildSyncHostHelloOkPayload({ + peer: metadata, + brain: metadata, + serverDbVersion: 0, + heartbeatIntervalMs: 30_000, + pollIntervalMs: 400, + projectCatalog: { projects: [] }, + projectCatalogEnabled: false, + projectActionsEnabled: false, + crossProjectChatEnabled: false, + remoteCommandSupportedActions: [], + remoteCommandDescriptors: [], + localCommandDescriptors: [], + cloudRelayWssUrl: "wss://relay.example/connect/abc123", + }); + expect(payload.cloudRelayWssUrl).toBe("wss://relay.example/connect/abc123"); }); }); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 7118d2354..d2cd5c356 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -636,6 +636,13 @@ type SyncHostServiceArgs = { * record always require a valid proof regardless of this flag. */ requireDpop?: () => boolean; + /** + * Live cloud tunnel-relay connect URL (`wss://…/connect/`), or + * null when the relay is disabled. Advertised in `hello_ok` and + * `brain_status` so already-paired phones learn the off-LAN route without + * re-scanning a QR. + */ + getCloudRelayWssUrl?: () => string | null; }; function sanitizeRemoteAddress(remoteAddress: string | null | undefined): string | null { @@ -869,6 +876,7 @@ export function buildSyncHostHelloOkPayload(args: { localCommandDescriptors: SyncRemoteCommandDescriptor[]; compressionThresholdBytes?: number; maxProjectCatalogEnvelopeBytes?: number; + cloudRelayWssUrl?: string | null; }): SyncHelloOkPayload { const actions = [ ...args.remoteCommandDescriptors, @@ -882,6 +890,7 @@ export function buildSyncHostHelloOkPayload(args: { heartbeatIntervalMs: args.heartbeatIntervalMs, pollIntervalMs: args.pollIntervalMs, projects: args.projectCatalog.projects, + ...(args.cloudRelayWssUrl ? { cloudRelayWssUrl: args.cloudRelayWssUrl } : {}), features: { fileAccess: true, terminalStreaming: true, @@ -3150,6 +3159,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { function buildBrainStatus(): SyncBrainStatusPayload { const brainMetadata = readBrainMetadata(); + const cloudRelayWssUrl = args.getCloudRelayWssUrl?.() ?? null; if (disposed) { return { brain: brainMetadata, @@ -3169,6 +3179,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { lastCommandResultLatencyMs, lastChangesetAckLatencyMs, }, + cloudRelayWssUrl, }; } const dbVersion = args.db.sync.getDbVersion(); @@ -3202,6 +3213,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { lastCommandResultLatencyMs, lastChangesetAckLatencyMs, }, + cloudRelayWssUrl, }; } @@ -4293,6 +4305,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { localCommandDescriptors: localPresenceCommandDescriptors, compressionThresholdBytes, maxProjectCatalogEnvelopeBytes, + cloudRelayWssUrl: args.getCloudRelayWssUrl?.() ?? null, }), envelope.requestId); args.onStateChanged?.(); await pumpChanges(); @@ -4973,6 +4986,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return buildBrainStatus(); }, + /** + * Push a fresh `brain_status` to every connected peer. Used when a value + * it advertises changes outside the normal broadcast cadence (e.g. the + * cloud-relay kill-switch flips), so phones don't wait a full interval. + */ + broadcastBrainStatusNow(): void { + broadcastBrainStatus(); + }, + async broadcastProjectCatalog(): Promise { const payload = await buildProjectCatalogPayload(); if (bonjourPort != null) { diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index fc8f9cf6f..a225a4075 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -743,6 +743,8 @@ export function createSyncService(args: SyncServiceArgs) { remoteCommandService, remoteCommandExecutor: args.remoteCommandExecutor, requireDpop: () => securityStore.getRequireDpop(), + getCloudRelayWssUrl: () => + cloudRelayStore.isEnabled() ? cloudRelayStore.getRelayWssUrl() : null, onStateChanged: () => { void refreshRoleState(); }, @@ -1361,6 +1363,9 @@ export function createSyncService(args: SyncServiceArgs) { async setCloudRelayEnabled(enabled: boolean): Promise { cloudRelayStore.setEnabled(enabled); args.onCloudRelayEnabledChanged?.(enabled); + // Connected phones learn the flip from brain_status (cloudRelayWssUrl) + // without waiting for the next scheduled broadcast. + hostService?.broadcastBrainStatusNow?.(); // The relay candidate rides pairingConnectInfo, so republish status. const snapshot = await service.getStatus(); args.onStatusChanged?.(snapshot); diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts index 7e48bc3d8..0b5b7f0d3 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts @@ -139,13 +139,15 @@ describe("signature builders", () => { }); describe("createSyncCloudRelayStore", () => { - it("mints a stable identity, defaults to disabled, and persists chmod 600", () => { + it("mints a stable identity, defaults to enabled, and persists chmod 600", () => { const filePath = tempStorePath(); const store = createSyncCloudRelayStore({ filePath }); const first = store.getMachineIdentity(); expect(/^[a-f0-9]{32}$/.test(first.machineKey)).toBe(true); expect(first.secret.length).toBe(48); - expect(store.isEnabled()).toBe(false); + // Relay-everywhere: enabled out of the box; only an explicit kill-switch + // false (covered in syncCloudRelayStore.test.ts) keeps it off. + expect(store.isEnabled()).toBe(true); // Re-opening the store keeps the same identity. const reopened = createSyncCloudRelayStore({ filePath }); diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index 0d327cca0..6b9d5b42f 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -109,7 +109,7 @@ function addressKindLabel(kind: SyncAddressCandidate["kind"]): string { case "saved": return "Saved"; case "relay": - return "Cloud relay"; + return "ADE relay"; default: return "Manual"; } @@ -700,9 +700,9 @@ function PairPhoneCard({
-
Cloud relay fallback
+
ADE relay
- Adds a cloud tunnel as a last-resort route when no LAN or Tailscale path works. + Keeps your phone connected when no LAN or Tailscale route works. On by default; turn off to never route through the relay.
` cloud tunnel-relay URL for this + * machine, when the relay is enabled. Lets phones that paired before the + * relay existed (or via discovery + PIN, where no QR carried it) learn the + * off-LAN route over the live connection and persist it for reconnects. + */ + cloudRelayWssUrl?: string | null; }; export type SyncHelloErrorPayload = { @@ -530,10 +537,12 @@ export type SyncPairingConnectInfo = { }; /** - * Machine-level cloud tunnel relay posture (Settings > Sync "Cloud relay - * fallback"). When enabled, the brain keeps an outbound tunnel registered and - * a `relay`-kind address candidate (a full wss:// URL) is advertised to - * phones as the lowest-priority transport. + * Machine-level cloud tunnel relay posture. Enabled by default so paired + * phones stay reachable off LAN/tailnet with zero configuration: the brain + * keeps an outbound tunnel registered and a `relay`-kind address candidate + * (a full wss:// URL) is advertised to phones as the lowest-priority + * transport. Settings > Sync keeps a single quiet kill-switch for operators + * who never want traffic relayed. */ export type SyncCloudRelayStatus = { enabled: boolean; @@ -834,6 +843,11 @@ export type SyncBrainStatusPayload = { lastCommandResultLatencyMs?: number | null; lastChangesetAckLatencyMs?: number | null; }; + /** + * Mirrors `SyncHelloOkPayload.cloudRelayWssUrl` so an already-connected + * phone picks up a relay enable/disable flip without reconnecting. + */ + cloudRelayWssUrl?: string | null; }; export type SyncRunQuickCommandArgs = { diff --git a/apps/push-relay/src/relay.ts b/apps/push-relay/src/relay.ts index c042e0591..05231a592 100644 --- a/apps/push-relay/src/relay.ts +++ b/apps/push-relay/src/relay.ts @@ -52,6 +52,7 @@ export type PushPhase = "running" | "waiting" | "terminal"; type AlertPublishItem = { deviceIds: string[] | null; + /** Empty for a silent badge-only item (badge != null). */ title: string; subtitle: string | null; body: string | null; @@ -62,6 +63,13 @@ type AlertPublishItem = { collapseId: string | null; dedupeKey: string | null; phase: PushPhase; + /** Passed through top-level (like deepLink) so iOS can act without opening. */ + sessionId: string | null; + itemId: string | null; + /** aps.category — binds registered UNNotificationActions on the device. */ + category: string | null; + /** aps.badge — the app icon's awaiting-attention count. */ + badge: number | null; }; type LiveActivityPublishItem = { @@ -304,7 +312,10 @@ function parseAlertItems(raw: unknown): AlertPublishItem[] { for (const entry of raw.slice(0, MAX_PUBLISH_ITEMS)) { if (!isRecord(entry)) continue; const title = readString(entry, "title"); - if (!title) continue; + const badgeRaw = readNumber(entry, "badge"); + const badge = badgeRaw != null && badgeRaw >= 0 ? Math.floor(badgeRaw) : null; + // A title-less item is valid only as a silent badge sync. + if (!title && badge == null) continue; const interruption = readString(entry, "interruptionLevel"); items.push({ deviceIds: readDeviceIds(entry), @@ -321,6 +332,10 @@ function parseAlertItems(raw: unknown): AlertPublishItem[] { collapseId: readOptionalString(entry, "collapseId"), dedupeKey: readOptionalString(entry, "dedupeKey"), phase: normalizePhase(readString(entry, "phase")), + sessionId: readOptionalString(entry, "sessionId"), + itemId: readOptionalString(entry, "itemId"), + category: readOptionalString(entry, "category"), + badge, }); } return items; @@ -454,19 +469,24 @@ function phaseExpiration(phase: PushPhase, nowSeconds = Math.floor(Date.now() / } function alertApnsPayload(item: AlertPublishItem): Record { - const aps: Record = { - alert: { + const aps: Record = {}; + if (item.title) { + aps.alert = { title: item.title, ...(item.subtitle ? { subtitle: item.subtitle } : {}), ...(item.body ? { body: item.body } : {}), - }, - }; + }; + } if (item.sound) aps.sound = item.sound; if (item.threadId) aps["thread-id"] = item.threadId; if (item.interruptionLevel) aps["interruption-level"] = item.interruptionLevel; + if (item.category) aps.category = item.category; + if (item.badge != null) aps.badge = item.badge; return { aps, ...(item.deepLink ? { deepLink: item.deepLink } : {}), + ...(item.sessionId ? { sessionId: item.sessionId } : {}), + ...(item.itemId ? { itemId: item.itemId } : {}), }; } diff --git a/apps/push-relay/test/relay.test.ts b/apps/push-relay/test/relay.test.ts index 8064c1dc0..9d39af9f0 100644 --- a/apps/push-relay/test/relay.test.ts +++ b/apps/push-relay/test/relay.test.ts @@ -456,6 +456,99 @@ describe("push relay", () => { expect(db.devices[0]?.apns_token).toBeNull(); }); + it("passes actionable fields through: category into aps, sessionId/itemId top-level, badge count", async () => { + const env = makeEnv(db, apnsKey); + await claimMachine(db, env); + await handleRequest( + await signedRequest({ + method: "PUT", + path: `/machines/${MACHINE_KEY}/devices/phone-1`, + body: { apnsToken: "ab".repeat(32), bundleId: "com.ade.ios", apsEnvironment: "sandbox" }, + }), + env, + ); + + const apnsCalls: Array<{ body: string }> = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + apnsCalls.push({ body: String(init?.body ?? "") }); + return new Response(null, { status: 200 }); + })); + + const publish = await handleRequest( + await signedRequest({ + method: "POST", + path: `/machines/${MACHINE_KEY}/publish`, + body: { + notifications: [{ + title: "Claude needs your approval", + deepLink: "ade://session/s-1", + sessionId: "s-1", + itemId: "item-42", + category: "ADE_APPROVAL", + badge: 2, + phase: "waiting", + }], + }, + }), + env, + ); + expect(publish.status).toBe(200); + const body = JSON.parse(apnsCalls[0]?.body ?? "{}") as { + aps: { category?: string; badge?: number }; + sessionId?: string; + itemId?: string; + }; + expect(body.aps.category).toBe("ADE_APPROVAL"); + expect(body.aps.badge).toBe(2); + expect(body.sessionId).toBe("s-1"); + expect(body.itemId).toBe("item-42"); + }); + + it("delivers a silent badge-only item (no title) and rejects a bare empty item", async () => { + const env = makeEnv(db, apnsKey); + await claimMachine(db, env); + await handleRequest( + await signedRequest({ + method: "PUT", + path: `/machines/${MACHINE_KEY}/devices/phone-1`, + body: { apnsToken: "ab".repeat(32), bundleId: "com.ade.ios", apsEnvironment: "sandbox" }, + }), + env, + ); + + const apnsCalls: Array<{ body: string }> = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + apnsCalls.push({ body: String(init?.body ?? "") }); + return new Response(null, { status: 200 }); + })); + + const badgeOnly = await handleRequest( + await signedRequest({ + method: "POST", + path: `/machines/${MACHINE_KEY}/publish`, + body: { notifications: [{ title: "", sound: null, badge: 0, phase: "terminal" }] }, + }), + env, + ); + expect(badgeOnly.status).toBe(200); + expect(((await badgeOnly.json()) as { delivered: number }).delivered).toBe(1); + const body = JSON.parse(apnsCalls[0]?.body ?? "{}") as { aps: { badge?: number; alert?: unknown; sound?: unknown } }; + expect(body.aps.badge).toBe(0); + expect(body.aps.alert).toBeUndefined(); + expect(body.aps.sound).toBeUndefined(); + + // Title-less AND badge-less item is invalid → nothing to publish. + const empty = await handleRequest( + await signedRequest({ + method: "POST", + path: `/machines/${MACHINE_KEY}/publish`, + body: { notifications: [{ body: "no title", phase: "waiting" }] }, + }), + env, + ); + expect(empty.status).toBe(400); + }); + it("suppresses redundant publishes by dedupe key content hash", async () => { const env = makeEnv(db, apnsKey); await claimMachine(db, env); diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 112785f10..96bfc0506 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -371,10 +371,12 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `~/.ade/secrets/sync-security.json` (chmod `0600`). Owns the `requireDpop` flag with the `ADE_SYNC_REQUIRE_DPOP=1|0` env override; both the per-project host and the brain ingress handler read it. -- `syncCloudRelayStore.ts` — persists the optional cloud tunnel-relay - identity + enablement at `~/.ade/secrets/sync-cloud-relay.json` - (lazily-minted 32-hex `machineKey` + HMAC `secret`, chmod `0600`, - default `enabled: false`). Derives the phone-facing +- `syncCloudRelayStore.ts` — persists the cloud tunnel-relay identity + + enablement at `~/.ade/secrets/sync-cloud-relay.json` (lazily-minted + 32-hex `machineKey` + HMAC `secret`, chmod `0600`). **Default + `enabled: true`** — a file without the field reads as enabled, while an + explicit `false` (the desktop kill-switch or `ade sync relay disable`) + is preserved. Derives the phone-facing `wss:///connect/` URL and the canonical host/pipe HMAC signing strings shared with the `apps/tunnel-relay` worker. - `syncTunnelClientService.ts` — the brain-side tunnel client. When the @@ -620,12 +622,14 @@ is not supported. version ≥ 3 as long as the fields it understands are present. - **Address candidates**: the runtime advertises LAN IPs, the saved `lastHost` (when it matches the current set), the Tailscale IP, - `127.0.0.1`, and — when the operator turns on the cloud tunnel relay — - a `relay`-kind candidate carrying a full + `127.0.0.1`, and — unless the relay kill-switch is off — a + `relay`-kind candidate carrying a full `wss://…/connect/` URL. `SyncAddressCandidateKind` is `lan | saved | tailscale | loopback | relay`; the relay candidate is the lowest-priority transport (see the transport race in - `ios-companion.md`). + `ios-companion.md`). Already-paired phones also learn the relay URL + from `hello_ok` / `brain_status` (`cloudRelayWssUrl`) and persist it + with the host profile for reconnects. - **mDNS**: `publishLanDiscovery` builds a TXT record whose `addresses` CSV includes the Tailscale IP alongside LAN IPs. It also advertises `runtimeKind`, `runtimeVersion`, `projects`, and @@ -848,15 +852,22 @@ project scope split. when over tailnet; LAN connections rely on pairing token validation. TLS is not enforced for localhost/LAN; the runtime listens on all interfaces (intended for trusted LAN and tailnets). -- **Cloud tunnel relay (optional, off by default)**: when the operator - enables it in Settings > Sync, the brain keeps an outbound - HMAC-authenticated tunnel to the `apps/tunnel-relay` Cloudflare Worker - so a phone off the LAN/tailnet can dial the machine over TLS. The relay - only pipes bytes: the normal ADE hello / PIN / paired-secret / DPoP - handshake still runs end-to-end, so the relay never sees pairing - credentials in the clear. The `machineKey` is an unguessable 32-hex - identifier and the tunnel upgrades are HMAC-signed with a per-machine - secret. +- **Cloud tunnel relay (on by default; Settings > Sync is the + kill-switch)**: the brain keeps an outbound HMAC-authenticated tunnel + to the `apps/tunnel-relay` Cloudflare Worker so a phone off the + LAN/tailnet can dial the machine over TLS with zero configuration. + Phones always try direct routes first — the relay candidate is + strictly the lowest-priority transport. The relay only pipes bytes: + the normal ADE hello / PIN / paired-secret / DPoP handshake still runs + end-to-end, so the relay never sees pairing credentials in the clear. + The `machineKey` is an unguessable 32-hex identifier and the tunnel + upgrades are HMAC-signed with a per-machine secret. Operators who + never want traffic relayed flip the single "ADE relay" control in + Settings > Sync (or `ade sync relay disable`); an explicit off is + preserved across upgrades. The live relay URL is also advertised to + already-paired phones in `hello_ok` / `brain_status` + (`cloudRelayWssUrl`), so devices paired before the relay existed learn + the route without re-scanning a QR. - **Secret isolation**: each device stores its own pairing secret in its OS keychain. - **Execution isolation**: the ADE runtime runs agents; controllers do not. @@ -904,7 +915,7 @@ project scope split. | iOS Lanes / Files / Work / PRs / Settings tabs | Implemented | | QR pairing UX | Implemented (payload v3 smart URL + iOS camera scanner; PIN entered separately) | | Device-bound pairing (DPoP, Secure Enclave P-256) | Implemented (host + brain ingress; `requireDpop` / `ADE_SYNC_REQUIRE_DPOP`) | -| Cloud tunnel relay (off-LAN transport, `relay` candidate) | Implemented, default off (`syncTunnelClientService` + `apps/tunnel-relay`) | +| Cloud tunnel relay (off-LAN transport, `relay` candidate) | Implemented, default on with Settings kill-switch (`syncTunnelClientService` + `apps/tunnel-relay`) | | Push notifications + Live Activities (APNs relay) | Implemented (see `push-notifications.md`; on-device E2E needs a physical iPhone) | | Tailscale integration | Implemented (address candidate + mDNS TXT + per-node `tailscale serve` publication on the live sync port) | | Lane portability desktop-to-desktop | Planned | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 10e390c42..b177681db 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -425,11 +425,18 @@ Source: `apps/ios/ADE/Services/SyncService.swift`. never the full 8787–8999 stale-port range, which belongs to the connect path's recovery sweep. Cloud-relay candidates (full `wss://…/connect/` URLs) are appended **last**, as the - lowest-priority transport, and only when the user has turned on the - "Cloud relay fallback" toggle (App Group key - `ade.sync.cloudRelayFallbackEnabled`, default off) — a full-URL relay - route cannot be host:port TCP-probed, so it is dialed only after every - direct route fails. `reconnectIfPossible` is guarded so overlapping + lowest-priority transport — a full-URL relay route cannot be + host:port TCP-probed, so it is dialed only after every direct route + fails. There is no user toggle: relay candidates always race + (LAN → Tailscale → relay, zero config). They arrive from the pairing + QR and — for already-paired phones — from `hello_ok` / + `brain_status`'s `cloudRelayWssUrl`, persisted into the host + profile's `savedRelayCandidates` (an explicit `cloudRelayWssUrl: + null` in `brain_status` means the operator flipped the machine's + relay kill-switch, and the phone clears its saved relay routes). + When the ACTIVE connection is a relay route, the Settings connection + header shows one quiet line — "Using ADE relay — Tailscale gives a + faster, private connection" — and nothing else changes. `reconnectIfPossible` is guarded so overlapping wake-ups never stack TCP/WebSocket attempts, and a reconnect never tears down an already-live connection. The socket declares the `chunkedEnvelopes` capability and sets a 32 MiB diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index 678747ee6..ee6d8bdc8 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -67,6 +67,25 @@ prompt delivery for waiting transitions; two dedupe lines (in-memory JSON fingerprint, then the relay's `dedupeKey` content-hash suppression); suppressed Live Activity updates never fall back to alert pushes. +Approval alerts are **actionable**: the publisher stamps top-level +`sessionId` + `itemId` and `aps.category: "ADE_APPROVAL"` on the payload, +and iOS binds Approve/Deny notification actions to that category (routed +through the same intent command registry the widgets use — `chat.approve` +with `decision: accept|decline`), so approvals resolve from the lock +screen without opening the app. + +Every alert also carries `aps.badge` = the machine-wide count of runs in +`waiting_for_*` phases. When that count changes with no alert to ride +(e.g. an approval answered on the Mac), the publisher sends a silent +title-less badge-only item (`dedupeKey: "alert:badge"`, no sound) so the +app icon never shows stale attention. iOS clears the badge on every +foreground. + +On daemon shutdown the publisher makes a best-effort Live Activity `end` +(`dismissalDate` now + 60 s, still-active runs re-stamped `stale`), +bounded by a short timeout so exit never hangs — dead agents don't linger +on the lock screen until the stale-date dim. + Per-device preferences are enforced brain-side before publishing: master enable, per-session mutes, and quiet hours (evaluated in the device's timezone; may span midnight). Live Activity updates ignore quiet hours @@ -90,6 +109,11 @@ One aggregate activity per machine (`activityId: "agent-runs"`, } ``` +Additive optional field: a `waiting_for_approval` row carries `itemId` +(the pending approval item), which the widget uses to render Approve/Deny +`Button(intent:)` on the lock-screen presentation. Older widgets ignore +it; the Swift decode stays lenient. + Phases: `starting | running | waiting_for_approval | waiting_for_input | completed | failed | stale`. Runs are capped at 3 (most recent first), `detail` at 160 chars, and a `failed` run's detail is redacted to a fixed @@ -113,6 +137,15 @@ when all runs reach a terminal phase. environment, last push received, relay reachability (via `push.getStatus`), plus notification/Live-Activity toggles, per-session mutes, and quiet hours. +- Per-session mute is also one tap away in the Work list: the session + row's context menu (and the open chat's header menu) offers + "Mute notifications" / "Unmute notifications", and muted rows show a + subtle `bell.slash` glyph. A muted session still counts toward the + badge and Live Activity — only its alert pushes are skipped. +- Approve/Deny actions appear on approval alerts (long-press or pull + down) and on `waiting_for_approval` Live Activity rows; both dispatch + through the pending-command registry, so they work even when the app + is not running. ## What needs a physical device From d3601513b5352026e4b3938070ecb6710628b08e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:02:47 -0400 Subject: [PATCH 02/11] iOS: actionable approval notifications, relay-everywhere, per-session mute, badge clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADE_APPROVAL notification category with Approve/Deny actions routed through the intent command registry (works app-closed via the pending command queue); default tap keeps deep-link routing. - Live Activity: run rows decode optional itemId; lock-screen waiting_for_approval rows render Approve/Deny Button(intent:) capsules (Dynamic Island unchanged); AttentionActionIntents.swift added to the ADEWidgets target sources. - Relay reachability: hello_ok/brain_status cloudRelayWssUrl persists into HostConnectionProfile.savedRelayCandidates (fixes the hello_ok profile rebuild dropping saved relay routes); explicit null clears them. - Toggle removed: relay candidates always race strictly last; settings show a quiet "Using ADE relay — Tailscale gives a faster, private connection" line when the active route is the relay. - Per-session mute in the Work list row context menu + open-chat header menu, bell.slash glyph on muted rows. - App badge cleared on every foreground. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 8 +- apps/ade-cli/src/services/sync/syncService.ts | 2 +- apps/ios/ADE.xcodeproj/project.pbxproj | 2 + apps/ios/ADE/App/ADEApp.swift | 4 + apps/ios/ADE/App/ADEAppDelegate.swift | 56 ++++++++++++- apps/ios/ADE/Models/RemoteModels.swift | 8 +- .../Services/PushNotificationService.swift | 7 ++ apps/ios/ADE/Services/SyncService.swift | 80 ++++++++++++------- .../Shared/ADEAgentActivityAttributes.swift | 12 ++- .../Settings/ConnectionSettingsView.swift | 15 +++- .../Settings/SettingsConnectionHeader.swift | 12 ++- .../Settings/SettingsPairingSection.swift | 33 -------- .../Work/WorkChatHeaderAndMessageViews.swift | 14 ++++ .../ADE/Views/Work/WorkRootComponents.swift | 31 +++++++ .../Work/WorkSessionDestinationView.swift | 3 +- apps/ios/ADETests/ADETests.swift | 22 +++++ .../ADEWidgets/ADEAgentActivityWidget.swift | 71 ++++++++++++++-- .../sync-and-multi-device/ios-companion.md | 2 +- 18 files changed, 293 insertions(+), 89 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 91eb20ab1..80ac7234b 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1223,10 +1223,10 @@ export async function createAdeRuntime(args: { projectConfigService, usageTrackingService, }); - // Cloud tunnel relay (phone → Cloudflare DO → this brain). Off by default; - // the Settings toggle flips the shared store and the client follows. The - // store instance is shared with the sync service so the relay candidate in - // pairingConnectInfo and the tunnel client always agree on one config file. + // Cloud tunnel relay (phone → Cloudflare DO → this brain). On by default — + // the Settings kill-switch flips the shared store and the client follows. + // The store instance is shared with the sync service so the relay candidate + // in pairingConnectInfo and the tunnel client always agree on one config file. const { createSyncCloudRelayStore } = await import("./services/sync/syncCloudRelayStore"); const { createSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService"); const cloudRelayStore = createSyncCloudRelayStore({ diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index a225a4075..1a1115883 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -128,7 +128,7 @@ type SyncServiceArgs = { * absent a store is created under the pairing state dir. */ cloudRelayStore?: SyncCloudRelayStore; - /** Fired when the "Cloud relay fallback" toggle flips (start/stop tunnel). */ + /** Fired when the ADE relay kill-switch flips (start/stop tunnel). */ onCloudRelayEnabledChanged?: (enabled: boolean) => void; projectCatalogProvider?: SyncProjectCatalogProvider; rosterProvider?: SyncRosterProvider; diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index a9f20a9b5..5764c0595 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ AA1100000000000000000022 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; AA1100000000000000000023 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; }; AA5100000000000000000014 /* AttentionActionIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5100000000000000000004 /* AttentionActionIntents.swift */; }; + AA5100000000000000000024 /* AttentionActionIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5100000000000000000004 /* AttentionActionIntents.swift */; }; D1C7A70000000000000001B1 /* DictationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C7A70000000000000001A1 /* DictationController.swift */; }; AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000012 /* DeepLinkRouter.swift */; }; K10000000000000000000001 /* SendToMacCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = K20000000000000000000001 /* SendToMacCard.swift */; }; @@ -1199,6 +1200,7 @@ AA1100000000000000000013 /* ADESharedTheme.swift in Sources */, AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */, AA5200000000000000000014 /* ADELockScreenWidget.swift in Sources */, + AA5100000000000000000024 /* AttentionActionIntents.swift in Sources */, AE00000000000000000000C5 /* ADEAgentActivityAttributes.swift in Sources */, AE00000000000000000000B6 /* ADEAgentActivityWidget.swift in Sources */, ); diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 8bc7f1eb2..4891c676c 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -29,10 +29,14 @@ struct ADEApp: App { guard !didBootstrapSync else { return } didBootstrapSync = true lastActivationSyncAt = Date() + await PushNotificationService.shared.clearAppBadge() await syncService.handleForegroundTransition() } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } + // Clear the badge on every foreground, independent of the sync + // throttle below — a lingering count after re-entry reads as stale. + Task { await PushNotificationService.shared.clearAppBadge() } guard didBootstrapSync else { return } let now = Date() guard now.timeIntervalSince(lastActivationSyncAt) > 1.0 else { return } diff --git a/apps/ios/ADE/App/ADEAppDelegate.swift b/apps/ios/ADE/App/ADEAppDelegate.swift index c21d9f041..251c5c466 100644 --- a/apps/ios/ADE/App/ADEAppDelegate.swift +++ b/apps/ios/ADE/App/ADEAppDelegate.swift @@ -11,9 +11,39 @@ final class ADEAppDelegate: NSObject, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { UNUserNotificationCenter.current().delegate = self + registerNotificationCategories() return true } + /// Register the approval-alert category so approval pushes carry inline + /// Approve / Deny actions on the lock screen and in Notification Center. The + /// brain stamps `aps.category = "ADE_APPROVAL"` on those alerts; the action + /// identifiers are matched in `didReceive response`. + private func registerNotificationCategories() { + let approve = UNNotificationAction( + identifier: ADEAppDelegate.approveActionIdentifier, + title: "Approve", + options: [.authenticationRequired] + ) + let deny = UNNotificationAction( + identifier: ADEAppDelegate.denyActionIdentifier, + title: "Deny", + options: [.authenticationRequired, .destructive] + ) + let category = UNNotificationCategory( + identifier: ADEAppDelegate.approvalCategoryIdentifier, + actions: [approve, deny], + intentIdentifiers: [], + options: [] + ) + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + // Identifiers shared with the brain's APNs payload contract. + static let approvalCategoryIdentifier = "ADE_APPROVAL" + static let approveActionIdentifier = "ADE_APPROVE" + static let denyActionIdentifier = "ADE_DENY" + func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data @@ -74,9 +104,29 @@ extension ADEAppDelegate: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse ) async { let userInfo = response.notification.request.content.userInfo - await MainActor.run { - PushNotificationService.shared.notePushReceived() - DeepLinkRouter.shared.handleNotificationUserInfo(userInfo) + let sessionId = (userInfo["sessionId"] as? String) ?? "" + let itemId = (userInfo["itemId"] as? String) ?? "" + + switch response.actionIdentifier { + case ADEAppDelegate.approveActionIdentifier where !sessionId.isEmpty: + await MainActor.run { PushNotificationService.shared.notePushReceived() } + await ADEIntentCommandRegistry.dispatch( + .approveSession, + payload: ["sessionId": sessionId, "itemId": itemId] + ) + case ADEAppDelegate.denyActionIdentifier where !sessionId.isEmpty: + await MainActor.run { PushNotificationService.shared.notePushReceived() } + await ADEIntentCommandRegistry.dispatch( + .denySession, + payload: ["sessionId": sessionId, "itemId": itemId] + ) + default: + // Default tap (and any action we don't handle) routes through the + // existing deep-link navigation, which knows the payload keys. + await MainActor.run { + PushNotificationService.shared.notePushReceived() + DeepLinkRouter.shared.handleNotificationUserInfo(userInfo) + } } } diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index a03911508..3f1699f42 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -33,9 +33,11 @@ struct HostConnectionProfile: Codable, Equatable { var savedAddressCandidates: [String] var discoveredLanAddresses: [String] var tailscaleAddress: String? - /// Full `wss://…/connect/` cloud-relay URLs from a pairing QR. - /// Optional so profiles persisted before the relay feature decode cleanly. - /// Always attempted LAST and only when the user enabled the relay fallback. + /// Full `wss://…/connect/` cloud-relay URLs learned from a pairing + /// QR or advertised live by the host in `hello_ok`/`brain_status`. Optional so + /// profiles persisted before the relay feature decode cleanly. Always raced + /// LAST (after every LAN and Tailscale route) — unconditionally, no user + /// toggle; the relay is a zero-config fallback. var savedRelayCandidates: [String]? var updatedAt: String diff --git a/apps/ios/ADE/Services/PushNotificationService.swift b/apps/ios/ADE/Services/PushNotificationService.swift index e794703bb..7528e0eda 100644 --- a/apps/ios/ADE/Services/PushNotificationService.swift +++ b/apps/ios/ADE/Services/PushNotificationService.swift @@ -113,6 +113,13 @@ final class PushNotificationService: ObservableObject { updateDiagnostics { $0.lastPushReceivedAt = Date() } } + /// Clear the app-icon badge. Called on every foreground transition so the + /// badge (a count of runs awaiting attention, set by the brain's alerts) + /// never lingers once the user is back in the app. + func clearAppBadge() async { + try? await UNUserNotificationCenter.current().setBadgeCount(0) + } + // MARK: - Live Activity push-to-start token (from LiveActivityService) /// Records the ActivityKit push-to-start token. Re-registers with the brain diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 4377a7da1..67811cc19 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1592,9 +1592,6 @@ final class SyncService: ObservableObject { /// shared suite so a synthesized "pending sync" row survives relaunch and is /// visible to the same UI regardless of process. private let pendingChatCreationsKey = "ade.sync.pendingChatCreations.v1" - /// App Group key for the "Cloud relay fallback" toggle (default OFF). When - /// off, relay candidates are excluded from every transport race. - static let cloudRelayFallbackDefaultsKey = "ade.sync.cloudRelayFallbackEnabled" private let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" private let outboundSyncCursorsKey = "ade.sync.outboundSyncCursors" private let pendingOutboundChangesetsKey = "ade.sync.pendingOutboundChangesets" @@ -3896,11 +3893,11 @@ final class SyncService: ObservableObject { normalizedCandidateAddresses )) let portCandidates = syncConnectPortCandidates(primaryPort: requestedPort, addresses: addressCandidates) - // Relay URLs (full wss://) are attempted LAST and only when the fallback - // is enabled. They ride their own path/port, so they're kept out of the - // direct port sweep and appended as single attempts. + // Relay URLs (full wss://) are attempted LAST, always. They ride their own + // path/port, so they're kept out of the direct port sweep and appended as + // single attempts after every direct route. let normalizedRelayCandidates = deduplicatedAddresses(relayCandidates.filter(syncIsFullWebSocketRoute)) - let relayWalkCandidates = isCloudRelayFallbackEnabled ? normalizedRelayCandidates : [] + let relayWalkCandidates = normalizedRelayCandidates syncConnectLog.info( "ADE_SYNC_TRACE pair candidates host=\(requestedHost, privacy: .public) ports=[\(portCandidates.map(String.init).joined(separator: ","), privacy: .public)] discoveryLan=[\(syncLogAddressList(discoveryAddresses), privacy: .public)] discoveryTailnet=[\(syncLogAddressList(discoveryTailscaleAddresses), privacy: .public)] explicitTailnet=[\(syncLogAddressList(explicitTailscaleAddresses), privacy: .public)] provided=[\(syncLogAddressList(normalizedCandidateAddresses), privacy: .public)] connectable=[\(syncLogAddressList(addressCandidates), privacy: .public)] relay=[\(syncLogAddressList(relayWalkCandidates), privacy: .public)]" ) @@ -3991,8 +3988,7 @@ final class SyncService: ObservableObject { return !syncIsTailscaleRoute(host) }, tailscaleAddress: normalizedTailscaleAddress ?? addressCandidates.first(where: syncIsTailscaleRoute), - // Persist relay candidates regardless of the toggle so enabling the - // fallback later reuses them; the toggle only gates whether they race. + // Persist relay candidates so later reconnects can race them last. savedRelayCandidates: normalizedRelayCandidates.isEmpty ? nil : normalizedRelayCandidates ) currentAddress = preferredAddress @@ -7797,17 +7793,9 @@ final class SyncService: ObservableObject { return false } - /// Whether the user enabled the cloud relay fallback. Read from the App Group - /// suite so the value is shared and defaults OFF. - var isCloudRelayFallbackEnabled: Bool { - ADESharedContainer.defaults.bool(forKey: SyncService.cloudRelayFallbackDefaultsKey) - } - - /// Relay `wss://` candidates for a profile, ordered last and gated on the - /// user toggle. Empty when the fallback is off so relay is excluded from - /// every race. + /// Relay `wss://` candidates for a profile. Relay always races, strictly LAST + /// (after every LAN and Tailscale route) — zero config, no user toggle. private func relayFallbackCandidates(for profile: HostConnectionProfile) -> [String] { - guard isCloudRelayFallbackEnabled else { return [] } return deduplicatedAddresses((profile.savedRelayCandidates ?? []).filter(syncIsFullWebSocketRoute)) } @@ -8804,6 +8792,23 @@ final class SyncService: ObservableObject { let discoveredLan = deduplicatedAddresses( matchingDiscovery?.addresses ?? activeHostProfile?.discoveredLanAddresses ?? [] ) + // The host advertises its live cloud-relay URL in `hello_ok` (key omitted + // when the relay is disabled or on older hosts). Fold it into the saved + // relay candidates — freshest first — so an already-paired phone learns + // the relay route even when it never scanned a relay QR. Preserve any + // previously-saved relay URLs (the rebuilt profile would otherwise drop + // them, since the init defaults `savedRelayCandidates` to nil). + let advertisedRelayUrl = (payload["cloudRelayWssUrl"] as? String).flatMap { url in + syncIsFullWebSocketRoute(url) ? url : nil + } + let mergedRelayCandidates = Array( + deduplicatedAddresses( + (advertisedRelayUrl.map { [$0] } ?? []) + + (activeHostProfile?.savedRelayCandidates ?? []) + ) + .filter(syncIsFullWebSocketRoute) + .prefix(3) + ) let profile = HostConnectionProfile( hostIdentity: remoteHostIdentity ?? activeHostProfile?.hostIdentity ?? expectedHostIdentity, @@ -8819,7 +8824,8 @@ final class SyncService: ObservableObject { discoveredLanAddresses: discoveredLan, tailscaleAddress: matchingDiscovery?.tailscaleAddress ?? (syncIsTailscaleRoute(connectedHost) ? connectedHost : nil) - ?? activeHostProfile?.tailscaleAddress + ?? activeHostProfile?.tailscaleAddress, + savedRelayCandidates: mergedRelayCandidates.isEmpty ? nil : mergedRelayCandidates ) saveProfile(profile) resetOutboundCursorStateForActiveProject() @@ -9073,16 +9079,32 @@ final class SyncService: ObservableObject { let ack = try decode(payload, as: SyncChangesetAckPayload.self) handleChangesetAck(ack) case "brain_status": - if let dict = payload as? [String: Any], let brain = dict["brain"] as? [String: Any] { - hostName = brain["deviceName"] as? String - updateProfile { profile in - profile.hostName = brain["deviceName"] as? String - profile.lastHostDeviceId = brain["deviceId"] as? String + if let dict = payload as? [String: Any] { + if let brain = dict["brain"] as? [String: Any] { + hostName = brain["deviceName"] as? String + updateProfile { profile in + profile.hostName = brain["deviceName"] as? String + profile.lastHostDeviceId = brain["deviceId"] as? String + } + if let peer = (dict["connectedPeers"] as? [[String: Any]])?.first(where: { $0["deviceId"] as? String == deviceId }) { + let latencyMs = (peer["latencyMs"] as? NSNumber)?.intValue + let syncLag = (peer["syncLag"] as? NSNumber)?.intValue + recordConnectionLoadSample(latencyMs: latencyMs, syncLag: syncLag) + } } - if let peer = (dict["connectedPeers"] as? [[String: Any]])?.first(where: { $0["deviceId"] as? String == deviceId }) { - let latencyMs = (peer["latencyMs"] as? NSNumber)?.intValue - let syncLag = (peer["syncLag"] as? NSNumber)?.intValue - recordConnectionLoadSample(latencyMs: latencyMs, syncLag: syncLag) + // Relay reachability can flip live. `brain_status` always carries + // `cloudRelayWssUrl` on new hosts: a wss route when the relay is up, + // JSON null when it was turned off. Track both so an already-paired + // phone gains the relay the moment the host enables it, and drops it + // the moment it goes away. Key absent = older host → leave as-is. + if let url = dict["cloudRelayWssUrl"] as? String, syncIsFullWebSocketRoute(url) { + updateProfile { profile in + profile.savedRelayCandidates = Array( + deduplicatedAddresses([url] + (profile.savedRelayCandidates ?? [])).prefix(3) + ) + } + } else if dict["cloudRelayWssUrl"] is NSNull { + updateProfile { $0.savedRelayCandidates = nil } } } resolve(requestId: requestId, result: .success(payload)) diff --git a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift index 2ab0a5d95..c4968dbab 100644 --- a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift +++ b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift @@ -58,6 +58,11 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { public let model: String? public let lane: String? public let detail: String? + /// Approval item id, present only on rows whose `phase` is + /// `waiting_for_approval`. Threaded into the lock-screen Approve/Deny + /// intents so they resolve the exact pending request. Optional and + /// additive — older payloads without it decode to `nil`. + public let itemId: String? public init( id: String, @@ -65,7 +70,8 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { phase: String, model: String? = nil, lane: String? = nil, - detail: String? = nil + detail: String? = nil, + itemId: String? = nil ) { self.id = id self.title = title @@ -73,10 +79,11 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { self.model = model self.lane = lane self.detail = detail + self.itemId = itemId } private enum CodingKeys: String, CodingKey { - case id, title, phase, model, lane, detail + case id, title, phase, model, lane, detail, itemId } public init(from decoder: Decoder) throws { @@ -87,6 +94,7 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { self.model = try? c.decodeIfPresent(String.self, forKey: .model) self.lane = try? c.decodeIfPresent(String.self, forKey: .lane) self.detail = try? c.decodeIfPresent(String.self, forKey: .detail) + self.itemId = try? c.decodeIfPresent(String.self, forKey: .itemId) } public var resolvedPhase: AgentRunPhase { diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 7e9246cfc..8e0bfd3b8 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -48,8 +48,6 @@ struct ConnectionSettingsView: View { snapshot: presentationModel.pairingSnapshot, presentedSheet: $presentedSheet ) - - SettingsCloudRelayToggle() } .padding(.horizontal, 16) .padding(.top, 4) @@ -191,6 +189,10 @@ struct SettingsConnectionSnapshot: Equatable { var savedReconnectPrefersTailnet: Bool var errorMessage: String? var showTailscaleOffHint = false + /// True when the live, connected route is the cloud relay (a full wss:// URL) + /// rather than a direct LAN/Tailscale path. Drives the quiet "prefer + /// Tailscale" nudge in the header. + var usingRelay = false } struct SettingsPairingSnapshot: Equatable { @@ -275,7 +277,9 @@ private final class SettingsConnectionPresentationModel: ObservableObject { canReconnectToSavedHost: syncService.canReconnectToSavedHost, savedReconnectPrefersTailnet: savedReconnectHost?.tailscaleAddress != nil, errorMessage: health.transport == .unreachable ? health.lastFailureMessage : nil, - showTailscaleOffHint: syncService.tailscaleOffHintVisible + showTailscaleOffHint: syncService.tailscaleOffHintVisible, + usingRelay: syncService.connectionState == .connected + && (address.map(syncIsFullWebSocketRoute) ?? false) ) ) @@ -332,6 +336,11 @@ private final class SettingsConnectionPresentationModel: ObservableObject { private static func routeLine(address: String?, port: Int?) -> String? { guard let address else { return nil } + // A full wss:// relay URL carries an opaque `/connect/` path and + // its own port — showing it raw is noise. Name the route instead. + if syncIsFullWebSocketRoute(address) { + return "ADE relay" + } let prefix = syncIsTailscaleRoute(address) ? "Tailscale " : "" if let port { return "\(prefix)\(address) · :\(port)" diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 5d39bd7c7..b511fbcfc 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -45,7 +45,17 @@ struct SettingsConnectionHeader: View { } if health.transport.isConnected { - SettingsConnectedHostDetails(routeLine: snapshot.routeLine) + VStack(alignment: .leading, spacing: 4) { + SettingsConnectedHostDetails(routeLine: snapshot.routeLine) + if snapshot.usingRelay { + // Quiet nudge: we're reachable over the relay, but a direct tailnet + // path would be faster and stays private. Nothing modal. + Text("Using ADE relay — Tailscale gives a faster, private connection") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } } else if let hostName = pendingHostName { Text(pendingDescription(hostName: hostName)) .font(.subheadline) diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index de9acd4e1..0096c31fb 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -49,39 +49,6 @@ struct SettingsPairingSection: View { } } -/// Compact toggle for routing through ADE's cloud relay when no direct path -/// works. Default OFF; stored in the App Group suite so the value is shared. -struct SettingsCloudRelayToggle: View { - @AppStorage(SyncService.cloudRelayFallbackDefaultsKey, store: ADESharedContainer.defaults) - private var cloudRelayFallbackEnabled = false - - var body: some View { - Toggle(isOn: $cloudRelayFallbackEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text("Cloud relay fallback") - .font(.body.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - Text("Route through ADE's cloud relay when no direct path works.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - .tint(ADEColor.purpleAccent) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.5)) - ) - .glassEffect(in: .rect(cornerRadius: 16)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(ADEColor.border.opacity(0.18), lineWidth: 0.75) - ) - .accessibilityHint("Use ADE's cloud relay as a last resort when direct connections fail.") - } -} - struct SettingsSectionHeader: View { let label: String let hint: String? diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index fd24982af..634cdb4c4 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -93,6 +93,10 @@ struct WorkChatHeaderMenuModel: Equatable { var sessionPinned: Bool var sessionIdCopied: Bool var sessionDeepLinkCopied: Bool + /// Open chat's session id, used by the Mute item to read / toggle push prefs. + /// Defaults to empty so the memberwise init at the call site stays source + /// compatible; the destination view passes the real id. + var sessionId: String = "" } /// Chat header overflow menu, extracted from `WorkSessionDestinationView` and @@ -246,6 +250,16 @@ struct WorkChatHeaderMenu: View, Equatable { Label(model.sessionPinned ? "Unpin from front" : "Pin to front", systemImage: model.sessionPinned ? "pin.slash" : "pin") } + + if !model.sessionId.isEmpty { + let muted = PushNotificationService.shared.prefs.mutedSessionIds.contains(model.sessionId) + Button { + PushNotificationService.shared.setMuted(!muted, sessionId: model.sessionId) + } label: { + Label(muted ? "Unmute notifications" : "Mute notifications", + systemImage: muted ? "bell" : "bell.slash") + } + } } } diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index f01141c95..ec23a50b1 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -502,6 +502,14 @@ struct WorkSessionListRow: View { /// Defaults to a no-op so preview harnesses don't have to wire it. var onOpenPullRequest: (TerminalSessionSummary, LanePrTag) -> Void = { _, _ in } + /// Read straight from the shared prefs — mute only applies to chat sessions. + /// Context menus rebuild each open and `WorkSessionRow` re-evaluates whenever + /// the parent list does, so a direct read is enough to keep both in step. + private var isMuted: Bool { + isChatSession(session) + && PushNotificationService.shared.prefs.mutedSessionIds.contains(session.id) + } + var body: some View { let rowStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) Button { @@ -525,6 +533,7 @@ struct WorkSessionListRow: View { chatSummary: chatSummary, status: rowStatus, isArchived: isArchived, + isMuted: isMuted, transitionNamespace: transitionNamespace, isSelectedTransitionSource: selectedSessionId == session.id, compact: compact @@ -607,6 +616,15 @@ struct WorkSessionListRow: View { Label(session.pinned ? "Unpin from front" : "Pin to front", systemImage: session.pinned ? "pin.slash" : "pin") } + if isChatSession(session) { + let muted = PushNotificationService.shared.prefs.mutedSessionIds.contains(session.id) + Button { + PushNotificationService.shared.setMuted(!muted, sessionId: session.id) + } label: { + Label(muted ? "Unmute notifications" : "Mute notifications", + systemImage: muted ? "bell" : "bell.slash") + } + } } } @@ -790,6 +808,7 @@ private struct WorkSessionRowRenderSignature: Equatable { let pullRequestState: String? let status: String let isArchived: Bool + let isMuted: Bool let isSelectedTransitionSource: Bool let compact: Bool @@ -800,6 +819,7 @@ private struct WorkSessionRowRenderSignature: Equatable { chatSummary: AgentChatSessionSummary?, status: String, isArchived: Bool, + isMuted: Bool, isSelectedTransitionSource: Bool, compact: Bool ) { @@ -819,6 +839,7 @@ private struct WorkSessionRowRenderSignature: Equatable { self.pullRequestState = pullRequest.map { lanePrStateLabel($0.state) } self.status = status self.isArchived = isArchived + self.isMuted = isMuted self.isSelectedTransitionSource = isSelectedTransitionSource self.compact = compact } @@ -831,6 +852,7 @@ struct WorkSessionRow: View, Equatable { let chatSummary: AgentChatSessionSummary? let status: String let isArchived: Bool + var isMuted: Bool = false let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool var compact: Bool = false @@ -843,6 +865,7 @@ struct WorkSessionRow: View, Equatable { chatSummary: AgentChatSessionSummary?, status: String, isArchived: Bool, + isMuted: Bool = false, transitionNamespace: Namespace.ID?, isSelectedTransitionSource: Bool, compact: Bool = false @@ -853,6 +876,7 @@ struct WorkSessionRow: View, Equatable { self.chatSummary = chatSummary self.status = status self.isArchived = isArchived + self.isMuted = isMuted self.transitionNamespace = transitionNamespace self.isSelectedTransitionSource = isSelectedTransitionSource self.compact = compact @@ -863,6 +887,7 @@ struct WorkSessionRow: View, Equatable { chatSummary: chatSummary, status: status, isArchived: isArchived, + isMuted: isMuted, isSelectedTransitionSource: isSelectedTransitionSource, compact: compact ) @@ -947,6 +972,12 @@ struct WorkSessionRow: View, Equatable { .font(.caption2) .foregroundStyle(ADEColor.accent) } + if isMuted { + Image(systemName: "bell.slash") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .accessibilityLabel("Notifications muted") + } Spacer(minLength: 6) Text(relativeTimestampCompact(workSessionActivityTimestamp(session: session, summary: chatSummary))) .font(.caption2.monospacedDigit()) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 3fe706b58..9cdf9022a 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -649,7 +649,8 @@ struct WorkSessionDestinationView: View { createPrBlockedReason: createPullRequestBlockedReason, sessionPinned: session.pinned, sessionIdCopied: sessionIdCopied, - sessionDeepLinkCopied: sessionDeepLinkCopied + sessionDeepLinkCopied: sessionDeepLinkCopied, + sessionId: session.id ) } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 7904a4b93..521e89e1f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5991,6 +5991,28 @@ final class ADETests: XCTestCase { XCTAssertEqual(recorder.commands.first?.payload["prId"], "pr_42") } + func testAgentRunsContentStateDecodesOptionalItemId() throws { + // The brain stamps `itemId` only on rows blocked on approval; older payloads + // omit it. The lenient decoder must keep both shapes working so the lock- + // screen Approve/Deny intents resolve the right pending request. + let json = Data(""" + { + "updatedAt": 1720000000, + "activeCount": 2, + "runs": [ + { "id": "c", "title": "Release checklist", "phase": "waiting_for_approval", "itemId": "item_release_push" }, + { "id": "a", "title": "Refactor sync transport", "phase": "running" } + ] + } + """.utf8) + + let state = try JSONDecoder().decode(ADEAgentRunsAttributes.ContentState.self, from: json) + XCTAssertEqual(state.runs.count, 2) + XCTAssertEqual(state.runs[0].resolvedPhase, .waitingForApproval) + XCTAssertEqual(state.runs[0].itemId, "item_release_push") + XCTAssertNil(state.runs[1].itemId, "runs without an itemId key decode to nil") + } + func testPrActionAvailabilityMatchesDesktopBaseline() { let open = PrActionAvailability(prState: "open") XCTAssertTrue(open.showsMerge) diff --git a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift index af72db8dd..76534c2d8 100644 --- a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift +++ b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift @@ -1,4 +1,5 @@ import ActivityKit +import AppIntents import SwiftUI import WidgetKit @@ -199,7 +200,34 @@ private struct AgentRunRow: View { private var phase: AgentRunPhase { run.resolvedPhase } + /// Inline Approve / Deny only earns space on the full lock-screen row for a + /// run actually blocked on approval. The Dynamic Island (`compact`) stays + /// glance-only. + private var showsApprovalActions: Bool { + !compact && phase == .waitingForApproval + } + var body: some View { + Group { + if showsApprovalActions { + VStack(alignment: .leading, spacing: 7) { + rowContent + approvalActions + } + } else { + rowContent + } + } + .padding(.vertical, phase.needsAttention && !compact ? 3 : 0) + .padding(.horizontal, phase.needsAttention && !compact ? 6 : 0) + .background( + phase.needsAttention && !compact + ? RoundedRectangle(cornerRadius: 7, style: .continuous).fill(phase.tint.opacity(0.14)) + : nil + ) + } + + private var rowContent: some View { HStack(spacing: 8) { Image(systemName: phase.symbol) .font(.system(size: compact ? 10 : 11, weight: .semibold)) @@ -228,13 +256,40 @@ private struct AgentRunRow: View { .foregroundStyle(phase.needsAttention ? phase.tint : .secondary) .lineLimit(1) } - .padding(.vertical, phase.needsAttention && !compact ? 3 : 0) - .padding(.horizontal, phase.needsAttention && !compact ? 6 : 0) - .background( - phase.needsAttention && !compact - ? RoundedRectangle(cornerRadius: 7, style: .continuous).fill(phase.tint.opacity(0.14)) - : nil - ) + } + + /// Approve / Deny capsules, aligned under the title (past the status glyph). + /// Approve carries the phase amber; Deny stays neutral so the destructive + /// choice never reads as the primary one. + private var approvalActions: some View { + HStack(spacing: 8) { + Button(intent: ApproveSessionIntent(sessionId: run.id, itemId: run.itemId ?? "")) { + approvalLabel("Approve", systemImage: "checkmark", tint: phase.tint) + } + .buttonStyle(.plain) + + Button(intent: DenySessionIntent(sessionId: run.id, itemId: run.itemId ?? "")) { + approvalLabel("Deny", systemImage: "xmark", tint: .secondary) + } + .buttonStyle(.plain) + + Spacer(minLength: 0) + } + .padding(.leading, 22) + } + + private func approvalLabel(_ title: String, systemImage: String, tint: Color) -> some View { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + Text(title) + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(tint) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(tint.opacity(0.16), in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).stroke(tint.opacity(0.30), lineWidth: 0.6)) } /// Prefer the host-supplied detail line; fall back to "lane · model". @@ -299,7 +354,7 @@ private extension ADEAgentRunsAttributes.ContentState { updatedAt: Date().timeIntervalSince1970, activeCount: 3, runs: [ - .init(id: "c", title: "Release checklist", phase: "waiting_for_approval", model: "claude", lane: "Primary", detail: "approve git push"), + .init(id: "c", title: "Release checklist", phase: "waiting_for_approval", model: "claude", lane: "Primary", detail: "approve git push", itemId: "item_release_push"), .init(id: "a", title: "Refactor sync transport", phase: "running", model: "gpt-5-codex", lane: "Primary"), ] ) diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index b177681db..e405d3946 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1103,7 +1103,7 @@ reflected in the phone's UI on the next descriptor read. | PIN pairing flow | Implemented | | QR pairing payload (v3 smart URL) + camera scanner (`SettingsPairingScannerSheet`) | Implemented | | Device-bound pairing (DPoP, Secure Enclave P-256) | Implemented (`DpopKeyService`; signed proof on every paired hello) | -| Cloud relay fallback (lowest-priority `relay` transport, default off) | Implemented | +| Cloud relay (lowest-priority `relay` transport, always races, no toggle) | Implemented | | Project home + machine project switching | Implemented, including Add project actions for browsing/opening existing Git repos, creating local projects, cloning GitHub repos on the paired machine, and removing projects from the list | | Lanes tab | Implemented to live machine parity (with `devicesOpen`, multi-attach, stack canvas, stack-position/base-branch editing in Manage Lane, and template environment progress) | | Files tab | Implemented with freely-editable workspaces (mobile read-only file gate removed) and a unified full-screen name + content search page (`FilesSearchScreen`) | From 75b4034a0851d92d327637a1004b7d234a3438d8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:19:57 -0400 Subject: [PATCH 03/11] Quality round: LiveActivityIntent conformance, relay default-on migration marker, badge subset sync, shutdown guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Approve/Deny intents conform to LiveActivityIntent so lock-screen Live Activity buttons run in the APP process (plain AppIntents execute in the widget extension where the command bridge is nil); pending intent queue now also drains on every foreground, not just cold launch. - syncCloudRelayStore: enabledSetByUser marker — pre-default-on builds implicitly persisted enabled:false on first run, which would have kept the relay off for every upgrader; an unmarked false now migrates to enabled while setEnabled() stamps the marker so a chosen kill-switch false survives. - Badge-only sync targets alert-enabled devices NOT covered by this flush's alerts (a device muted for the session still gets the count). - publisher.shutdown(): clears any scheduled flush timer before the LA end and holds the raced publish promise so a late rejection can't surface unhandled. - Judo moves: shared resolvePushRelayStateFile() (bootstrap + daemon shutdown key can't drift), closure-based dispose (no this-dependence), syncMergedRelayCandidates() unifies the hello_ok/brain_status merge. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 4 +- apps/ade-cli/src/cli.ts | 4 +- .../push/pushPublisherService.test.ts | 49 ++++++++++++++++ .../src/services/push/pushPublisherService.ts | 58 +++++++++++++------ .../services/sync/syncCloudRelayStore.test.ts | 10 ++++ .../src/services/sync/syncCloudRelayStore.ts | 44 +++++++++----- .../main/services/sync/syncService.test.ts | 9 ++- apps/ios/ADE/App/ADEApp.swift | 4 ++ apps/ios/ADE/Services/SyncService.swift | 31 +++++----- .../ADE/Shared/AttentionActionIntents.swift | 10 +++- 10 files changed, 172 insertions(+), 51 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 80ac7234b..20c2f11db 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -99,7 +99,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { createPushRegistrationStore } from "./services/push/pushRegistrationStore"; import { createPushRelayClient } from "./services/push/pushRelayClient"; -import { getSharedPushPublisherService, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService"; +import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import type { PrEventPayload } from "../../desktop/src/shared/types/prs"; @@ -1178,7 +1178,7 @@ export async function createAdeRuntime(args: { // push-identity file), so a run in one project doesn't clobber the phone's // single "agent-runs" Live Activity for another. Each scope wires its own // chat/pty/PR signals via attachSources; the aggregate merges runs across all. - const pushRelayFilePath = path.join(resolveMachineAdeLayout().secretsDir, "push-relay.json"); + const pushRelayFilePath = resolvePushRelayStateFile(resolveMachineAdeLayout().secretsDir); const pushPublisherService = getSharedPushPublisherService(pushRelayFilePath, () => { const store = createPushRegistrationStore({ filePath: pushRelayFilePath }); return { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 648033220..02af998f3 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -13564,9 +13564,9 @@ async function runServe( // Activity `end` so the lock screen doesn't show dead agents until the // stale-date dim. Bounded by the publisher's internal timeout. try { - const { peekSharedPushPublisherService } = await import("./services/push/pushPublisherService"); + const { peekSharedPushPublisherService, resolvePushRelayStateFile } = await import("./services/push/pushPublisherService"); await peekSharedPushPublisherService( - path.join(layout.secretsDir, "push-relay.json"), + resolvePushRelayStateFile(layout.secretsDir), )?.shutdown(); } catch { // Shutdown must never hang or fail on push cleanup. diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index efa9a6c4f..aafcd6746 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -249,6 +249,55 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("badge-syncs devices whose alert was muted while the alert covers the rest", async () => { + const deviceB = { ...device, deviceId: "dev-2", prefs: { ...device.prefs, mutedSessionIds: ["s-1"] } }; + const publish = vi.fn().mockResolvedValue({ ok: true }); + const store = { + hasRegisteredDevices: () => true, + getStatusSnapshot: () => ({ enabled: true, claimed: true, registeredDeviceCount: 2, lastPublishAt: null, lastPublishError: null, lastRelayContactAt: null }), + listDevices: () => [device, deviceB], + getDevice: () => device, + recordPublishResult: vi.fn(), + recordRelayContact: vi.fn(), + }; + const relayClient = { publish, health: vi.fn().mockResolvedValue({ ok: true, apnsConfigured: true }), baseUrl: "https://relay.test" }; + let chatCb: ((env: AgentChatEventEnvelope) => void) | null = null; + const emit = (env: AgentChatEventEnvelope) => chatCb?.(env); + const publisher = createPushPublisherService({ + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() } as never, + store: store as never, + relayClient: relayClient as never, + machineName: "MacBook", + flushDebounceMs: 2_000, + promptFlushMs: 150, + }); + publisher.attachSources("scope-1", { + agentChatService: { + subscribeToEvents: (cb: (env: AgentChatEventEnvelope) => void) => { + chatCb = cb; + return () => {}; + }, + getSessionSummary: vi.fn().mockResolvedValue(null), + } as never, + }); + await publisher.start(); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + const payload = publish.mock.calls[0][0]; + const alertItem = payload.notifications.find((item: { title: string }) => item.title.length > 0); + const badgeItem = payload.notifications.find((item: { dedupeKey?: string }) => item.dedupeKey === "alert:badge"); + // The audible alert reaches only the unmuted device... + expect(alertItem.deviceIds).toEqual(["dev-1"]); + expect(alertItem.badge).toBe(1); + // ...while the muted device still gets the new badge count silently. + expect(badgeItem.deviceIds).toEqual(["dev-2"]); + expect(badgeItem.badge).toBe(1); + + publisher.dispose(); + }); + it("sends a silent badge-only item when the awaiting count drops with no alert", async () => { const { publisher, publish, emit } = makeHarness(); await publisher.start(); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index ea416f48f..90c9afc07 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { PtyExitEvent } from "../../../../desktop/src/shared/types/sessions"; @@ -19,6 +20,15 @@ import type { export const AGENT_RUNS_ACTIVITY_ID = "agent-runs"; export const AGENT_RUNS_ATTRIBUTES_TYPE = "ADEAgentRunsAttributes"; export const AGENT_RUNS_DEDUPE_KEY = "la:agent-runs"; +/** + * Machine-level push identity/registration file. Also the key of the shared + * publisher singleton — bootstrap (create) and the daemon shutdown path (peek) + * must resolve the identical path or the shutdown Live-Activity end silently + * misses the instance. + */ +export function resolvePushRelayStateFile(secretsDir: string): string { + return path.join(secretsDir, "push-relay.json"); +} /** UNNotificationCategory id iOS binds Approve/Deny actions to. */ export const APPROVAL_NOTIFICATION_CATEGORY = "ADE_APPROVAL"; const BADGE_DEDUPE_KEY = "alert:badge"; @@ -546,17 +556,20 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } // The badge must also track *drops* (an approval answered on the Mac), which - // produce no alert. A silent badge-only item keeps the icon honest; the - // relay's content-hash suppression absorbs unchanged resends. - const badgeDeviceIds = devices - .filter((device) => Boolean(device.apnsToken) && device.prefs.enabled) + // produce no alert — and devices whose alert was muted still need the new + // count. A silent badge-only item targets every alert-enabled device NOT + // already covered by an alert in this flush; the relay's content-hash + // suppression absorbs unchanged resends. + const alertCoveredDeviceIds = new Set(alertItems.flatMap((item) => item.deviceIds ?? [])); + const badgeSyncDeviceIds = devices + .filter((device) => + Boolean(device.apnsToken) + && device.prefs.enabled + && !alertCoveredDeviceIds.has(device.deviceId)) .map((device) => device.deviceId); - const needsBadgeSync = badgeCount !== lastSentBadgeCount - && alertItems.length === 0 - && badgeDeviceIds.length > 0; - if (needsBadgeSync) { + if (badgeCount !== lastSentBadgeCount && badgeSyncDeviceIds.length > 0) { alertItems.push({ - deviceIds: badgeDeviceIds, + deviceIds: badgeSyncDeviceIds, title: "", sound: null, dedupeKey: BADGE_DEDUPE_KEY, @@ -871,6 +884,13 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } }; + const dispose = (): void => { + disposed = true; + for (const scopeKey of [...scopes.keys()]) detachScope(scopeKey); + if (flushTimer) clearTimeout(flushTimer); + flushTimer = null; + }; + return { /** Warm the APNs-configured cache. Idempotent; safe to call per scope. */ async start(): Promise { @@ -954,12 +974,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return buildDeliveryStatus(deviceId); }, - dispose(): void { - disposed = true; - for (const scopeKey of [...scopes.keys()]) detachScope(scopeKey); - if (flushTimer) clearTimeout(flushTimer); - flushTimer = null; - }, + dispose, /** * Daemon-exit path: best-effort `end` for the aggregate Live Activity so @@ -968,6 +983,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { * and only sent when a start was actually committed. Ends with dispose(). */ async shutdown(): Promise { + // Stop any scheduled flush first so a timer-driven publish can't race + // the shutdown `end`. + if (flushTimer) clearTimeout(flushTimer); + flushTimer = null; + flushFireAt = 0; if (!disposed && liveActivityStarted && !isGated()) { try { const nowMs = now(); @@ -989,9 +1009,13 @@ export function createPushPublisherService(deps: PushPublisherDeps) { dismissalDate: Math.floor(nowMs / 1000) + 60, }; let timeout: NodeJS.Timeout | null = null; + // Keep a handle so a publish that loses the race can't surface as + // an unhandled rejection after shutdown returns. + const publishPromise = deps.relayClient.publish({ liveActivity: [item] }); + publishPromise.catch(() => {}); try { await Promise.race([ - deps.relayClient.publish({ liveActivity: [item] }), + publishPromise, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("shutdown publish timed out")), SHUTDOWN_PUBLISH_TIMEOUT_MS); }), @@ -1004,7 +1028,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { logWarn("push.shutdown_end_failed", error); } } - this.dispose(); + dispose(); }, // Exposed for tests / diagnostics. diff --git a/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts b/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts index f84536606..4a094b5da 100644 --- a/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts +++ b/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts @@ -31,6 +31,16 @@ describe("syncCloudRelayStore enablement default", () => { expect(store.getMachineIdentity().machineKey).toBe(machineKey); }); + it("migrates a legacy implicit enabled:false (no user marker) to enabled", () => { + // Pre-default-on builds persisted `enabled: false` on first run without any + // user action. Those files must read as enabled after the flip; only a + // marker-stamped false (setEnabled) is a real kill-switch choice. + const seeded = createSyncCloudRelayStore({ filePath }); + const { machineKey, secret } = seeded.getMachineIdentity(); + fs.writeFileSync(filePath, `${JSON.stringify({ enabled: false, machineKey, secret })}\n`); + expect(createSyncCloudRelayStore({ filePath }).isEnabled()).toBe(true); + }); + it("preserves an explicit kill-switch false across reads", () => { const store = createSyncCloudRelayStore({ filePath }); store.setEnabled(false); diff --git a/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts b/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts index 1b49472d3..aa803a661 100644 --- a/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts +++ b/apps/ade-cli/src/services/sync/syncCloudRelayStore.ts @@ -8,9 +8,11 @@ const DEFAULT_RELAY_URL = "https://ade-tunnel-relay.arulsharma1028.workers.dev"; export type SyncCloudRelayConfig = { /** * When false the tunnel client never claims or connects. Defaults to true - * (relay-everywhere, zero-config): a file without the field reads as - * enabled, while an explicit `false` — the desktop kill-switch or - * `ade sync relay disable` — is preserved. + * (relay-everywhere, zero-config). Only an `enabled: false` accompanied by + * the `enabledSetByUser` file marker — i.e. the desktop kill-switch or + * `ade sync relay disable` was actually used — keeps the relay off: + * pre-default-on builds implicitly persisted `enabled: false` on first run, + * so an unmarked false is legacy default state, not a user choice. */ enabled: boolean; /** Per-machine identifier phones dial through the relay (32 hex chars). */ @@ -21,7 +23,10 @@ export type SyncCloudRelayConfig = { relayUrl?: string; }; -type SyncCloudRelayFile = Partial; +type SyncCloudRelayFile = Partial & { + /** True once setEnabled() ran — distinguishes a chosen `false` from legacy. */ + enabledSetByUser?: boolean; +}; /** Default relay base URL: env override wins, else the deployed worker. */ export function defaultRelayUrl(): string { @@ -75,9 +80,13 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { return safeJsonParse(fs.readFileSync(args.filePath, "utf8"), {}); }; - const write = (value: SyncCloudRelayConfig): void => { + const write = (value: SyncCloudRelayConfig, enabledSetByUser: boolean): void => { + const fileValue: SyncCloudRelayFile = { + ...value, + ...(enabledSetByUser ? { enabledSetByUser: true } : {}), + }; // 0o600 at temp-file creation so the identity secret is never world-readable. - writeTextAtomic(args.filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + writeTextAtomic(args.filePath, `${JSON.stringify(fileValue, null, 2)}\n`, { mode: 0o600 }); try { fs.chmodSync(args.filePath, 0o600); } catch { @@ -85,6 +94,10 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { } }; + /** Effective enablement: honor `enabled` only when a user actually set it. */ + const readEnabled = (raw: SyncCloudRelayFile): boolean => + raw.enabledSetByUser === true ? raw.enabled !== false : true; + // First-run identity mint via exclusive create (O_EXCL). If a concurrent // process already minted the identity, adopt the winner's rather than // overwriting it with a divergent machineKey/secret pair. @@ -100,14 +113,14 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { && typeof raw.secret === "string" && raw.secret.length >= 32 ) { return { - enabled: raw.enabled !== false, + enabled: readEnabled(raw), machineKey: raw.machineKey, secret: raw.secret, relayUrl: typeof raw.relayUrl === "string" && raw.relayUrl.trim() ? raw.relayUrl.trim() : undefined, }; } } - write(config); + write(config, false); return config; } }; @@ -123,9 +136,10 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { ? raw.secret : randomBytes(24).toString("hex"); const config: SyncCloudRelayConfig = { - // Missing field → enabled (default-on). Only an explicit false — a - // toggle/CLI choice the user made — keeps the relay off. - enabled: raw.enabled !== false, + // Default-on. `enabled: false` counts only when the user-set marker is + // present: pre-default-on builds wrote an implicit false on first run, + // so an unmarked false migrates to enabled. + enabled: readEnabled(raw), machineKey, secret, relayUrl: typeof raw.relayUrl === "string" && raw.relayUrl.trim() ? raw.relayUrl.trim() : undefined, @@ -133,7 +147,7 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { if (raw.machineKey !== machineKey || raw.secret !== secret) { // Absent file → race-safe first mint; existing-but-repaired → plain write. if (!fs.existsSync(args.filePath)) return mintExclusive(config); - write(config); + write(config, raw.enabledSetByUser === true); } return config; }; @@ -149,7 +163,9 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { setEnabled(enabled: boolean): SyncCloudRelayConfig { const next = { ...load(), enabled }; - write(next); + // A toggle/CLI call is an explicit choice — marked so a chosen `false` + // survives the default-on migration. + write(next, true); return next; }, @@ -164,7 +180,7 @@ export function createSyncCloudRelayStore(args: { filePath: string }) { setRelayUrl(relayUrl: string | null): SyncCloudRelayConfig { const next = { ...load(), relayUrl: relayUrl?.trim() || undefined }; - write(next); + write(next, read().enabledSetByUser === true); return next; }, diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index 92a61ac43..6df63d7fa 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -703,7 +703,14 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { (entry) => entry.kind === "loopback" && entry.host === "127.0.0.1", ); expect(addressCandidates.length).toBeGreaterThan(0); - expect(loopbackCandidateIndex).toBe(addressCandidates.length - 1); + // Loopback is the last DIRECT candidate; only the cloud-relay candidate + // (default-on, strictly lowest priority) may follow it. + expect(loopbackCandidateIndex).toBeGreaterThanOrEqual(0); + expect( + addressCandidates + .slice(loopbackCandidateIndex + 1) + .every((entry) => entry.kind === "relay"), + ).toBe(true); expect( addressCandidates .slice(0, Math.max(loopbackCandidateIndex, 0)) diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 4891c676c..f7cac0ebc 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -37,6 +37,10 @@ struct ADEApp: App { // Clear the badge on every foreground, independent of the sync // throttle below — a lingering count after re-entry reads as stale. Task { await PushNotificationService.shared.clearAppBadge() } + // Defense-in-depth: drain intent commands queued by an extension + // process while the bridge wasn't reachable (cold launch drains via + // register(); this covers warm foregrounds). + Task { await ADEIntentCommandRegistry.drainPendingCommands() } guard didBootstrapSync else { return } let now = Date() guard now.timeIntervalSince(lastActivationSyncAt) > 1.0 else { return } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 67811cc19..dca3290dd 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7607,6 +7607,19 @@ final class SyncService: ObservableObject { deduplicatedStrings(addresses) } + /// Folds a host-advertised relay URL (from `hello_ok` / `brain_status` + /// `cloudRelayWssUrl`) into a profile's saved relay candidates: validated, + /// deduplicated, freshest first, capped at 3. Nil/invalid `advertised` keeps + /// the existing list (filtered), so an older host never wipes saved routes. + func syncMergedRelayCandidates(advertised: String?, existing: [String]?) -> [String] { + let validAdvertised = advertised.flatMap { syncIsFullWebSocketRoute($0) ? $0 : nil } + return Array( + deduplicatedAddresses((validAdvertised.map { [$0] } ?? []) + (existing ?? [])) + .filter(syncIsFullWebSocketRoute) + .prefix(3) + ) + } + private func deduplicatedStrings(_ values: [String]) -> [String] { var seen = Set() return values @@ -8798,16 +8811,9 @@ final class SyncService: ObservableObject { // the relay route even when it never scanned a relay QR. Preserve any // previously-saved relay URLs (the rebuilt profile would otherwise drop // them, since the init defaults `savedRelayCandidates` to nil). - let advertisedRelayUrl = (payload["cloudRelayWssUrl"] as? String).flatMap { url in - syncIsFullWebSocketRoute(url) ? url : nil - } - let mergedRelayCandidates = Array( - deduplicatedAddresses( - (advertisedRelayUrl.map { [$0] } ?? []) - + (activeHostProfile?.savedRelayCandidates ?? []) - ) - .filter(syncIsFullWebSocketRoute) - .prefix(3) + let mergedRelayCandidates = syncMergedRelayCandidates( + advertised: payload["cloudRelayWssUrl"] as? String, + existing: activeHostProfile?.savedRelayCandidates ) let profile = HostConnectionProfile( @@ -9099,9 +9105,8 @@ final class SyncService: ObservableObject { // the moment it goes away. Key absent = older host → leave as-is. if let url = dict["cloudRelayWssUrl"] as? String, syncIsFullWebSocketRoute(url) { updateProfile { profile in - profile.savedRelayCandidates = Array( - deduplicatedAddresses([url] + (profile.savedRelayCandidates ?? [])).prefix(3) - ) + let merged = syncMergedRelayCandidates(advertised: url, existing: profile.savedRelayCandidates) + profile.savedRelayCandidates = merged.isEmpty ? nil : merged } } else if dict["cloudRelayWssUrl"] is NSNull { updateProfile { $0.savedRelayCandidates = nil } diff --git a/apps/ios/ADE/Shared/AttentionActionIntents.swift b/apps/ios/ADE/Shared/AttentionActionIntents.swift index e0c49af95..3f2a44c40 100644 --- a/apps/ios/ADE/Shared/AttentionActionIntents.swift +++ b/apps/ios/ADE/Shared/AttentionActionIntents.swift @@ -108,8 +108,13 @@ public enum ADEIntentCommandRegistry { // MARK: - Session actions +/// Conforms to `LiveActivityIntent` (not plain `AppIntent`): an intent fired +/// from a Live Activity button only runs in the APP process — where the +/// command bridge is registered — under that conformance. As a plain AppIntent +/// it would run in the widget extension, where the bridge is nil and the +/// command would sit in the pending queue until the next app launch. @available(iOS 17.0, *) -public struct ApproveSessionIntent: AppIntent { +public struct ApproveSessionIntent: LiveActivityIntent { public static var title: LocalizedStringResource = "Approve" public static var description = IntentDescription( "Approve the pending action in the current ADE session." @@ -139,8 +144,9 @@ public struct ApproveSessionIntent: AppIntent { } } +/// See `ApproveSessionIntent` — LiveActivityIntent so the deny runs in-app. @available(iOS 17.0, *) -public struct DenySessionIntent: AppIntent { +public struct DenySessionIntent: LiveActivityIntent { public static var title: LocalizedStringResource = "Deny" public static var description = IntentDescription( "Deny the pending action in the current ADE session." From be7d8bd9d05db7e86d7ec4cdae7b87bf5c0a26e2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:33:09 -0400 Subject: [PATCH 04/11] /test: consolidate cloud-relay store tests, parity passes (docs/CLI/iOS) - syncCloudRelayStore tests consolidated into their colocated file (moved store/url/signature describes out of syncTunnelClientService.test.ts, deduped 3 overlapping cases); added marker-persistence-through-setRelayUrl case. - iOS: testSyncMergedRelayCandidatesFoldsAdvertisedFreshestFirst pins the relay-fold contract (dedup, cap 3, freshest first, nil/invalid advertised preserves existing). - Docs: sync README/push-notifications/ios-companion refined for the enabledSetByUser migration, LiveActivityIntent flow + foreground drain, and subset-targeted badge sync; ade-cli README relay lines updated. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 6 +- .../services/sync/syncCloudRelayStore.test.ts | 90 ++++++++++++-- .../sync/syncTunnelClientService.test.ts | 111 +----------------- apps/ios/ADETests/ADETests.swift | 57 +++++++++ docs/features/sync-and-multi-device/README.md | 17 ++- .../sync-and-multi-device/ios-companion.md | 21 +++- .../push-notifications.md | 30 +++-- 7 files changed, 199 insertions(+), 133 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 65cba8a5b..fb63d510d 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -323,9 +323,9 @@ ade --socket browser open http://localhost:5173 --new-tab --text ade --socket update status --text ade --socket update check --text ade --socket update install --text -ade sync relay status --text # cloud relay: wss URL phones dial + on/off state -ade sync relay enable # route phones through the tunnel relay (headless brains have no Settings UI) -ade sync relay disable +ade sync relay status --text # cloud relay: wss URL phones dial + on/off state (on by default) +ade sync relay enable # re-enable after a disable (relay is on by default; headless brains have no Settings UI) +ade sync relay disable # kill-switch: never route sync through the relay ade sync security status --text # machine sync security posture (require-DPoP) ade sync security require-dpop on # reject paired hellos from devices without a Secure Enclave key ade secrets list --text diff --git a/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts b/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts index 4a094b5da..8b2426098 100644 --- a/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts +++ b/apps/ade-cli/src/services/sync/syncCloudRelayStore.test.ts @@ -2,7 +2,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createSyncCloudRelayStore, deriveRelayWssConnectUrl, httpToWsUrl } from "./syncCloudRelayStore"; +import { + buildHostSignatureBase, + buildPipeSignatureBase, + createSyncCloudRelayStore, + defaultRelayUrl, + deriveRelayWssConnectUrl, + httpToWsUrl, + signRelayHmacHex, +} from "./syncCloudRelayStore"; describe("syncCloudRelayStore enablement default", () => { let dir: string; @@ -49,17 +57,81 @@ describe("syncCloudRelayStore enablement default", () => { expect(createSyncCloudRelayStore({ filePath }).isEnabled()).toBe(true); }); - it("keeps the minted identity stable across reloads", () => { - const first = createSyncCloudRelayStore({ filePath }).getMachineIdentity(); - const second = createSyncCloudRelayStore({ filePath }).getMachineIdentity(); - expect(second).toEqual(first); + it("keeps the explicit kill-switch false when other settings rewrite the file", () => { + const store = createSyncCloudRelayStore({ filePath }); + store.setEnabled(false); + store.setRelayUrl("http://127.0.0.1:8787"); + expect(createSyncCloudRelayStore({ filePath }).isEnabled()).toBe(false); + }); + + it("mints a stable identity and persists the file chmod 600", () => { + const store = createSyncCloudRelayStore({ filePath }); + const first = store.getMachineIdentity(); expect(first.machineKey).toMatch(/^[a-f0-9]{32}$/); + expect(first.secret.length).toBe(48); + expect(createSyncCloudRelayStore({ filePath }).getMachineIdentity()).toEqual(first); + if (process.platform !== "win32") { + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + } + }); + + it("toggles enabled and exposes the connect url", () => { + const store = createSyncCloudRelayStore({ filePath }); + expect(store.setEnabled(true).enabled).toBe(true); + expect(store.isEnabled()).toBe(true); + const { machineKey } = store.getMachineIdentity(); + expect(store.getRelayWssUrl()).toBe( + `wss://ade-tunnel-relay.arulsharma1028.workers.dev/connect/${machineKey}`, + ); + }); + + it("honors a relay url override for the connect url", () => { + const store = createSyncCloudRelayStore({ filePath }); + store.setRelayUrl("http://127.0.0.1:8787"); + const { machineKey } = store.getMachineIdentity(); + expect(store.getRelayWssUrl()).toBe(`ws://127.0.0.1:8787/connect/${machineKey}`); + }); +}); + +describe("url derivation", () => { + afterEach(() => { + delete process.env.ADE_TUNNEL_RELAY_URL; }); - it("derives the phone-facing wss connect URL", () => { - expect(httpToWsUrl("https://relay.example")).toBe("wss://relay.example"); - expect(deriveRelayWssConnectUrl("https://relay.example/", "abc123")).toBe( - "wss://relay.example/connect/abc123", + it("swaps http(s) to ws(s) and drops trailing slashes", () => { + expect(httpToWsUrl("https://relay.example.com/")).toBe("wss://relay.example.com"); + expect(httpToWsUrl("http://127.0.0.1:8787")).toBe("ws://127.0.0.1:8787"); + expect(httpToWsUrl("wss://already.ws")).toBe("wss://already.ws"); + expect(httpToWsUrl("relay.example.com")).toBe("wss://relay.example.com"); + }); + + it("builds the phone-facing connect url", () => { + expect(deriveRelayWssConnectUrl("https://relay.example.com", "abc123")).toBe( + "wss://relay.example.com/connect/abc123", + ); + expect(deriveRelayWssConnectUrl("http://127.0.0.1:8787", "deadbeef")).toBe( + "ws://127.0.0.1:8787/connect/deadbeef", ); }); + + it("prefers the env override for the default relay url", () => { + expect(defaultRelayUrl()).toBe("https://ade-tunnel-relay.arulsharma1028.workers.dev"); + process.env.ADE_TUNNEL_RELAY_URL = "http://127.0.0.1:8787"; + expect(defaultRelayUrl()).toBe("http://127.0.0.1:8787"); + }); +}); + +describe("signature builders", () => { + it("use the canonical strings the worker verifies against", () => { + expect(buildHostSignatureBase("key", "1700")).toBe("host:key:1700"); + expect(buildPipeSignatureBase("key", "id0", "1700")).toBe("pipe:key:id0:1700"); + }); + + it("produce a deterministic 64-char hex hmac", () => { + const a = signRelayHmacHex("secret", buildHostSignatureBase("key", "1700")); + const b = signRelayHmacHex("secret", buildHostSignatureBase("key", "1700")); + expect(a).toBe(b); + expect(/^[a-f0-9]{64}$/.test(a)).toBe(true); + expect(signRelayHmacHex("secret", buildHostSignatureBase("key", "1701"))).not.toBe(a); + }); }); diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts index 0b5b7f0d3..5e0dd8d98 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts @@ -1,22 +1,13 @@ -import { afterEach, describe, expect, it } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { describe, expect, it } from "vitest"; import { computeBackoffMs, createSyncTunnelClientService, parseControlMessage, } from "./syncTunnelClientService"; -import { - buildHostSignatureBase, - buildPipeSignatureBase, - createSyncCloudRelayStore, - defaultRelayUrl, - deriveRelayWssConnectUrl, - httpToWsUrl, - signRelayHmacHex, - type SyncCloudRelayStore, -} from "./syncCloudRelayStore"; +import type { SyncCloudRelayStore } from "./syncCloudRelayStore"; + +// syncCloudRelayStore itself (enablement default/migration, identity mint, url +// derivation, signature builders) is covered in syncCloudRelayStore.test.ts. function fakeStore(enabled: boolean): SyncCloudRelayStore { const identity = { machineKey: "a".repeat(32), secret: "b".repeat(48) }; @@ -82,95 +73,3 @@ describe("createSyncTunnelClientService", () => { await service.dispose(); }); }); - - -const tempFiles: string[] = []; - -function tempStorePath(): string { - const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ade-tunnel-")), "cloud-relay.json"); - tempFiles.push(path.dirname(file)); - return file; -} - -afterEach(() => { - while (tempFiles.length) { - fs.rmSync(tempFiles.pop() as string, { recursive: true, force: true }); - } - delete process.env.ADE_TUNNEL_RELAY_URL; -}); - -describe("url derivation", () => { - it("swaps http(s) to ws(s) and drops trailing slashes", () => { - expect(httpToWsUrl("https://relay.example.com/")).toBe("wss://relay.example.com"); - expect(httpToWsUrl("http://127.0.0.1:8787")).toBe("ws://127.0.0.1:8787"); - expect(httpToWsUrl("wss://already.ws")).toBe("wss://already.ws"); - expect(httpToWsUrl("relay.example.com")).toBe("wss://relay.example.com"); - }); - - it("builds the phone-facing connect url", () => { - expect(deriveRelayWssConnectUrl("https://relay.example.com", "abc123")).toBe( - "wss://relay.example.com/connect/abc123", - ); - expect(deriveRelayWssConnectUrl("http://127.0.0.1:8787", "deadbeef")).toBe( - "ws://127.0.0.1:8787/connect/deadbeef", - ); - }); - - it("prefers the env override for the default relay url", () => { - expect(defaultRelayUrl()).toBe("https://ade-tunnel-relay.arulsharma1028.workers.dev"); - process.env.ADE_TUNNEL_RELAY_URL = "http://127.0.0.1:8787"; - expect(defaultRelayUrl()).toBe("http://127.0.0.1:8787"); - }); -}); - -describe("signature builders", () => { - it("use the canonical strings the worker verifies against", () => { - expect(buildHostSignatureBase("key", "1700")).toBe("host:key:1700"); - expect(buildPipeSignatureBase("key", "id0", "1700")).toBe("pipe:key:id0:1700"); - }); - - it("produce a deterministic 64-char hex hmac", () => { - const a = signRelayHmacHex("secret", buildHostSignatureBase("key", "1700")); - const b = signRelayHmacHex("secret", buildHostSignatureBase("key", "1700")); - expect(a).toBe(b); - expect(/^[a-f0-9]{64}$/.test(a)).toBe(true); - expect(signRelayHmacHex("secret", buildHostSignatureBase("key", "1701"))).not.toBe(a); - }); -}); - -describe("createSyncCloudRelayStore", () => { - it("mints a stable identity, defaults to enabled, and persists chmod 600", () => { - const filePath = tempStorePath(); - const store = createSyncCloudRelayStore({ filePath }); - const first = store.getMachineIdentity(); - expect(/^[a-f0-9]{32}$/.test(first.machineKey)).toBe(true); - expect(first.secret.length).toBe(48); - // Relay-everywhere: enabled out of the box; only an explicit kill-switch - // false (covered in syncCloudRelayStore.test.ts) keeps it off. - expect(store.isEnabled()).toBe(true); - - // Re-opening the store keeps the same identity. - const reopened = createSyncCloudRelayStore({ filePath }); - expect(reopened.getMachineIdentity()).toEqual(first); - if (process.platform !== "win32") { - expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); - } - }); - - it("toggles enabled and exposes the connect url", () => { - const store = createSyncCloudRelayStore({ filePath: tempStorePath() }); - expect(store.setEnabled(true).enabled).toBe(true); - expect(store.isEnabled()).toBe(true); - const { machineKey } = store.getMachineIdentity(); - expect(store.getRelayWssUrl()).toBe( - `wss://ade-tunnel-relay.arulsharma1028.workers.dev/connect/${machineKey}`, - ); - }); - - it("honors a relay url override for the connect url", () => { - const store = createSyncCloudRelayStore({ filePath: tempStorePath() }); - store.setRelayUrl("http://127.0.0.1:8787"); - const { machineKey } = store.getMachineIdentity(); - expect(store.getRelayWssUrl()).toBe(`ws://127.0.0.1:8787/connect/${machineKey}`); - }); -}); diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 521e89e1f..a11f1348f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -6013,6 +6013,63 @@ final class ADETests: XCTestCase { XCTAssertNil(state.runs[1].itemId, "runs without an itemId key decode to nil") } + @MainActor + func testSyncMergedRelayCandidatesFoldsAdvertisedFreshestFirst() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + + // A host-advertised relay URL is folded to the FRONT (freshest first) and + // deduped against the existing saved list. + XCTAssertEqual( + service.syncMergedRelayCandidates( + advertised: "wss://relay.ade.dev/connect/new", + existing: ["wss://relay.ade.dev/connect/old", "wss://relay.ade.dev/connect/new"] + ), + ["wss://relay.ade.dev/connect/new", "wss://relay.ade.dev/connect/old"], + "advertised URL leads, duplicate of it is dropped from the tail" + ) + + // Nil advertised keeps the existing list, filtered to real wss routes. + XCTAssertEqual( + service.syncMergedRelayCandidates( + advertised: nil, + existing: ["wss://relay.ade.dev/connect/a", "192.168.1.5:8787"] + ), + ["wss://relay.ade.dev/connect/a"], + "nil advertised preserves existing relay routes, non-wss entries filtered out" + ) + + // A non-wss advertised value is treated as absent — never wipes saved routes. + XCTAssertEqual( + service.syncMergedRelayCandidates( + advertised: "192.168.1.5:8787", + existing: ["wss://relay.ade.dev/connect/a"] + ), + ["wss://relay.ade.dev/connect/a"], + "invalid advertised route is ignored, existing preserved" + ) + + // The merged list is capped at 3, keeping the freshest (advertised + head). + XCTAssertEqual( + service.syncMergedRelayCandidates( + advertised: "wss://relay.ade.dev/connect/z", + existing: [ + "wss://relay.ade.dev/connect/a", + "wss://relay.ade.dev/connect/b", + "wss://relay.ade.dev/connect/c", + ] + ), + [ + "wss://relay.ade.dev/connect/z", + "wss://relay.ade.dev/connect/a", + "wss://relay.ade.dev/connect/b", + ], + "cap at 3 keeps the advertised URL and the two freshest existing routes" + ) + + // Empty inputs yield an empty list (caller stores nil). + XCTAssertTrue(service.syncMergedRelayCandidates(advertised: nil, existing: nil).isEmpty) + } + func testPrActionAvailabilityMatchesDesktopBaseline() { let open = PrActionAvailability(prState: "open") XCTAssertTrue(open.showsMerge) diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 96bfc0506..c0a373995 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -374,9 +374,14 @@ Canonical files (`apps/ade-cli/src/services/sync/`): - `syncCloudRelayStore.ts` — persists the cloud tunnel-relay identity + enablement at `~/.ade/secrets/sync-cloud-relay.json` (lazily-minted 32-hex `machineKey` + HMAC `secret`, chmod `0600`). **Default - `enabled: true`** — a file without the field reads as enabled, while an - explicit `false` (the desktop kill-switch or `ade sync relay disable`) - is preserved. Derives the phone-facing + `enabled: true`** — a file without the field reads as enabled. An + `enabled: false` is honored only when the sibling `enabledSetByUser` + marker is also written; `setEnabled()` (the desktop kill-switch or + `ade sync relay disable`) writes that marker, so a deliberate off + survives upgrades. A pre-default-on build's implicit `enabled: false` + (persisted on first run before relay-everywhere shipped, no marker) + therefore migrates to enabled instead of stranding old machines off + the relay. Derives the phone-facing `wss:///connect/` URL and the canonical host/pipe HMAC signing strings shared with the `apps/tunnel-relay` worker. - `syncTunnelClientService.ts` — the brain-side tunnel client. When the @@ -863,8 +868,10 @@ project scope split. The `machineKey` is an unguessable 32-hex identifier and the tunnel upgrades are HMAC-signed with a per-machine secret. Operators who never want traffic relayed flip the single "ADE relay" control in - Settings > Sync (or `ade sync relay disable`); an explicit off is - preserved across upgrades. The live relay URL is also advertised to + Settings > Sync (or `ade sync relay disable`); a deliberate off is + recorded with an `enabledSetByUser` marker in `sync-cloud-relay.json` + and preserved across upgrades, while a legacy build's implicit + (unmarked) off migrates to on. The live relay URL is also advertised to already-paired phones in `hello_ok` / `brain_status` (`cloudRelayWssUrl`), so devices paired before the relay existed learn the route without re-scanning a QR. diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index e405d3946..5ae887f1f 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -687,9 +687,26 @@ contract) is documented in (runtime-scoped commands the brain forwards to the relay). Every foreground transition re-reports tokens and ends orphaned activities; unpair/forget sends `push.unregisterDevice` and ends local activities. -- **Alert payloads deep-link.** A push carries a top-level `deepLink` - (`ade://session/`, `ade://pr/`) routed through +- **Alert payloads deep-link.** A default tap carries a top-level + `deepLink` (`ade://session/`, `ade://pr/`) routed through `DeepLinkRouter.handleNotificationUserInfo`. +- **Approval alerts are actionable.** `ADEAppDelegate` registers the + `ADE_APPROVAL` notification category so approval pushes (stamped + `aps.category = "ADE_APPROVAL"` plus top-level `sessionId` / `itemId` by + the brain) show inline Approve / Deny. `didReceive response` maps the + `ADE_APPROVE` / `ADE_DENY` action ids to `ADEIntentCommandRegistry` + (`chat.approve`), so approvals resolve from the lock screen without + opening the app. The `waiting_for_approval` Live Activity row carries the + same buttons via `ApproveSessionIntent` / `DenySessionIntent`, which are + `LiveActivityIntent`s (so they run in-app, not the widget extension); a + tap while the app is dead queues in the registry and drains on the next + launch / foreground. +- **App-icon badge.** The brain stamps `aps.badge` = the machine-wide + count of runs awaiting attention on every alert (and a silent badge-only + item when the count changes with no alert). The phone clears the badge + on every foreground transition (`PushNotificationService.clearAppBadge`, + called from `ADEApp`'s scene-phase handler) so a lingering count never + reads as stale. - **Live Activity** mirrors up to three active agent runs on the Lock Screen and Dynamic Island. `LiveActivityService` starts one aggregate activity per machine (`activityId: "agent-runs"`), applies brain-pushed diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index ee6d8bdc8..35722d58d 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -75,11 +75,16 @@ with `decision: accept|decline`), so approvals resolve from the lock screen without opening the app. Every alert also carries `aps.badge` = the machine-wide count of runs in -`waiting_for_*` phases. When that count changes with no alert to ride -(e.g. an approval answered on the Mac), the publisher sends a silent -title-less badge-only item (`dedupeKey: "alert:badge"`, no sound) so the -app icon never shows stale attention. iOS clears the badge on every -foreground. +`waiting_for_*` phases (`countAwaitingAttentionRuns`). When that count +changes, the publisher also emits a silent, title-less badge-only item +(`dedupeKey: "alert:badge"`, no sound) so the icon tracks *drops* too — an +approval answered on the Mac produces no alert but must still lower the +badge. That badge-only item targets every alert-enabled device that is +**not** already carrying an alert in the same flush (a muted session still +needs the fresh count even though its own alert push is skipped); the +relay's content-hash suppression absorbs unchanged resends, and the +relay accepts a title-less item only when it carries a `badge`. iOS +clears the badge on every foreground. On daemon shutdown the publisher makes a best-effort Live Activity `end` (`dismissalDate` now + 60 s, still-active runs re-stamped `stale`), @@ -143,9 +148,18 @@ when all runs reach a terminal phase. subtle `bell.slash` glyph. A muted session still counts toward the badge and Live Activity — only its alert pushes are skipped. - Approve/Deny actions appear on approval alerts (long-press or pull - down) and on `waiting_for_approval` Live Activity rows; both dispatch - through the pending-command registry, so they work even when the app - is not running. + down) and on `waiting_for_approval` Live Activity rows. On the alert, + `ADEAppDelegate` registers the `ADE_APPROVAL` `UNNotificationCategory` + (`ADE_APPROVE` / `ADE_DENY` actions) and its `didReceive response` + routes those action ids to `chat.approve`. On the Live Activity, the + buttons fire `ApproveSessionIntent` / `DenySessionIntent`, which conform + to **`LiveActivityIntent`** (not plain `AppIntent`) precisely so the + intent executes in the *app* process — where the command bridge is + registered — instead of the widget extension where the bridge is nil. + Both paths dispatch through `ADEIntentCommandRegistry`, which queues the + command when the bridge isn't live yet; `register()` drains the queue on + cold launch and every warm foreground also calls `drainPendingCommands()`, + so an approval tapped while the app was dead still lands on the next open. ## What needs a physical device From 30e8fdd1ff7257256e2da8685142cf4104567268 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:49:22 -0400 Subject: [PATCH 05/11] review: require itemId before offering Approve/Deny (LA buttons + notification actions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A waiting_for_approval row from a pre-itemId host would render buttons that dispatch an empty item id — a command that cannot target the pending approval. Gate the Live Activity buttons and the notification-action dispatch on a non-empty itemId; legacy payloads fall back to tap-to-open. Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/App/ADEAppDelegate.swift | 8 ++++++-- apps/ios/ADEWidgets/ADEAgentActivityWidget.swift | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/ios/ADE/App/ADEAppDelegate.swift b/apps/ios/ADE/App/ADEAppDelegate.swift index 251c5c466..a809628cc 100644 --- a/apps/ios/ADE/App/ADEAppDelegate.swift +++ b/apps/ios/ADE/App/ADEAppDelegate.swift @@ -107,14 +107,18 @@ extension ADEAppDelegate: UNUserNotificationCenterDelegate { let sessionId = (userInfo["sessionId"] as? String) ?? "" let itemId = (userInfo["itemId"] as? String) ?? "" + // Both ids are required to target the pending approval — a payload + // missing either (older host, malformed push) falls through to the + // deep-link path so the user lands in the app instead of a command + // that cannot resolve. switch response.actionIdentifier { - case ADEAppDelegate.approveActionIdentifier where !sessionId.isEmpty: + case ADEAppDelegate.approveActionIdentifier where !sessionId.isEmpty && !itemId.isEmpty: await MainActor.run { PushNotificationService.shared.notePushReceived() } await ADEIntentCommandRegistry.dispatch( .approveSession, payload: ["sessionId": sessionId, "itemId": itemId] ) - case ADEAppDelegate.denyActionIdentifier where !sessionId.isEmpty: + case ADEAppDelegate.denyActionIdentifier where !sessionId.isEmpty && !itemId.isEmpty: await MainActor.run { PushNotificationService.shared.notePushReceived() } await ADEIntentCommandRegistry.dispatch( .denySession, diff --git a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift index 76534c2d8..7b3de2bf7 100644 --- a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift +++ b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift @@ -201,10 +201,12 @@ private struct AgentRunRow: View { private var phase: AgentRunPhase { run.resolvedPhase } /// Inline Approve / Deny only earns space on the full lock-screen row for a - /// run actually blocked on approval. The Dynamic Island (`compact`) stays - /// glance-only. + /// run actually blocked on approval — and only when the host supplied the + /// pending `itemId` (older hosts omit it; a button dispatching an empty + /// item id could not target the approval, so the row stays tap-to-open). + /// The Dynamic Island (`compact`) stays glance-only. private var showsApprovalActions: Bool { - !compact && phase == .waitingForApproval + !compact && phase == .waitingForApproval && !(run.itemId ?? "").isEmpty } var body: some View { From 3730d12e80ff0b4b9c16210fc3258f9a30d220ea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:04:58 -0400 Subject: [PATCH 06/11] review: refresh in-memory profile on savedRelayCandidates changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncProfilesEquivalentForPublishedState omitted savedRelayCandidates, so a relay-only updateProfile (brain_status advertisement flip) persisted to disk while activeHostProfile stayed stale — and the next profile write, which starts from the in-memory copy, wrote the old relay list back. Include the field; only the high-churn cursor fields stay excluded. Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/Services/SyncService.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index dca3290dd..b7b02dc85 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7075,6 +7075,13 @@ final class SyncService: ObservableObject { && lhs.savedAddressCandidates == rhs.savedAddressCandidates && lhs.discoveredLanAddresses == rhs.discoveredLanAddresses && lhs.tailscaleAddress == rhs.tailscaleAddress + // Relay routes change rarely (host advertisement flips) but MUST refresh + // the in-memory profile: a relay-only write that skips the refresh leaves + // `activeHostProfile` stale, and the next updateProfile — which starts + // from the in-memory copy — would write the old relay list back to disk. + // Only the high-churn cursor fields (lastRemoteDbVersion, + // remoteDbVersionBySite, updatedAt) stay excluded. + && lhs.savedRelayCandidates == rhs.savedRelayCandidates } private func markSyncActivity(force: Bool = false) { From ef191b049dd44b92b94113801a85f7530f899033 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:20:23 -0400 Subject: [PATCH 07/11] review: quiet hours also suppress the silent badge-only push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Mute pushes on a schedule" covers badge-only APNs sends too — exclude devices currently inside their quiet-hours window from the badge-sync target list. A stale badge self-heals on foreground (cleared) or the first post-window flush. Co-Authored-By: Claude Fable 5 --- .../push/pushPublisherService.test.ts | 26 ++++++++++++++++++- .../src/services/push/pushPublisherService.ts | 5 +++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index aafcd6746..418e0476f 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { createHash, createHmac } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; +import type { PushQuietHours } from "../../../../desktop/src/shared/types/push"; import { createPushRegistrationStore, type PushRegistrationStore } from "./pushRegistrationStore"; import { createPushRelayClient } from "./pushRelayClient"; import { @@ -126,7 +127,7 @@ describe("createPushPublisherService flush", () => { pushToStartToken: "b".repeat(64), bundleId: "com.ade.ios", apsEnvironment: "sandbox" as const, - prefs: { enabled: true, liveActivitiesEnabled: true, mutedSessionIds: [] as string[], quietHours: null }, + prefs: { enabled: true, liveActivitiesEnabled: true, mutedSessionIds: [] as string[], quietHours: null as PushQuietHours | null }, updatedAt: "", }; @@ -298,6 +299,29 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("suppresses the badge-only item for a device inside quiet hours", async () => { + // System time is 12:00 UTC (beforeEach); a 00:00→23:59 UTC window is active. + const quiet = { + ...device, + prefs: { ...device.prefs, quietHours: { start: "00:00", end: "23:59", timezone: "UTC" } }, + }; + const { publisher, publish, emit } = makeHarness(quiet); + await publisher.start(); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + // Alert is quiet-hours-filtered AND no badge-only item may replace it — + // "mute pushes on a schedule" covers silent badge pushes too. Only the + // Live Activity (quiet-hours-exempt) goes out. + expect(publish).toHaveBeenCalledTimes(1); + const payload = publish.mock.calls[0][0]; + expect(payload.notifications).toBeUndefined(); + expect(payload.liveActivity[0].event).toBe("start"); + + publisher.dispose(); + }); + it("sends a silent badge-only item when the awaiting count drops with no alert", async () => { const { publisher, publish, emit } = makeHarness(); await publisher.start(); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 90c9afc07..20731e298 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -559,12 +559,15 @@ export function createPushPublisherService(deps: PushPublisherDeps) { // produce no alert — and devices whose alert was muted still need the new // count. A silent badge-only item targets every alert-enabled device NOT // already covered by an alert in this flush; the relay's content-hash - // suppression absorbs unchanged resends. + // suppression absorbs unchanged resends. Quiet hours block it too — the + // setting promises "no pushes on a schedule", and a stale badge self-heals + // on the next foreground (which clears it) or the first post-window flush. const alertCoveredDeviceIds = new Set(alertItems.flatMap((item) => item.deviceIds ?? [])); const badgeSyncDeviceIds = devices .filter((device) => Boolean(device.apnsToken) && device.prefs.enabled + && !isWithinQuietHours(device.prefs.quietHours, nowMs) && !alertCoveredDeviceIds.has(device.deviceId)) .map((device) => device.deviceId); if (badgeCount !== lastSentBadgeCount && badgeSyncDeviceIds.length > 0) { From bd0a63b6e24c52bfb66ef02a5174d64350e01d9e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:35:52 -0400 Subject: [PATCH 08/11] review: explicit-null relay in hello_ok + per-device badge sync state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildSyncHostHelloOkPayload includes cloudRelayWssUrl: null when the caller supplied it (relay disabled) instead of omitting — the brain fallback handler never sends brain_status, so hello_ok is the only clear signal on that path; iOS clears saved relay routes on the explicit null (absent key still means "older host, keep them"). - Badge sync state is tracked per device instead of one global count: a flush that excludes a device (quiet hours, alert-covered subset) no longer marks its stale badge as synced; entries drop on unregister. Co-Authored-By: Claude Fable 5 --- .../src/services/push/pushPublisherService.ts | 23 ++++++++++---- .../src/services/sync/syncHostService.test.ts | 30 +++++++++++++++++++ .../src/services/sync/syncHostService.ts | 7 ++++- apps/ios/ADE/Services/SyncService.swift | 21 ++++++------- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 20731e298..ca5a24f76 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -317,8 +317,13 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const lastAlertFingerprintByKey = new Map(); let lastLiveActivityFingerprint: string | null = null; let liveActivityStarted = false; - /** Last app-icon badge count committed to the relay (null = never sent). */ - let lastSentBadgeCount: number | null = null; + /** + * Last app-icon badge count delivered per device (absent = never sent). + * Per-device because a flush can legitimately exclude some devices (quiet + * hours, alert-covered subsets) — a global scalar would mark their stale + * badge as already-synced and skip them until the next count change. + */ + const lastSentBadgeByDevice = new Map(); let flushTimer: NodeJS.Timeout | null = null; let flushFireAt = 0; @@ -568,9 +573,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { Boolean(device.apnsToken) && device.prefs.enabled && !isWithinQuietHours(device.prefs.quietHours, nowMs) - && !alertCoveredDeviceIds.has(device.deviceId)) + && !alertCoveredDeviceIds.has(device.deviceId) + && lastSentBadgeByDevice.get(device.deviceId) !== badgeCount) .map((device) => device.deviceId); - if (badgeCount !== lastSentBadgeCount && badgeSyncDeviceIds.length > 0) { + if (badgeSyncDeviceIds.length > 0) { alertItems.push({ deviceIds: badgeSyncDeviceIds, title: "", @@ -609,7 +615,13 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return; } for (const [key, fingerprint] of alertCommits) lastAlertFingerprintByKey.set(key, fingerprint); - if (alertItems.length > 0) lastSentBadgeCount = badgeCount; + // Every alert item (including the badge-only sync) carries this flush's + // badge count — commit it for exactly the devices that were targeted. + for (const item of alertItems) { + for (const deviceId of item.deviceIds ?? []) { + lastSentBadgeByDevice.set(deviceId, badgeCount); + } + } if (laPlan) { // Commit the Live Activity plan only if its own targets landed. A mixed // publish can report delivered>0 from the alert while the LA push-to-start @@ -963,6 +975,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { logWarn("push.unregister_failed", error); } deps.store.removeDevice(deviceId); + lastSentBadgeByDevice.delete(deviceId); }, setPrefs(deviceId: string, prefs: PushNotificationPrefs): boolean { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index b4232c63f..8c998fca3 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -253,6 +253,36 @@ describe("buildSyncHostHelloOkPayload", () => { }); expect(payload.cloudRelayWssUrl).toBe("wss://relay.example/connect/abc123"); }); + + it("sends an explicit null when the relay is disabled so clients drop saved routes", () => { + const metadata = { + deviceId: "d", + deviceName: "n", + platform: "iOS", + deviceType: "phone", + siteId: "s", + dbVersion: 0, + } satisfies SyncPeerMetadata; + const payload = buildSyncHostHelloOkPayload({ + peer: metadata, + brain: metadata, + serverDbVersion: 0, + heartbeatIntervalMs: 30_000, + pollIntervalMs: 400, + projectCatalog: { projects: [] }, + projectCatalogEnabled: false, + projectActionsEnabled: false, + crossProjectChatEnabled: false, + remoteCommandSupportedActions: [], + remoteCommandDescriptors: [], + localCommandDescriptors: [], + cloudRelayWssUrl: null, + }); + // Present-with-null ≠ absent: absent means "older host, keep saved relay + // routes"; null means "kill-switch off, clear them". + expect("cloudRelayWssUrl" in payload).toBe(true); + expect(payload.cloudRelayWssUrl).toBeNull(); + }); }); describe("buildSyncProjectCatalogMessages", () => { diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index d2cd5c356..66b7ee2a5 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -890,7 +890,12 @@ export function buildSyncHostHelloOkPayload(args: { heartbeatIntervalMs: args.heartbeatIntervalMs, pollIntervalMs: args.pollIntervalMs, projects: args.projectCatalog.projects, - ...(args.cloudRelayWssUrl ? { cloudRelayWssUrl: args.cloudRelayWssUrl } : {}), + // Explicit null (relay disabled) must reach the wire: clients treat an + // ABSENT key as "older host — keep saved relay routes", and the brain + // fallback handler never sends brain_status, so hello_ok is the only + // clear signal on that path. Omit only when the caller didn't supply + // the argument at all. + ...(args.cloudRelayWssUrl !== undefined ? { cloudRelayWssUrl: args.cloudRelayWssUrl } : {}), features: { fileAccess: true, terminalStreaming: true, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b7b02dc85..3e16751a8 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -8812,16 +8812,17 @@ final class SyncService: ObservableObject { let discoveredLan = deduplicatedAddresses( matchingDiscovery?.addresses ?? activeHostProfile?.discoveredLanAddresses ?? [] ) - // The host advertises its live cloud-relay URL in `hello_ok` (key omitted - // when the relay is disabled or on older hosts). Fold it into the saved - // relay candidates — freshest first — so an already-paired phone learns - // the relay route even when it never scanned a relay QR. Preserve any - // previously-saved relay URLs (the rebuilt profile would otherwise drop - // them, since the init defaults `savedRelayCandidates` to nil). - let mergedRelayCandidates = syncMergedRelayCandidates( - advertised: payload["cloudRelayWssUrl"] as? String, - existing: activeHostProfile?.savedRelayCandidates - ) + // The host advertises its live cloud-relay URL in `hello_ok`: a wss route + // when the relay is up, JSON null when the kill-switch is off (the brain + // fallback handler never sends brain_status, so this is the only clear + // signal on that path), and no key at all on older hosts. Fold a route in + // freshest-first; clear on explicit null; keep saved routes when absent. + let mergedRelayCandidates: [String] = payload["cloudRelayWssUrl"] is NSNull + ? [] + : syncMergedRelayCandidates( + advertised: payload["cloudRelayWssUrl"] as? String, + existing: activeHostProfile?.savedRelayCandidates + ) let profile = HostConnectionProfile( hostIdentity: remoteHostIdentity ?? activeHostProfile?.hostIdentity ?? expectedHostIdentity, From 5ce9a69836447d44c5387ca7bf119cf0d653e581 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:55:34 -0400 Subject: [PATCH 09/11] review: target-aware badge delivery, shared tunnel client, mute state through the menu model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Badge-only items drop the relay dedupeKey (its suppression hash ignores deviceIds, so a shared key starved other devices of the same count); the publisher's per-device map is the sole dedupe. - lastSentBadgeByDevice commits from the relay's per-device alert outcomes — a transiently-failed target stays unsynced and retries on the next flush; legacy outcome-less responses commit all targeted. - Tunnel client is a machine-level shared singleton keyed by the relay config file: per-scope instances re-registered the same machineKey on every project open and churned the relay connection. Scope dispose no longer stops it; the daemon shutdown path does. - iOS: mute state rides WorkChatHeaderMenuModel (equatable menu re-renders on flips from anywhere) and both Work surfaces observe PushNotificationService so glyph/menu labels stay live. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 31 +++++++----- apps/ade-cli/src/cli.ts | 8 ++++ .../push/pushPublisherService.test.ts | 8 ++-- .../src/services/push/pushPublisherService.ts | 47 ++++++++++++++++--- .../services/sync/syncTunnelClientService.ts | 25 ++++++++++ .../Work/WorkChatHeaderAndMessageViews.swift | 13 +++-- .../ADE/Views/Work/WorkRootComponents.swift | 17 +++---- .../Work/WorkSessionDestinationView.swift | 6 ++- 8 files changed, 119 insertions(+), 36 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 20c2f11db..fdb68348c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1228,18 +1228,21 @@ export async function createAdeRuntime(args: { // The store instance is shared with the sync service so the relay candidate // in pairingConnectInfo and the tunnel client always agree on one config file. const { createSyncCloudRelayStore } = await import("./services/sync/syncCloudRelayStore"); - const { createSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService"); - const cloudRelayStore = createSyncCloudRelayStore({ - filePath: path.join( - resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir, - "sync-cloud-relay.json", - ), - }); - const syncTunnelClientService = createSyncTunnelClientService({ - logger, - configStore: cloudRelayStore, - getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null, - }); + const { createSyncTunnelClientService, getSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService"); + const cloudRelayFilePath = path.join( + resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir, + "sync-cloud-relay.json", + ); + const cloudRelayStore = createSyncCloudRelayStore({ filePath: cloudRelayFilePath }); + // ONE tunnel client per machine (keyed by the config file): per-scope + // instances would re-register the same machineKey with the relay on every + // project open and churn the connection paired phones dial through. + const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () => + createSyncTunnelClientService({ + logger, + configStore: cloudRelayStore, + getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null, + })); if (cloudRelayStore.isEnabled()) { void syncTunnelClientService.start().catch((error) => { logger.warn("sync.tunnel_start_failed", { @@ -1394,7 +1397,9 @@ export async function createAdeRuntime(args: { swallow(() => prPollingService.dispose()); // Detach only this scope's signals; the shared publisher outlives the scope. swallow(() => detachPushSources()); - void syncTunnelClientService.dispose().catch(() => {}); + // The tunnel client is machine-level and shared across scopes — closing + // one project must not sever the relay for the others. The daemon's + // shutdown path (disposeServeResources) stops it. swallow(() => automationIngressService?.dispose()); swallow(() => automationService?.dispose()); swallow(() => usageTrackingService.dispose()); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 02af998f3..787c90d48 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -13571,6 +13571,14 @@ async function runServe( } catch { // Shutdown must never hang or fail on push cleanup. } + try { + const { peekSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService"); + await peekSharedSyncTunnelClientService( + path.join(layout.secretsDir, "sync-cloud-relay.json"), + )?.dispose(); + } catch { + // Best-effort tunnel teardown; the process exit closes sockets anyway. + } await scopeRegistry.disposeAll(); if (sharedSyncListener) { await sharedSyncListener.close().catch(() => {}); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 418e0476f..88d27b492 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -288,7 +288,7 @@ describe("createPushPublisherService flush", () => { const payload = publish.mock.calls[0][0]; const alertItem = payload.notifications.find((item: { title: string }) => item.title.length > 0); - const badgeItem = payload.notifications.find((item: { dedupeKey?: string }) => item.dedupeKey === "alert:badge"); + const badgeItem = payload.notifications.find((item: { title: string; badge?: number | null }) => item.title === "" && item.badge != null); // The audible alert reaches only the unmuted device... expect(alertItem.deviceIds).toEqual(["dev-1"]); expect(alertItem.badge).toBe(1); @@ -338,7 +338,7 @@ describe("createPushPublisherService flush", () => { await vi.advanceTimersByTimeAsync(2_500); const lastPayload = publish.mock.calls.at(-1)?.[0]; const badgeItem = (lastPayload.notifications ?? []).find( - (item: { dedupeKey?: string }) => item.dedupeKey === "alert:badge", + (item: { title: string; badge?: number | null }) => item.title === "" && item.badge != null, ); expect(badgeItem).toMatchObject({ title: "", badge: 0, sound: null }); @@ -390,7 +390,9 @@ describe("createPushPublisherService flush", () => { expect(payload.notifications[0].title).toBe(""); expect(payload.notifications[0].badge).toBe(1); expect(payload.notifications[0].sound).toBeNull(); - expect(payload.notifications[0].dedupeKey).toBe("alert:badge"); + // No relay dedupeKey: the suppression hash ignores deviceIds, so a shared + // key would starve other devices of the same count. + expect(payload.notifications[0].dedupeKey).toBeUndefined(); expect(payload.liveActivity[0].event).toBe("start"); publisher.dispose(); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index ca5a24f76..0546617ee 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -31,7 +31,6 @@ export function resolvePushRelayStateFile(secretsDir: string): string { } /** UNNotificationCategory id iOS binds Approve/Deny actions to. */ export const APPROVAL_NOTIFICATION_CATEGORY = "ADE_APPROVAL"; -const BADGE_DEDUPE_KEY = "alert:badge"; const SHUTDOWN_PUBLISH_TIMEOUT_MS = 2_500; const AGENT_RUNS_MAX = 3; const DETAIL_MAX_CHARS = 160; @@ -271,6 +270,26 @@ function readOutcomeCount(result: Record | null | undefined, ke type RelayLiveActivityOutcome = { delivered: boolean; suppressed: boolean; skipped: boolean }; +type RelayAlertOutcome = { deviceId: string; delivered: boolean; suppressed: boolean }; + +/** + * The relay's per-target `outcomes[]` entries filtered to alert items, keyed + * by device. Null when the relay response has no outcomes array (legacy/mock + * shape) — callers treat that as "no per-device verdicts", not "nothing landed". + */ +function readAlertOutcomes(result: Record | null | undefined): RelayAlertOutcome[] | null { + const raw = result?.outcomes; + if (!Array.isArray(raw)) return null; + return raw + .filter((entry): entry is Record => Boolean(entry && typeof entry === "object")) + .filter((entry) => entry.kind === "alert" && typeof entry.deviceId === "string") + .map((entry) => ({ + deviceId: entry.deviceId as string, + delivered: entry.delivered === true, + suppressed: entry.suppressed === true, + })); +} + /** The relay's per-target `outcomes[]` entries filtered to the Live Activity item. */ function readLiveActivityOutcomes(result: Record | null | undefined): RelayLiveActivityOutcome[] | null { const raw = result?.outcomes; @@ -577,11 +596,14 @@ export function createPushPublisherService(deps: PushPublisherDeps) { && lastSentBadgeByDevice.get(device.deviceId) !== badgeCount) .map((device) => device.deviceId); if (badgeSyncDeviceIds.length > 0) { + // No relay dedupeKey here: the relay's suppression hash ignores + // deviceIds, so a shared key would suppress the same count for a device + // that never received it (fresh registration, quiet hours just ended). + // The per-device map above is the only dedupe this item needs. alertItems.push({ deviceIds: badgeSyncDeviceIds, title: "", sound: null, - dedupeKey: BADGE_DEDUPE_KEY, phase: "terminal", badge: badgeCount, }); @@ -616,10 +638,23 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } for (const [key, fingerprint] of alertCommits) lastAlertFingerprintByKey.set(key, fingerprint); // Every alert item (including the badge-only sync) carries this flush's - // badge count — commit it for exactly the devices that were targeted. - for (const item of alertItems) { - for (const deviceId of item.deviceIds ?? []) { - lastSentBadgeByDevice.set(deviceId, badgeCount); + // badge count. Commit it per device from the relay's outcomes: a device + // whose send failed transiently must stay unsynced so the next flush + // retries its badge. Suppressed counts as synced (identical content was + // already delivered); a legacy/mock response without outcomes commits + // all targeted devices, mirroring the Live Activity commit rule. + const alertOutcomes = readAlertOutcomes(result); + if (alertOutcomes == null) { + for (const item of alertItems) { + for (const deviceId of item.deviceIds ?? []) { + lastSentBadgeByDevice.set(deviceId, badgeCount); + } + } + } else { + for (const outcome of alertOutcomes) { + if (outcome.delivered || outcome.suppressed) { + lastSentBadgeByDevice.set(outcome.deviceId, badgeCount); + } } } if (laPlan) { diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts index 45c7cc411..b579b98a3 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts @@ -312,6 +312,31 @@ function forward(target: WebSocket, data: RawData, isBinary: boolean): void { /** Deadline for the claim POST and every socket to reach OPEN. */ export const CONNECT_DEADLINE_MS = 15_000; +const sharedTunnelClients = new Map(); + +/** + * Machine-level singleton keyed by the relay config file path. Every project + * scope in the multi-project daemon shares ONE tunnel client — a per-scope + * instance would re-register the same machineKey with the relay on every + * project open and churn the host connection paired phones dial through. + */ +export function getSharedSyncTunnelClientService( + key: string, + make: () => SyncTunnelClientService, +): SyncTunnelClientService { + let existing = sharedTunnelClients.get(key); + if (!existing) { + existing = make(); + sharedTunnelClients.set(key, existing); + } + return existing; +} + +/** Shutdown-path lookup: never mints a client just to stop it. */ +export function peekSharedSyncTunnelClientService(key: string): SyncTunnelClientService | undefined { + return sharedTunnelClients.get(key); +} + /** * Terminates a socket that has not opened within the deadline so a stalled * relay or dead local sync port surfaces as an error + reconnect instead of a diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 634cdb4c4..7070ff5cf 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -93,10 +93,14 @@ struct WorkChatHeaderMenuModel: Equatable { var sessionPinned: Bool var sessionIdCopied: Bool var sessionDeepLinkCopied: Bool - /// Open chat's session id, used by the Mute item to read / toggle push prefs. + /// Open chat's session id, used by the Mute item to toggle push prefs. /// Defaults to empty so the memberwise init at the call site stays source /// compatible; the destination view passes the real id. var sessionId: String = "" + /// Mute state rides the model (not a direct singleton read in the menu body) + /// so the `.equatable()` gate re-renders the menu when it flips — including + /// from the Work-list row's context menu while this chat is open. + var sessionMuted: Bool = false } /// Chat header overflow menu, extracted from `WorkSessionDestinationView` and @@ -252,12 +256,11 @@ struct WorkChatHeaderMenu: View, Equatable { } if !model.sessionId.isEmpty { - let muted = PushNotificationService.shared.prefs.mutedSessionIds.contains(model.sessionId) Button { - PushNotificationService.shared.setMuted(!muted, sessionId: model.sessionId) + PushNotificationService.shared.setMuted(!model.sessionMuted, sessionId: model.sessionId) } label: { - Label(muted ? "Unmute notifications" : "Mute notifications", - systemImage: muted ? "bell" : "bell.slash") + Label(model.sessionMuted ? "Unmute notifications" : "Mute notifications", + systemImage: model.sessionMuted ? "bell" : "bell.slash") } } } diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index ec23a50b1..f25e7a67b 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -502,12 +502,14 @@ struct WorkSessionListRow: View { /// Defaults to a no-op so preview harnesses don't have to wire it. var onOpenPullRequest: (TerminalSessionSummary, LanePrTag) -> Void = { _, _ in } - /// Read straight from the shared prefs — mute only applies to chat sessions. - /// Context menus rebuild each open and `WorkSessionRow` re-evaluates whenever - /// the parent list does, so a direct read is enough to keep both in step. + /// Observed so the muted glyph and menu label re-render the moment a mute + /// flips anywhere (this menu, the open chat's header menu, settings). + @ObservedObject private var pushNotificationService = PushNotificationService.shared + + /// Mute only applies to chat sessions. private var isMuted: Bool { isChatSession(session) - && PushNotificationService.shared.prefs.mutedSessionIds.contains(session.id) + && pushNotificationService.prefs.mutedSessionIds.contains(session.id) } var body: some View { @@ -617,12 +619,11 @@ struct WorkSessionListRow: View { systemImage: session.pinned ? "pin.slash" : "pin") } if isChatSession(session) { - let muted = PushNotificationService.shared.prefs.mutedSessionIds.contains(session.id) Button { - PushNotificationService.shared.setMuted(!muted, sessionId: session.id) + PushNotificationService.shared.setMuted(!isMuted, sessionId: session.id) } label: { - Label(muted ? "Unmute notifications" : "Mute notifications", - systemImage: muted ? "bell" : "bell.slash") + Label(isMuted ? "Unmute notifications" : "Mute notifications", + systemImage: isMuted ? "bell" : "bell.slash") } } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 9cdf9022a..d33712ce3 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -293,6 +293,9 @@ private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? struct WorkSessionDestinationView: View { @EnvironmentObject var syncService: SyncService + /// Observed so a mute toggled anywhere (Work-list row menu, settings) flows + /// into `headerMenuModel` and re-renders the equatable-gated header menu. + @ObservedObject private var pushNotificationService = PushNotificationService.shared @Environment(\.dismiss) var dismiss let sessionId: String @@ -650,7 +653,8 @@ struct WorkSessionDestinationView: View { sessionPinned: session.pinned, sessionIdCopied: sessionIdCopied, sessionDeepLinkCopied: sessionDeepLinkCopied, - sessionId: session.id + sessionId: session.id, + sessionMuted: pushNotificationService.prefs.mutedSessionIds.contains(session.id) ) } From bce629a7f914c7ea25cb25def07768d3dac63ed3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:12:37 -0400 Subject: [PATCH 10/11] review: gate relay tunnel to the sync-hosting runtime; single relay attempts on reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only the runtime that owns the brain-level shared sync listener may register the relay tunnel: with default-on, a headless one-shot CLI runtime would otherwise claim the DO's single host socket (last-wins), stealing the relay from ade serve and failing every phone /connect. The enable-toggle handler gets the same gate. - iOS reconnect keeps relay wss URLs out of the TCP probe race and the port sweep — crossing one with the fallback port list retried the identical URL once per port. Each relay route is one attempt, strictly after the whole direct address x port sweep; relay-only profiles still connect. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 11 ++++++++++- apps/ios/ADE/Services/SyncService.swift | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index fdb68348c..6f7708429 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1243,7 +1243,13 @@ export async function createAdeRuntime(args: { configStore: cloudRelayStore, getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null, })); - if (cloudRelayStore.isEnabled()) { + // Only the runtime that actually hosts phone sync (owns the brain-level + // shared listener) may register the relay tunnel. The relay DO keeps ONE + // host socket per machineKey (last wins), so a headless one-shot CLI + // runtime or embedded fallback starting the tunnel would steal the relay + // from `ade serve` and then fail every phone /connect (no sync port). + const canHostRelayTunnel = resolvedArgs.syncRuntime?.sharedSyncListener != null; + if (canHostRelayTunnel && cloudRelayStore.isEnabled()) { void syncTunnelClientService.start().catch((error) => { logger.warn("sync.tunnel_start_failed", { error: error instanceof Error ? error.message : String(error), @@ -1297,6 +1303,9 @@ export async function createAdeRuntime(args: { getModelPickerStore: () => getSharedModelPickerStore(db), cloudRelayStore, onCloudRelayEnabledChanged: (enabled) => { + // Same gate as startup: only the sync-hosting runtime may register + // the relay tunnel (see canHostRelayTunnel above). + if (enabled && !canHostRelayTunnel) return; const action = enabled ? syncTunnelClientService.start() : syncTunnelClientService.stop(); void action.catch((error) => { logger.warn("sync.tunnel_toggle_failed", { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 3e16751a8..2de15823e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7925,16 +7925,21 @@ final class SyncService: ObservableObject { let rawAddresses = preferLiveCandidatesOnly ? automaticReconnectAddresses(for: profile) : prioritizedAddresses(for: profile) - let addresses = connectableAddresses(from: rawAddresses) + // Relay routes (full wss:// URLs) carry their own path and port: keep them + // out of the TCP probe race and the port sweep — crossing one with the + // fallback port list would retry the identical URL once per port. Each is + // a single attempt, strictly after every direct address × port pair. + let relayRoutes = rawAddresses.filter(syncIsFullWebSocketRoute) + let addresses = connectableAddresses(from: rawAddresses.filter { !syncIsFullWebSocketRoute($0) }) let portCandidates = syncConnectPortCandidates( primaryPort: primaryPort, addresses: addresses, allowFallbackSweep: !preferLiveCandidatesOnly || livePorts.isEmpty ) syncConnectLog.info( - "ADE_SYNC_TRACE reconnect candidates preferLiveOnly=\(preferLiveCandidatesOnly) path=\(syncLogPathSummary(self.lastNetworkPathSnapshot), privacy: .public) profile=\(syncLogProfileSummary(profile), privacy: .public) raw=[\(syncLogAddressList(rawAddresses), privacy: .public)] ports=[\(portCandidates.map(String.init).joined(separator: ","), privacy: .public)] connectable=[\(syncLogAddressList(addresses), privacy: .public)]" + "ADE_SYNC_TRACE reconnect candidates preferLiveOnly=\(preferLiveCandidatesOnly) path=\(syncLogPathSummary(self.lastNetworkPathSnapshot), privacy: .public) profile=\(syncLogProfileSummary(profile), privacy: .public) raw=[\(syncLogAddressList(rawAddresses), privacy: .public)] ports=[\(portCandidates.map(String.init).joined(separator: ","), privacy: .public)] connectable=[\(syncLogAddressList(addresses), privacy: .public)] relay=[\(syncLogAddressList(relayRoutes), privacy: .public)]" ) - guard !addresses.isEmpty else { + guard !addresses.isEmpty || !relayRoutes.isEmpty else { if rawAddresses.isEmpty { throw NSError(domain: "ADE", code: 18, userInfo: [NSLocalizedDescriptionKey: "No saved address is available for this machine."]) } @@ -7951,9 +7956,14 @@ final class SyncService: ObservableObject { ) } - let endpointAttempts = preferLiveCandidatesOnly && livePorts.isEmpty && portCandidates.count > 1 + let directAttempts = preferLiveCandidatesOnly && livePorts.isEmpty && portCandidates.count > 1 ? syncStalePortRecoveryEndpointAttempts(addresses: racedAddresses, ports: portCandidates) : syncConnectionEndpointAttempts(addresses: racedAddresses, ports: portCandidates) + // Relay routes: one attempt each, after the whole direct sweep. openSocket + // uses the full URL verbatim; the port field is informational. + let endpointAttempts = directAttempts + relayRoutes.map { route in + SyncConnectionEndpointAttempt(address: route, port: portCandidates.first ?? profile.port) + } for attempt in endpointAttempts { guard isCurrentConnectAttempt(connectAttemptGeneration) else { From d47dab6a9228cf9bb1ab64df6ff3c93537dd7a5b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:28:02 -0400 Subject: [PATCH 11/11] review: itemId in alert dedupe fingerprint; bounded reconnect before intent dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A new approval with identical rendered copy but a different itemId now re-publishes (fingerprint includes itemId/category) so the phone's Approve/Deny never targets an already-resolved item. - Notification actions / LA buttons can background-launch the app before the sync socket is up and chat.approve is not queueable — the intent bridge now treats the tap as a user-initiated reconnect and waits up to ~5s for the connection before sending. Co-Authored-By: Claude Fable 5 --- .../src/services/push/pushPublisherService.ts | 5 +++++ apps/ios/ADE/App/ADEApp.swift | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 0546617ee..1391f8f4e 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -555,11 +555,16 @@ export function createPushPublisherService(deps: PushPublisherDeps) { .map((device) => device.deviceId); if (eligibleIds.length === 0) continue; const copy = alert.render(); + // itemId is part of the fingerprint: a new approval with identical + // rendered copy must still publish, or the phone's Approve/Deny action + // keeps targeting the previous (already-resolved) item. const fingerprint = JSON.stringify({ title: copy.title, body: copy.body, deepLink: alert.deepLink ?? null, phase: alert.phase, + itemId: alert.itemId ?? null, + category: alert.category ?? null, }); if (lastAlertFingerprintByKey.get(alert.dedupeKey) === fingerprint) continue; alertCommits.push([alert.dedupeKey, fingerprint]); diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index f7cac0ebc..378fc7659 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -95,6 +95,17 @@ private final class ADESyncIntentBridge: ADEIntentCommandBridge { case .restartSession: mapped = .restartSession case .retryPrChecks: mapped = .retryPrChecks } - await SyncService.shared?.sendRemoteCommand(mapped, payload: payload) + guard let sync = SyncService.shared else { return } + // A notification action or Live Activity button can background-launch the + // app before the sync socket is up; these commands are not queueable + // (approving a stale item later would be wrong), so give the socket a + // bounded chance to connect — the tap IS a user-initiated reconnect. + if sync.connectionState != .connected { + await sync.reconnectIfPossible(userInitiated: true) + for _ in 0..<20 where sync.connectionState != .connected { + try? await Task.sleep(nanoseconds: 250_000_000) + } + } + await sync.sendRemoteCommand(mapped, payload: payload) } }