From 122d06bdf33dc531017780a83ea95bbcb7362ade Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:38:49 -0400 Subject: [PATCH 1/7] Remove mobile push support --- .ade/ade.yaml | 7 - CHANGELOG.md | 2 +- apps/ade-cli/src/bootstrap.ts | 42 - .../services/sync/deviceRegistryService.ts | 177 ---- .../src/services/sync/syncHostService.ts | 247 ------ apps/ade-cli/src/services/sync/syncService.ts | 8 - apps/desktop/apps/ios/TestFixtures/README.md | 17 - .../ios/TestFixtures/chat-awaiting-input.json | 18 - .../apps/ios/TestFixtures/chat-failed.json | 16 - .../apps/ios/TestFixtures/pr-ci-failing.json | 16 - .../apps/ios/TestFixtures/pr-merge-ready.json | 16 - .../ios/TestFixtures/pr-review-requested.json | 16 - apps/desktop/src/main/main.ts | 125 +-- .../main/services/adeActions/registry.test.ts | 75 -- .../src/main/services/adeActions/registry.ts | 50 -- .../config/projectConfigService.test.ts | 64 -- .../services/config/projectConfigService.ts | 64 +- .../src/main/services/ipc/registerIpc.ts | 558 ------------- .../notifications/apnsBridgeService.test.ts | 526 ------------ .../notifications/apnsBridgeService.ts | 460 ----------- .../notifications/apnsService.test.ts | 204 ----- .../services/notifications/apnsService.ts | 463 ----------- .../notificationEventBus.test.ts | 332 -------- .../notifications/notificationEventBus.ts | 371 --------- .../notifications/notificationMapper.test.ts | 296 ------- .../notifications/notificationMapper.ts | 573 ------------- .../src/main/services/prs/prPollingService.ts | 25 - .../sync/deviceRegistryService.test.ts | 76 -- apps/desktop/src/preload/global.d.ts | 20 - apps/desktop/src/preload/preload.test.ts | 60 -- apps/desktop/src/preload/preload.ts | 35 - apps/desktop/src/renderer/browserMock.ts | 19 - .../renderer/components/app/SettingsPage.tsx | 13 +- .../components/app/settingsSections.ts | 9 +- .../components/settings/MobilePushPanel.tsx | 755 ------------------ .../tours/settingsHighlightsTour.ts | 2 +- apps/desktop/src/shared/ipc.ts | 5 - apps/desktop/src/shared/types/config.ts | 29 - apps/desktop/src/shared/types/sync.ts | 255 ------ apps/ios/ADE.xcodeproj/project.pbxproj | 186 ----- apps/ios/ADE/ADE.entitlements | 4 - apps/ios/ADE/App/ADEApp.swift | 2 - apps/ios/ADE/App/AppDelegate.swift | 181 ----- apps/ios/ADE/App/NotificationCategories.swift | 186 ----- apps/ios/ADE/Info.plist | 1 - .../ADE/Models/NotificationPreferences.swift | 110 --- .../Services/LiveActivityCoordinator.swift | 61 +- apps/ios/ADE/Services/SyncService.swift | 235 +----- apps/ios/ADE/Shared/ADESharedContainer.swift | 4 +- apps/ios/ADE/Shared/ADESharedModels.swift | 3 +- apps/ios/ADE/Shared/ADESharedTheme.swift | 4 +- .../Shared/LiveActivityIntentsForward.swift | 118 +-- apps/ios/ADE/Shared/WidgetAppIntents.swift | 9 +- .../AttentionDrawerModel.swift | 2 +- .../Settings/ConnectionSettingsView.swift | 10 - .../Settings/NotificationsCenterView.swift | 581 -------------- .../Settings/PerSessionOverrideView.swift | 219 ----- .../Views/Settings/QuietHoursEditorView.swift | 101 --- .../SettingsNotificationsSection.swift | 130 --- .../ADENotificationService.entitlements | 10 - apps/ios/ADENotificationService/Info.plist | 31 - .../NotificationService.swift | 72 -- apps/ios/ADETests/ADETests.swift | 77 +- apps/ios/ADEWidgets/ADEControlWidget.swift | 60 -- apps/ios/ADEWidgets/ADEWidgetBundle.swift | 1 - apps/ios/ExportOptions.plist | 2 - apps/web/src/app/pages/PrivacyPage.tsx | 6 +- architecture.mdx | 2 +- changelog/v1.0.19.mdx | 11 +- changelog/v1.1.5.mdx | 1 - docs/ARCHITECTURE.md | 4 +- .../onboarding-and-settings/README.md | 7 +- docs/features/sync-and-multi-device/README.md | 43 +- .../sync-and-multi-device/ios-companion.md | 183 +---- docs/perf/ios-mobile-action-inventory.md | 6 - index.mdx | 2 +- key-concepts.mdx | 2 +- plans/tui-parity-roadmap.md | 6 +- 78 files changed, 87 insertions(+), 8632 deletions(-) delete mode 100644 apps/desktop/apps/ios/TestFixtures/README.md delete mode 100644 apps/desktop/apps/ios/TestFixtures/chat-awaiting-input.json delete mode 100644 apps/desktop/apps/ios/TestFixtures/chat-failed.json delete mode 100644 apps/desktop/apps/ios/TestFixtures/pr-ci-failing.json delete mode 100644 apps/desktop/apps/ios/TestFixtures/pr-merge-ready.json delete mode 100644 apps/desktop/apps/ios/TestFixtures/pr-review-requested.json delete mode 100644 apps/desktop/src/main/services/notifications/apnsBridgeService.test.ts delete mode 100644 apps/desktop/src/main/services/notifications/apnsBridgeService.ts delete mode 100644 apps/desktop/src/main/services/notifications/apnsService.test.ts delete mode 100644 apps/desktop/src/main/services/notifications/apnsService.ts delete mode 100644 apps/desktop/src/main/services/notifications/notificationEventBus.test.ts delete mode 100644 apps/desktop/src/main/services/notifications/notificationEventBus.ts delete mode 100644 apps/desktop/src/main/services/notifications/notificationMapper.test.ts delete mode 100644 apps/desktop/src/main/services/notifications/notificationMapper.ts delete mode 100644 apps/desktop/src/renderer/components/settings/MobilePushPanel.tsx delete mode 100644 apps/ios/ADE/App/AppDelegate.swift delete mode 100644 apps/ios/ADE/App/NotificationCategories.swift delete mode 100644 apps/ios/ADE/Models/NotificationPreferences.swift delete mode 100644 apps/ios/ADE/Views/Settings/NotificationsCenterView.swift delete mode 100644 apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift delete mode 100644 apps/ios/ADE/Views/Settings/QuietHoursEditorView.swift delete mode 100644 apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift delete mode 100644 apps/ios/ADENotificationService/ADENotificationService.entitlements delete mode 100644 apps/ios/ADENotificationService/Info.plist delete mode 100644 apps/ios/ADENotificationService/NotificationService.swift diff --git a/.ade/ade.yaml b/.ade/ade.yaml index d7995eecf..fad2139dd 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -42,10 +42,3 @@ ai: endpoint: http://127.0.0.1:1234 autoDetect: true preferredModelId: null -notifications: - apns: - enabled: true - env: sandbox - keyId: 8HYA5AWCGP - teamId: VQ372F39G6 - bundleId: com.ade.ios diff --git a/CHANGELOG.md b/CHANGELOG.md index 33352b39a..5ed2e8129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -228,7 +228,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added mobile push notifications through APNS, external MCP OAuth, auto-rebase suggestions, PR issue resolver, smart tooltips, and the diagnostics dashboard. +- Added external MCP OAuth, auto-rebase suggestions, PR issue resolver, smart tooltips, and the diagnostics dashboard. - Added legacy Cursor integration and OpenCode runtime integration for managed AI backends. ## [1.0.18] - 2026-04-14 diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index ff43f0c16..78e989a5f 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -74,10 +74,6 @@ import { createAutomationIngressService } from "../../desktop/src/main/services/ import { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; -import { - ApnsKeyStore, - ApnsService, -} from "../../desktop/src/main/services/notifications/apnsService"; import { ADE_AGENT_SKILLS_DIRS_ENV, getAdeAgentSkillRootsForPrompt, @@ -231,8 +227,6 @@ export type AdeRuntime = { builtInBrowserService?: BuiltInBrowserService | BuiltInBrowserDesktopBridgeClient | null; syncHostService?: ReturnType | null; syncService?: ReturnType | null; - apnsService?: ApnsService | null; - apnsKeyStore?: ApnsKeyStore | null; automationIngressService?: ReturnType | null; feedbackReporterService?: ReturnType | null; usageTrackingService?: ReturnType | null; @@ -1158,39 +1152,6 @@ export async function createAdeRuntime(args: { projectConfigService, usageTrackingService, }); - const apnsService = new ApnsService({ logger }); - const projectSecretsDir = path.join(projectRoot, ".ade", "secrets"); - const apnsKeyStore = new ApnsKeyStore({ - encryptedKeyPath: path.join(projectSecretsDir, "apns.key.enc"), - credentialStore: new EncryptedFileCredentialStore({ - secretsDir: projectSecretsDir, - }), - }); - try { - const apnsConfig = projectConfigService.get().effective.notifications?.apns; - if ( - apnsConfig?.enabled && - apnsKeyStore.has() && - apnsConfig.keyId && - apnsConfig.teamId && - apnsConfig.bundleId - ) { - const pem = apnsKeyStore.load(); - if (pem) { - apnsService.configure({ - keyP8Pem: pem, - keyId: apnsConfig.keyId, - teamId: apnsConfig.teamId, - bundleId: apnsConfig.bundleId, - env: apnsConfig.env ?? "sandbox", - }); - } - } - } catch (error) { - logger.warn("apns.configure_on_startup_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } let syncService: ReturnType | null = null; if (resolvedArgs.syncRuntime?.enabled && agentChatService) { const { createSyncService } = await import("./services/sync/syncService"); @@ -1287,8 +1248,6 @@ export async function createAdeRuntime(args: { diffService, syncService, syncHostService: syncService?.getHostService() ?? null, - apnsService, - apnsKeyStore, laneWorktreeLockService, ptyService, testService, @@ -1338,7 +1297,6 @@ export async function createAdeRuntime(args: { swallow(() => automationIngressService?.dispose()); swallow(() => automationService?.dispose()); swallow(() => usageTrackingService.dispose()); - swallow(() => apnsService.dispose()); swallow(() => syncService?.dispose()); swallow(() => processService.disposeAll()); swallow(() => runtimeDiagnosticsService.dispose()); diff --git a/apps/ade-cli/src/services/sync/deviceRegistryService.ts b/apps/ade-cli/src/services/sync/deviceRegistryService.ts index 9ad6223b3..0480d1274 100644 --- a/apps/ade-cli/src/services/sync/deviceRegistryService.ts +++ b/apps/ade-cli/src/services/sync/deviceRegistryService.ts @@ -13,7 +13,6 @@ import type { SyncPeerMetadata, SyncPeerPlatform, } from "../../../../desktop/src/shared/types"; -import { normalizeNotificationPreferences, type NotificationPreferences } from "../../../../desktop/src/shared/types/sync"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import { mapPlatform } from "./syncProtocol"; import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; @@ -53,7 +52,6 @@ type ClusterStateRow = { const DEVICE_ID_FILE = "sync-device-id"; export const DEFAULT_SYNC_CLUSTER_ID = "default"; -const WORKSPACE_ACTIVITY_ID = "workspace"; const TAILSCALE_STATUS_CACHE_MS = 30_000; let tailscaleStatusCache: @@ -477,174 +475,6 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { }); }; - type ApnsTokenKind = "alert" | "activity-start" | "activity-update"; - - const apnsMetaKey = (kind: ApnsTokenKind): string => { - if (kind === "alert") return "apnsAlertToken"; - if (kind === "activity-start") return "apnsActivityStartToken"; - return "apnsActivityUpdateTokens"; - }; - - const setApnsToken = ( - deviceId: string, - token: string, - kind: ApnsTokenKind, - env: "sandbox" | "production", - extras: { bundleId?: string; activityId?: string } = {}, - ): SyncDeviceRecord | null => { - const device = getDevice(deviceId); - if (!device) return null; - const nextMetadata: Record = { - ...device.metadata, - apnsEnv: env, - apnsTokenUpdatedAt: nowIso(), - }; - if (extras.bundleId) nextMetadata.apnsBundleId = extras.bundleId; - if (kind === "activity-update") { - const existing = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; - const activityId = extras.activityId?.trim() || WORKSPACE_ACTIVITY_ID; - nextMetadata.apnsActivityUpdateTokens = { ...existing, [activityId]: token }; - } else { - nextMetadata[apnsMetaKey(kind)] = token; - } - return upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const getApnsTokenForDevice = ( - deviceId: string, - kind: ApnsTokenKind, - activityId?: string, - ): string | null => { - const device = getDevice(deviceId); - if (!device) return null; - if (kind === "activity-update") { - const map = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; - return map[activityId?.trim() || WORKSPACE_ACTIVITY_ID] ?? null; - } - const raw = device.metadata[apnsMetaKey(kind)]; - return typeof raw === "string" && raw.trim().length > 0 ? raw : null; - }; - - const setNotificationPreferences = ( - deviceId: string, - prefs: NotificationPreferences, - ): SyncDeviceRecord | null => { - const device = getDevice(deviceId); - if (!device) return null; - const normalizedPrefs = normalizeNotificationPreferences(prefs); - return upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: { - ...device.metadata, - notificationPreferences: normalizedPrefs, - notificationPreferencesUpdatedAt: nowIso(), - }, - }); - }; - - const getNotificationPreferences = (deviceId: string): NotificationPreferences | null => { - const prefs = getDevice(deviceId)?.metadata.notificationPreferences; - if (!prefs || typeof prefs !== "object" || Array.isArray(prefs)) return null; - return normalizeNotificationPreferences(prefs); - }; - - const invalidateApnsToken = (deviceToken: string): void => { - const token = deviceToken.trim(); - if (!token) return; - const device = findDeviceByApnsToken(token); - if (!device) return; - const nextMetadata = { ...device.metadata }; - if (nextMetadata.apnsAlertToken === token) { - delete nextMetadata.apnsAlertToken; - } - if (nextMetadata.apnsActivityStartToken === token) { - delete nextMetadata.apnsActivityStartToken; - } - const updates = nextMetadata.apnsActivityUpdateTokens; - if (updates && typeof updates === "object" && !Array.isArray(updates)) { - const nextUpdates = { ...(updates as Record) }; - for (const [activityId, value] of Object.entries(nextUpdates)) { - if (value === token) delete nextUpdates[activityId]; - } - if (Object.keys(nextUpdates).length > 0) { - nextMetadata.apnsActivityUpdateTokens = nextUpdates; - } else { - delete nextMetadata.apnsActivityUpdateTokens; - } - } - upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const invalidateApnsTokensForDevice = (deviceId: string): void => { - const device = getDevice(deviceId); - if (!device) return; - const nextMetadata = { ...device.metadata }; - delete nextMetadata.apnsAlertToken; - delete nextMetadata.apnsActivityStartToken; - delete nextMetadata.apnsActivityUpdateTokens; - upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const findDeviceByApnsToken = (token: string): SyncDeviceRecord | null => { - for (const device of listDevices()) { - const alert = device.metadata.apnsAlertToken; - const activity = device.metadata.apnsActivityStartToken; - if (alert === token || activity === token) return device; - const updates = device.metadata.apnsActivityUpdateTokens; - if (updates && typeof updates === "object") { - for (const value of Object.values(updates as Record)) { - if (value === token) return device; - } - } - } - return null; - }; - const applyBrainStatus = (payload: SyncBrainStatusPayload): void => { upsertPeerMetadata(payload.brain, { lastSeenAt: nowIso() }); for (const peer of payload.connectedPeers) { @@ -698,13 +528,6 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { applyBrainStatus, clearClusterRegistryForViewerJoin, forgetDevice, - setApnsToken, - getApnsTokenForDevice, - setNotificationPreferences, - getNotificationPreferences, - invalidateApnsToken, - invalidateApnsTokensForDevice, - findDeviceByApnsToken, }; } diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 4f2d1ebf8..f0b10251a 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -93,17 +93,6 @@ import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; import type { DeviceRegistryService } from "./deviceRegistryService"; import { createSyncPairingStore, type SyncPairingRecord } from "./syncPairingStore"; -import type { NotificationEventBus } from "../../../../desktop/src/main/services/notifications/notificationEventBus"; -import type { - ApnsEnvironment, - ApnsPushTokenKind, - NotificationPreferences, - SyncInAppNotificationPayload, - SyncNotificationPrefsPayload, - SyncRegisterPushTokenPayload, - SyncSendTestPushPayload, -} from "../../../../desktop/src/shared/types/sync"; -import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../../desktop/src/shared/types/sync"; import type { SyncPinStore } from "./syncPinStore"; import type { SyncRuntimeNameStore } from "./syncRuntimeNameStore"; import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, DEFAULT_SYNC_MAX_FRAME_BYTES, encodeSyncEnvelope, encodeSyncEnvelopeFrames, mapPlatform, parseSyncEnvelope, SYNC_CHUNKED_ENVELOPES_CAPABILITY, wsDataToText } from "./syncProtocol"; @@ -255,7 +244,6 @@ type PersistedMobileCommand = { const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ "lanes.presence.announce", "lanes.presence.release", - "notification_prefs", "work.runQuickCommand", "work.startCliSession", "work.sendToSession", @@ -421,7 +409,6 @@ type SyncHostServiceArgs = { deviceRegistryService?: DeviceRegistryService; projectCatalogProvider?: SyncProjectCatalogProvider; onStateChanged?: () => void; - notificationEventBus?: NotificationEventBus | null; remoteCommandService?: SyncRemoteCommandService; remoteCommandExecutor?: Pick; }; @@ -1149,19 +1136,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }; loadPersistedCommandLedger(); - /** Notification preferences keyed by deviceId. The map is a hot cache; - * device metadata is the restart-safe source for offline push fan-out. */ - const notificationPrefsByDeviceId = new Map(); - const storeNotificationPrefsForDevice = (deviceId: string, prefs: NotificationPreferences): void => { - const normalizedPrefs = normalizeNotificationPreferences(prefs); - notificationPrefsByDeviceId.set(deviceId, normalizedPrefs); - args.deviceRegistryService?.setNotificationPreferences?.(deviceId, normalizedPrefs); - }; - const readNotificationPrefsForDevice = (deviceId: string): NotificationPreferences => { - return notificationPrefsByDeviceId.get(deviceId) - ?? args.deviceRegistryService?.getNotificationPreferences?.(deviceId) - ?? DEFAULT_NOTIFICATION_PREFERENCES; - }; const lanePresenceByLaneId = new Map>(); let localActiveLaneIds = new Set(); const PAIR_FAILURE_THRESHOLD = 5; @@ -1544,15 +1518,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const chatEventSubscription = args.agentChatService?.subscribeToEvents( (event) => { broadcastChatEvent(event); - // Let the notification bus (mobile push fan-out) observe chat events. - // Failures here must never break chat delivery to the UI. - try { - args.notificationEventBus?.publishChatEvent(event); - } catch (error) { - args.logger.warn("sync_host.notification_publish_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } }, ) ?? null; @@ -3209,36 +3174,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { reject("This ADE machine is hosting a different project. Select the project again and retry.", "project_not_open"); return; } - if (payload.action === "notification_prefs") { - // iOS bridges `SyncService.setMutePush` through the command envelope - // rather than a second `notification_prefs` envelope. We translate by - // merging `{ muteUntil }` into the device's existing prefs (or the - // default prefs if none have been uploaded yet) so the notification - // bus starts gating immediately — the same `isAllowedByPrefs` path the - // envelope-based update feeds. - const deviceId = peer.metadata?.deviceId; - if (!deviceId) { - reject("notification_prefs requires an authenticated device.", "invalid_command"); - return; - } - const rawArgs = (payload.args as Record | null | undefined) ?? {}; - const rawMute = rawArgs.muteUntil; - const muteUntil = typeof rawMute === "string" && rawMute.length > 0 ? rawMute : null; - const existing = readNotificationPrefsForDevice(deviceId); - storeNotificationPrefsForDevice(deviceId, { ...existing, muteUntil }); - const ack: SyncCommandAckPayload = { - commandId, - accepted: true, - status: "accepted", - message: muteUntil ? `Muted pushes until ${muteUntil}.` : "Cleared push mute.", - }; - sendResult(startCommandRecord(ack), { - commandId, - ok: true, - result: { ok: true, muteUntil }, - }); - return; - } if (payload.action === "lanes.presence.announce" || payload.action === "lanes.presence.release") { if (requestedProjectId && hostProjectId && !matchesHostProject) { reject("Lane presence is not available for a project that is not open in this phone sync host.", "project_not_open"); @@ -4007,172 +3942,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { : {}), }); break; - case "register_push_token": { - const payload = envelope.payload as SyncRegisterPushTokenPayload | null; - handleRegisterPushToken(peer, envelope.requestId, payload); - break; - } - case "notification_prefs": { - const payload = envelope.payload as SyncNotificationPrefsPayload | null; - handleNotificationPrefs(peer, payload); - break; - } - case "send_test_push": { - const payload = envelope.payload as SyncSendTestPushPayload | null; - await handleSendTestPush(peer, envelope.requestId, payload); - break; - } default: break; } } - function handleRegisterPushToken( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncRegisterPushTokenPayload | null, - ): void { - const deviceId = peer.metadata?.deviceId; - if (!deviceId) { - args.logger.warn("sync_host.push_token_missing_device", {}); - sendRequired(peer, "command_ack", { - commandId: "push-token:unknown", - accepted: false, - status: "missing_device_id", - message: "Cannot store push token before device registration completes.", - }, requestId ?? null); - return; - } - if (!payload || typeof payload.token !== "string" || payload.token.trim().length === 0) { - args.logger.warn("sync_host.push_token_missing", { deviceId }); - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:unknown`, - accepted: false, - status: "invalid_payload", - message: "Push token registration did not include a token.", - }, requestId ?? null); - return; - } - const kind: ApnsPushTokenKind = - payload.kind === "alert" || payload.kind === "activity-start" || payload.kind === "activity-update" - ? payload.kind - : "alert"; - if (kind === "activity-update" && !payload.activityId?.trim()) { - args.logger.warn("sync_host.push_token_missing_activity_id", { deviceId }); - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: false, - status: "missing_activity_id", - message: "Live Activity update tokens require an activity id.", - }, requestId ?? null); - return; - } - const env: ApnsEnvironment = payload.env === "production" ? "production" : "sandbox"; - const stored = args.deviceRegistryService?.setApnsToken?.(deviceId, payload.token.trim(), kind, env, { - bundleId: payload.bundleId, - activityId: payload.activityId, - }); - if (!stored) { - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: false, - status: "device_not_found", - message: `Could not store ${kind} push token for ${deviceId}.`, - }, requestId ?? null); - return; - } - // Optional ack so the client can retry on failure. - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: true, - status: "accepted", - message: `Stored ${kind} push token for ${deviceId}.`, - }, requestId ?? null); - } - - function handleNotificationPrefs(peer: PeerState, payload: SyncNotificationPrefsPayload | null): void { - const deviceId = peer.metadata?.deviceId; - if (!deviceId || !payload || !payload.prefs) return; - storeNotificationPrefsForDevice(deviceId, normalizeNotificationPreferences(payload.prefs)); - } - - async function handleSendTestPush( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncSendTestPushPayload | null, - ): Promise { - const deviceId = peer.metadata?.deviceId; - if (!deviceId) return; - const kind = payload?.kind === "activity" ? "activity" : "alert"; - if (!args.notificationEventBus) { - if (isIosPeerConnected(deviceId)) { - sendInAppNotification(deviceId, { - category: "system", - title: payload?.title?.trim() || "ADE test push", - body: payload?.body?.trim() || "This device reached its paired ADE machine.", - collapseId: "ade:test", - }); - sendRequired(peer, "command_result", { - commandId: `push-test:${deviceId}:${kind}`, - ok: true, - result: { - mode: "in_app", - message: "Test notification delivered in app. APNs is not wired in this runtime.", - }, - }, requestId ?? null); - return; - } - sendRequired(peer, "command_result", { - commandId: `push-test:${deviceId}:${kind}`, - ok: false, - error: { - code: "test_push_failed", - message: "Notifications are not wired in this ADE runtime.", - }, - }, requestId ?? null); - return; - } - const result = await args.notificationEventBus.sendTestPush(deviceId, kind); - sendRequired(peer, "command_result", { - commandId: `push-test:${deviceId}:${kind}`, - ok: result.ok, - ...(result.ok ? {} : { error: { code: "test_push_failed", message: result.reason ?? "unknown" } }), - }, requestId ?? null); - } - - /** - * Deliver a foreground-only notification to a specific iOS peer over the - * existing WebSocket. Used by the notification bus when the device is - * currently connected, in place of (or alongside) an APNs alert. - */ - function sendInAppNotification( - deviceId: string, - payload: Omit, - ): void { - const fullPayload: SyncInAppNotificationPayload = { - ...payload, - generatedAt: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (peer.metadata?.deviceId !== deviceId) continue; - send(peer.ws, "in_app_notification", fullPayload); - } - } - - function getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { - return readNotificationPrefsForDevice(deviceId); - } - - function isIosPeerConnected(deviceId: string): boolean { - for (const peer of peers) { - if (peer.metadata?.deviceId !== deviceId) continue; - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - return true; - } - return false; - } - const getLanePresenceSnapshot = (): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> => { return [...lanePresenceByLaneId.keys()] .sort((left, right) => left.localeCompare(right)) @@ -4360,27 +4134,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }, - /** - * Push an in-app notification to a specific iOS peer over the WebSocket. - * Used by the notification event bus as the foreground-delivery path. - */ - sendInAppNotification( - deviceId: string, - payload: Omit, - ): void { - sendInAppNotification(deviceId, payload); - }, - - /** Returns the latest announced notification prefs for a device, or null. */ - getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { - return getNotificationPrefsForDevice(deviceId); - }, - - /** Whether a given device is currently connected + authenticated. */ - isIosPeerConnected(deviceId: string): boolean { - return isIosPeerConnected(deviceId); - }, - handlePtyData(event: PtyDataEvent): void { const payload = { sessionId: event.sessionId, diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 599c4f02b..3deb0fbd8 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -42,7 +42,6 @@ import type { createPrService } from "../../../../desktop/src/main/services/prs/ import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; -import type { NotificationEventBus } from "../../../../desktop/src/main/services/notifications/notificationEventBus"; import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; import { nowIso, safeJsonParse, sleep, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; import { createDeviceRegistryService } from "./deviceRegistryService"; @@ -127,12 +126,6 @@ type SyncServiceArgs = { */ forceHostRole?: boolean; onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; - /** - * Optional notification bus forwarded to the sync host. The host publishes - * chat/PR/system events and invokes `sendInAppNotification` for - * connected iOS peers. - */ - notificationEventBus?: NotificationEventBus | null; projectCatalogProvider?: SyncProjectCatalogProvider; remoteCommandExecutor?: Pick; /** @@ -728,7 +721,6 @@ export function createSyncService(args: SyncServiceArgs) { runtimeKind: args.runtimeKind ?? "desktop-embedded", runtimeVersion: args.appVersion ?? "", deviceRegistryService, - notificationEventBus: args.notificationEventBus ?? null, projectCatalogProvider: args.projectCatalogProvider, remoteCommandService, remoteCommandExecutor: args.remoteCommandExecutor, diff --git a/apps/desktop/apps/ios/TestFixtures/README.md b/apps/desktop/apps/ios/TestFixtures/README.md deleted file mode 100644 index 1e13a66dd..000000000 --- a/apps/desktop/apps/ios/TestFixtures/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Simulator push fixtures - -Used with `xcrun simctl push com.ade.ios .json` to drive the notification -code paths without hitting APNs. `` can be `booted` to target whichever simulator is -currently running. - -Covers: CHAT_AWAITING_INPUT (tests Approve/Deny/Reply), CHAT_FAILED (tests RESTART), PR_CI_FAILING -(tests Retry), PR_REVIEW_REQUESTED, PR_MERGE_READY. - -Sequence that exercises the full surface: - xcrun simctl push booted com.ade.ios chat-awaiting-input.json - xcrun simctl push booted com.ade.ios pr-ci-failing.json - xcrun simctl push booted com.ade.ios chat-failed.json - xcrun simctl push booted com.ade.ios pr-review-requested.json - xcrun simctl push booted com.ade.ios pr-merge-ready.json - -Real device testing requires a configured APNs .p8 in ADE desktop → Settings → Mobile Push. diff --git a/apps/desktop/apps/ios/TestFixtures/chat-awaiting-input.json b/apps/desktop/apps/ios/TestFixtures/chat-awaiting-input.json deleted file mode 100644 index 553482e2f..000000000 --- a/apps/desktop/apps/ios/TestFixtures/chat-awaiting-input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "aps": { - "alert": { - "title": "Claude · auth-refactor", - "body": "Awaiting your approval on 3 file edits." - }, - "sound": "default", - "mutable-content": 1, - "interruption-level": "time-sensitive", - "relevance-score": 1.0, - "thread-id": "chat:session-abc:approval", - "category": "CHAT_AWAITING_INPUT" - }, - "providerSlug": "claude", - "sessionId": "session-abc", - "itemId": "item-42", - "kind": "approval" -} diff --git a/apps/desktop/apps/ios/TestFixtures/chat-failed.json b/apps/desktop/apps/ios/TestFixtures/chat-failed.json deleted file mode 100644 index 6f83c50e5..000000000 --- a/apps/desktop/apps/ios/TestFixtures/chat-failed.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "aps": { - "alert": { - "title": "Codex · tests-fix", - "body": "Session failed: rate limit exceeded." - }, - "sound": "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.7, - "thread-id": "chat:session-xyz", - "category": "CHAT_FAILED" - }, - "providerSlug": "codex", - "sessionId": "session-xyz" -} diff --git a/apps/desktop/apps/ios/TestFixtures/pr-ci-failing.json b/apps/desktop/apps/ios/TestFixtures/pr-ci-failing.json deleted file mode 100644 index 6bcecbc1c..000000000 --- a/apps/desktop/apps/ios/TestFixtures/pr-ci-failing.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "aps": { - "alert": { - "title": "PR #412 · auth-refactor", - "body": "3 CI checks failing: lint, tsc, test." - }, - "sound": "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.8, - "thread-id": "pr:412", - "category": "PR_CI_FAILING" - }, - "prId": "pr-412-internal", - "prNumber": 412 -} diff --git a/apps/desktop/apps/ios/TestFixtures/pr-merge-ready.json b/apps/desktop/apps/ios/TestFixtures/pr-merge-ready.json deleted file mode 100644 index a798f7076..000000000 --- a/apps/desktop/apps/ios/TestFixtures/pr-merge-ready.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "aps": { - "alert": { - "title": "PR #401 · refactor-auth", - "body": "All checks passed and approved. Ready to merge." - }, - "sound": "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.6, - "thread-id": "pr:401", - "category": "PR_MERGE_READY" - }, - "prId": "pr-401-internal", - "prNumber": 401 -} diff --git a/apps/desktop/apps/ios/TestFixtures/pr-review-requested.json b/apps/desktop/apps/ios/TestFixtures/pr-review-requested.json deleted file mode 100644 index 9a89ebd20..000000000 --- a/apps/desktop/apps/ios/TestFixtures/pr-review-requested.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "aps": { - "alert": { - "title": "PR #408 · new-widget", - "body": "alice requested your review." - }, - "sound": "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.7, - "thread-id": "pr:408", - "category": "PR_REVIEW_REQUESTED" - }, - "prId": "pr-408-internal", - "prNumber": 408 -} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 017722ef7..beecb2995 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -194,14 +194,6 @@ import { startBuiltInBrowserDesktopBridgeServer } from "./services/builtInBrowse import { configureBuiltInBrowserWebAuthn } from "./services/builtInBrowser/builtInBrowserWebAuthn"; import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntimeConnectionPool"; import { createSyncService } from "./services/sync/syncService"; -import { ApnsService, ApnsKeyStore } from "./services/notifications/apnsService"; -import { - createNotificationEventBus, - type DevicePushTarget, - type NotificationEventBus, -} from "./services/notifications/notificationEventBus"; -import type { SyncService } from "./services/sync/syncService"; -import type { DeviceRegistryService } from "./services/sync/deviceRegistryService"; import { blockPackagedLaunchForCrossChannelSyncConflict } from "./services/sync/packagedSyncHostLaunchGate"; import { createAutoUpdateService } from "./services/updates/autoUpdateService"; import { cleanupStaleTempArtifacts } from "./services/runtime/tempCleanupService"; @@ -2496,103 +2488,6 @@ app.whenReady().then(async () => { }); prServiceRef = prService; - // --- Mobile push notifications (APNs + event bus) ----------------------- - // ApnsService is instantiated but left unconfigured here; the Mobile Push - // settings panel calls into it once the user uploads a `.p8` key. The - // notification event bus is always wired so in-app WebSocket delivery - // works even when APNs is disabled. - let syncServiceForNotifications: SyncService | null = null; - const apnsService = new ApnsService({ logger }); - const apnsKeyStore = new ApnsKeyStore({ - encryptedKeyPath: path.join(projectRoot, ".ade", "secrets", "apns.key.enc"), - safeStorage, - }); - // Attempt to restore a previously-stored key + config on project load so - // push delivery survives restarts without user intervention. - try { - const effective = projectConfigService.get().effective; - const apnsConfig = effective.notifications?.apns ?? null; - logger.info("apns.configure_on_startup_attempt", { - hasConfig: apnsConfig != null, - enabled: apnsConfig?.enabled === true, - keyStored: apnsKeyStore.has(), - hasKeyId: Boolean(apnsConfig?.keyId), - hasTeamId: Boolean(apnsConfig?.teamId), - hasBundleId: Boolean(apnsConfig?.bundleId), - env: apnsConfig?.env ?? null, - }); - if (apnsConfig?.enabled && apnsKeyStore.has() && apnsConfig.keyId && apnsConfig.teamId && apnsConfig.bundleId) { - const pem = apnsKeyStore.load(); - if (pem) { - apnsService.configure({ - keyP8Pem: pem, - keyId: apnsConfig.keyId, - teamId: apnsConfig.teamId, - bundleId: apnsConfig.bundleId, - env: apnsConfig.env ?? "sandbox", - }); - logger.info("apns.configure_on_startup_ok", { keyId: apnsConfig.keyId }); - } - } - } catch (error) { - logger.warn("apns.configure_on_startup_failed", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - } - const listPushTargets = (): DevicePushTarget[] => { - const registry: DeviceRegistryService | null = - syncServiceForNotifications?.getDeviceRegistryService?.() ?? null; - if (!registry) return []; - const effective = projectConfigService.get().effective; - const apnsConfig = effective.notifications?.apns ?? null; - const bundleId = apnsConfig?.bundleId?.trim() ?? ""; - const env = apnsConfig?.env === "production" ? "production" : "sandbox"; - return registry - .listDevices() - .filter((device) => device.deviceType === "phone" && device.platform === "iOS") - .map((device) => { - const meta = device.metadata ?? {}; - const alertToken = typeof meta.apnsAlertToken === "string" ? meta.apnsAlertToken : null; - const activityStartToken = typeof meta.apnsActivityStartToken === "string" ? meta.apnsActivityStartToken : null; - const activityUpdateTokens = - meta.apnsActivityUpdateTokens && typeof meta.apnsActivityUpdateTokens === "object" - ? (meta.apnsActivityUpdateTokens as Record) - : null; - const perDeviceBundleId = - typeof meta.apnsBundleId === "string" && meta.apnsBundleId.trim().length > 0 - ? meta.apnsBundleId - : bundleId; - return { - deviceId: device.deviceId, - bundleId: perDeviceBundleId, - env: (meta.apnsEnv === "production" ? "production" : env) as "sandbox" | "production", - alertToken, - activityStartToken, - activityUpdateTokens, - } satisfies DevicePushTarget; - }) - .filter((target) => target.bundleId.trim().length > 0); - }; - const notificationEventBus: NotificationEventBus = createNotificationEventBus({ - logger, - apnsService, - listPushTargets, - getPrefsForDevice: (deviceId) => - syncServiceForNotifications?.getHostService()?.getNotificationPrefsForDevice(deviceId) ?? null, - sendInAppNotification: (deviceId, payload) => { - syncServiceForNotifications?.getHostService()?.sendInAppNotification(deviceId, payload); - }, - isDeviceConnected: (deviceId) => - syncServiceForNotifications?.getHostService()?.isIosPeerConnected(deviceId) ?? false, - }); - // When APNs reports an invalid token, drop it from the registry so we - // stop fanning out to a dead device. - apnsService.onTokenInvalidated(({ deviceToken }) => { - const registry = syncServiceForNotifications?.getDeviceRegistryService?.() ?? null; - registry?.invalidateApnsToken?.(deviceToken); - }); - const rpcEventBuffer = createEventBuffer(); const emitPrEvent = (event: PrEventPayload): void => { emitProjectEvent(projectRoot, IPC.prsEvent, event); @@ -2616,7 +2511,6 @@ app.whenReady().then(async () => { prService, projectConfigService, db, - notificationEventBus, onEvent: emitPrEvent, onPullRequestsChanged: async ({ changedPrs, changes }) => { if (changedPrs.length > 0) { @@ -3485,7 +3379,6 @@ app.whenReady().then(async () => { phonePairingStateDir: machineAdeLayout.secretsDir, hostDiscoveryEnabled: isMobileSyncHostContext, forceHostRole: false, - notificationEventBus, projectCatalogProvider: { listProjects: listMobileSyncProjects, prepareProjectConnection: prepareMobileSyncProjectConnection, @@ -3541,9 +3434,6 @@ app.whenReady().then(async () => { }, }); syncServiceRef = syncService; - // Late-bind the sync service into the notification bus dependencies so - // push targets / prefs / in-app delivery are resolved at send time. - syncServiceForNotifications = syncService; scheduleBackgroundProjectTask( "sync.initialize", () => measureProjectInitStep("sync.initialize", () => syncService.initialize()), @@ -4265,9 +4155,6 @@ app.whenReady().then(async () => { budgetCapService, syncHostService: syncService.getHostService(), syncService, - apnsService, - apnsKeyStore, - notificationEventBus, orchestrationService, agentChatService, projectConfigService, @@ -4446,9 +4333,6 @@ app.whenReady().then(async () => { budgetCapService: null, syncHostService: null, syncService: null, - apnsService: null, - apnsKeyStore: null, - notificationEventBus: null, orchestrationService: null, projectConfigService: null, processService: null, @@ -4648,11 +4532,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - await ctx.apnsService?.dispose?.(); - } catch { - // ignore - } try { ctx.processRegistry?.stop(); } catch { @@ -5681,8 +5560,8 @@ app.whenReady().then(async () => { const recordPeers = (peers: readonly SyncPeerConnectionState[] | undefined): void => { for (const peer of peers ?? []) { // Match the canonical connected-phone convention used by the phone - // device list (main.ts ~2535) and APNS targeting: a phone is both - // deviceType "phone" AND platform "iOS". The looser OR form belongs to + // device list: a phone is both deviceType "phone" AND platform "iOS". + // The looser OR form belongs to // host-side sync gating, not user-facing phone copy. if (peer.deviceType !== "phone" || peer.platform !== "iOS") continue; phonesById.set(peer.deviceId, peer.deviceName?.trim() || "Connected phone"); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index ea5f1e8ba..9763634ba 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -257,13 +257,6 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { } }); - it("exposes mobile push settings through runtime APNs actions", () => { - const actions = ADE_ACTION_ALLOWLIST.notifications_apns ?? []; - for (const name of ["getStatus", "saveConfig", "uploadKey", "clearKey", "sendTestPush"]) { - expect(actions).toContain(name); - } - }); - it("exposes lane.listSnapshots for runtime-backed lane snapshot parity", () => { const actions = ADE_ACTION_ALLOWLIST.lane ?? []; expect(actions).toContain("listSnapshots"); @@ -366,74 +359,6 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { }); -describe("runtime APNs action service", () => { - it("does not read APNs dependencies when resolving an unrelated domain", () => { - const runtime = { - laneService: { - list: vi.fn(), - }, - projectConfigService: { - get: vi.fn(), - }, - get apnsService() { - throw new Error("apnsService should not be read for lane actions"); - }, - get apnsKeyStore() { - throw new Error("apnsKeyStore should not be read for lane actions"); - }, - } as unknown as Parameters[0]; - - const services = getAdeActionDomainServices(runtime); - const laneService = services.lane as Record; - - expect(Object.keys(services)).toContain("notifications_apns"); - expect(laneService.list).toEqual(expect.any(Function)); - }); - - it("reuses the APNs bridge for repeated lookups on a stable runtime", () => { - const runtime = { - projectConfigService: { - get: vi.fn(() => ({ effective: {}, shared: {}, local: {} })), - }, - apnsService: { - isConfigured: vi.fn(() => false), - }, - apnsKeyStore: { - has: vi.fn(() => false), - }, - } as unknown as Parameters[0]; - - const first = getAdeActionDomainServices(runtime).notifications_apns; - const second = getAdeActionDomainServices(runtime).notifications_apns; - - expect(second).toBe(first); - }); - - it("refreshes the cached APNs bridge when late-bound dependencies change", async () => { - const runtime = { - projectConfigService: { - get: vi.fn(() => ({ effective: {}, shared: {}, local: {} })), - }, - apnsService: { - isConfigured: vi.fn(() => false), - }, - apnsKeyStore: { - has: vi.fn(() => false), - }, - } as any as Parameters[0]; - - const first = getAdeActionDomainServices(runtime).notifications_apns; - (runtime as any).apnsService = { - isConfigured: vi.fn(() => true), - }; - const second = getAdeActionDomainServices(runtime).notifications_apns as { - getStatus: () => Promise<{ configured: boolean }>; - }; - - expect(second).not.toBe(first); - await expect(second.getStatus()).resolves.toMatchObject({ configured: true }); - }); -}); describe("runtime Linear issue tracker actions", () => { it("builds catalog and picker payloads from tracker reads", async () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 6c5c8e75b..a4f8e1a99 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -73,7 +73,6 @@ import { mapPermissionModeForModelFamily } from "../prs/resolverUtils"; import { getErrorMessage, isRecord, nowIso } from "../shared/utils"; import { parseLinearGraphQLInput } from "../cto/linearGraphQLInput"; import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; -import { createApnsBridgeService } from "../notifications/apnsBridgeService"; import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession"; import { createOrchestrationDomainService } from "../orchestration/orchestrationDomain"; @@ -122,7 +121,6 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "automations", "review", "issue", - "notifications_apns", "orchestration", ] as const; @@ -689,13 +687,6 @@ export const ADE_ACTION_ALLOWLIST: Partial(); - -function getApnsBridgeDomainService(runtime: AdeRuntime): OpaqueService { - const projectConfigService = runtime.projectConfigService; - const apnsService = runtime.apnsService; - const apnsKeyStore = runtime.apnsKeyStore; - const cached = apnsBridgeDomainServices.get(runtime); - if ( - cached && - cached.projectConfigService === projectConfigService && - cached.apnsService === apnsService && - cached.apnsKeyStore === apnsKeyStore - ) { - return cached.service; - } - const service = createApnsBridgeService({ - projectConfigService, - apnsService, - apnsKeyStore, - getDeviceRegistryService: () => - runtime.syncService?.getDeviceRegistryService?.() ?? null, - }) as OpaqueService; - apnsBridgeDomainServices.set(runtime, { - projectConfigService, - apnsService, - apnsKeyStore, - service, - }); - return service; -} - const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; function agentChatParallelLaunchStateKey(projectRoot: string, parentLaneId: string): string { @@ -2736,9 +2689,6 @@ export function getAdeActionDomainServices( review: toService(runtime.reviewService), issue: toService(buildIssueDomainService(runtime)), orchestration: toService(buildOrchestrationDomainService(runtime)), - get notifications_apns() { - return toService(getApnsBridgeDomainService(runtime)); - }, }; } diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index 004806ab8..d0a2d8550 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -1391,67 +1391,3 @@ describe("projectConfigService - process groups", () => { expect(saved.processes[0].command[0]).toBe("scripts/dogfood.sh"); }); }); - -describe("projectConfigService - notifications", () => { - it("deep-merges APNs local overrides with shared notification config", () => { - const { root, adeDir } = makeProjectFixture("ade-project-config-notifications-"); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - notifications: { - apns: { - enabled: true, - env: "production", - keyId: "KEY_SHARED", - teamId: "TEAM_SHARED", - bundleId: "com.ade.shared", - keyStored: true, - }, - }, - }), - "utf8", - ); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - notifications: { - apns: { - keyId: "KEY_LOCAL", - }, - }, - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-notifications", - db: makeDb(), - logger: makeLogger(), - }); - - expect(service.get().effective.notifications?.apns).toEqual({ - enabled: true, - env: "production", - keyId: "KEY_LOCAL", - teamId: "TEAM_SHARED", - bundleId: "com.ade.shared", - keyStored: true, - }); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index e27e5ad76..ad0c8202c 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -59,8 +59,6 @@ import type { LinearAutoDispatchAction, LinearSyncConfig, ModelConfig, - NotificationsConfig, - NotificationApnsConfig, ProjectIdentityConfig, StackButtonDefinition, TestSuiteDefinition, @@ -1605,28 +1603,6 @@ function normalizeIssueStateKey(value: unknown): return null; } -function coerceNotificationsConfig(value: unknown): NotificationsConfig | undefined { - if (!isRecord(value)) return undefined; - const out: NotificationsConfig = {}; - if (isRecord(value.apns)) { - const raw = value.apns; - const apns = {} as NotificationApnsConfig; - const enabled = asBool(raw.enabled); - if (enabled != null) apns.enabled = enabled; - if (raw.env === "production" || raw.env === "sandbox") apns.env = raw.env; - const keyId = asString(raw.keyId)?.trim(); - if (keyId) apns.keyId = keyId; - const teamId = asString(raw.teamId)?.trim(); - if (teamId) apns.teamId = teamId; - const bundleId = asString(raw.bundleId)?.trim(); - if (bundleId) apns.bundleId = bundleId; - const keyStored = asBool(raw.keyStored); - if (keyStored != null) apns.keyStored = keyStored; - out.apns = apns; - } - return Object.keys(out).length > 0 ? out : undefined; -} - function coerceLinearSync(value: unknown): LinearSyncConfig | undefined { if (!isRecord(value)) return undefined; const out: LinearSyncConfig = {}; @@ -2070,8 +2046,6 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { delete providersRaw.ai; } - const notifications = coerceNotificationsConfig(value.notifications); - return { version, ...(project ? { project } : {}), @@ -2090,8 +2064,7 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { ...(ai ? { ai } : {}), ...(providersRaw && Object.keys(providersRaw).length ? { providers: providersRaw } : {}), ...(linearSync ? { linearSync } : {}), - ...(ui ? { ui } : {}), - ...(notifications ? { notifications } : {}) + ...(ui ? { ui } : {}) }; } @@ -2117,32 +2090,6 @@ function readConfigFile(filePath: string): { config: ProjectConfigFile; raw: str } } -function mergeNotificationsConfig( - shared: NotificationsConfig | undefined, - local: NotificationsConfig | undefined -): NotificationsConfig | undefined { - if (!shared && !local) return undefined; - const keyId = local?.apns?.keyId ?? shared?.apns?.keyId; - const teamId = local?.apns?.teamId ?? shared?.apns?.teamId; - const bundleId = local?.apns?.bundleId ?? shared?.apns?.bundleId; - const keyStored = local?.apns?.keyStored ?? shared?.apns?.keyStored; - const apns: NotificationApnsConfig | undefined = shared?.apns || local?.apns - ? { - enabled: local?.apns?.enabled ?? shared?.apns?.enabled ?? false, - env: local?.apns?.env ?? shared?.apns?.env ?? "sandbox", - ...(keyId ? { keyId } : {}), - ...(teamId ? { teamId } : {}), - ...(bundleId ? { bundleId } : {}), - ...(keyStored != null ? { keyStored } : {}) - } - : undefined; - return { - ...(shared ?? {}), - ...(local ?? {}), - ...(apns ? { apns } : {}) - }; -} - function coerceProjectIdentityConfig(value: unknown): ProjectIdentityConfig | undefined { if (!isRecord(value)) return undefined; if (Object.prototype.hasOwnProperty.call(value, "iconPath") && value.iconPath === null) { @@ -2170,8 +2117,7 @@ function toCanonicalYaml(config: ProjectConfigFile): string { ...(config.ai ? { ai: config.ai } : {}), ...(config.providers ? { providers: config.providers } : {}), ...(config.linearSync ? { linearSync: config.linearSync } : {}), - ...(config.ui ? { ui: config.ui } : {}), - ...(config.notifications ? { notifications: config.notifications } : {}) + ...(config.ui ? { ui: config.ui } : {}) }; return YAML.stringify(normalized, { indent: 2 }); } @@ -2199,7 +2145,6 @@ function hasSharedConfigContent(config: ProjectConfigFile): boolean { || (config.providers && Object.keys(config.providers).length > 0) || config.linearSync || config.ui - || config.notifications ); } @@ -2512,8 +2457,6 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(local.ui ?? {}), } : undefined; - const mergedNotifications = mergeNotificationsConfig(shared.notifications, local.notifications); - const environments = [...(shared.environments ?? []), ...(local.environments ?? [])]; const aiModeRaw = typeof mergedAi?.mode === "string" ? String(mergedAi.mode).trim().toLowerCase() : ""; @@ -2565,8 +2508,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(effectiveAi ? { ai: effectiveAi } : {}), ...(mergedProviders ? { providers: mergedProviders } : {}), ...(mergedLinearSync ? { linearSync: mergedLinearSync } : {}), - ...(mergedUi ? { ui: mergedUi } : {}), - ...(mergedNotifications ? { notifications: mergedNotifications } : {}) + ...(mergedUi ? { ui: mergedUi } : {}) }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5dddd068f..d91ced478 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -409,12 +409,6 @@ import type { SyncPeerDeviceType, SyncRoleSnapshot, SyncTransferReadiness, - ApnsBridgeStatus, - ApnsBridgeSaveConfigArgs, - ApnsBridgeUploadKeyArgs, - ApnsBridgeSendTestPushArgs, - ApnsBridgeSendTestPushResult, - ApnsTestPushKind, CtoGetStateArgs, CtoEnsureSessionArgs, CtoUpdateIdentityArgs, @@ -938,9 +932,6 @@ export type AppContext = { syncService?: ReturnType | null; rpcSocketServer?: NetServer; rpcSocketPath?: string; - apnsService?: import("../notifications/apnsService").ApnsService | null; - apnsKeyStore?: import("../notifications/apnsService").ApnsKeyStore | null; - notificationEventBus?: import("../notifications/notificationEventBus").NotificationEventBus | null; autoUpdateService?: ReturnType | null; updateInstallImpactProvider?: (() => Promise) | null; feedbackReporterService?: ReturnType | null; @@ -9555,553 +9546,4 @@ export function registerIpc({ getCtx().autoUpdateService?.dismissInstalledNotice(); }); - // -------------------------------------------------------------------- - // Mobile Push (APNs) — bridge for the MobilePushPanel settings UI - // -------------------------------------------------------------------- - const readApnsStatus = (): ApnsBridgeStatus => { - const ctx = getCtx(); - const effective = ctx.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - return { - enabled: apnsConfig?.enabled === true, - configured: ctx.apnsService?.isConfigured?.() === true, - keyStored: ctx.apnsKeyStore?.has?.() === true, - keyId: apnsConfig?.keyId ?? null, - teamId: apnsConfig?.teamId ?? null, - bundleId: apnsConfig?.bundleId ?? null, - env: apnsConfig?.env === "production" ? "production" : "sandbox", - }; - }; - - const saveApnsConfigToProject = (next: ApnsBridgeSaveConfigArgs): void => { - const ctx = getCtx(); - if (!ctx.projectConfigService) return; - const snapshot = ctx.projectConfigService.get(); - const shared = snapshot.shared ?? {}; - const sharedNotifications = - (shared as Record).notifications && - typeof (shared as Record).notifications === "object" - ? ((shared as Record).notifications as Record) - : {}; - ctx.projectConfigService.save({ - shared: { - ...shared, - notifications: { - ...sharedNotifications, - apns: { - enabled: next.enabled, - keyId: next.keyId, - teamId: next.teamId, - bundleId: next.bundleId, - env: next.env, - }, - }, - }, - local: snapshot.local ?? {}, - }); - }; - - ipcMain.handle(IPC.notificationsApnsGetStatus, async (): Promise => { - return readApnsStatus(); - }); - - ipcMain.handle( - IPC.notificationsApnsSaveConfig, - async (_event, args: ApnsBridgeSaveConfigArgs): Promise => { - const ctx = getCtx(); - if (!args.enabled) { - saveApnsConfigToProject(args); - await ctx.apnsService?.reset?.(); - return readApnsStatus(); - } - // Validate against any stored key before committing the new metadata so - // a failed save cannot replace a previously working APNs configuration. - if (args.enabled && ctx.apnsService && ctx.apnsKeyStore?.has()) { - const pem = ctx.apnsKeyStore.load(); - if (pem) { - try { - ctx.apnsService.configure({ - keyP8Pem: pem, - keyId: args.keyId, - teamId: args.teamId, - bundleId: args.bundleId, - env: args.env, - }); - } catch (error) { - throw new Error( - `APNs configure failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } else { - await ctx.apnsService?.reset?.(); - } - saveApnsConfigToProject(args); - return readApnsStatus(); - }, - ); - - ipcMain.handle( - IPC.notificationsApnsUploadKey, - async (_event, args: ApnsBridgeUploadKeyArgs): Promise => { - const ctx = getCtx(); - if (!ctx.apnsKeyStore) throw new Error("ApnsKeyStore unavailable."); - const trimmed = (args.p8Pem ?? "").trim(); - if (!trimmed) throw new Error("Empty .p8 payload."); - // If complete config is already persisted (second upload / rotation), - // configure first so an invalid key never replaces a working one on disk. - const effective = ctx.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - if ( - apnsConfig?.enabled && - apnsConfig.keyId && - apnsConfig.teamId && - apnsConfig.bundleId && - ctx.apnsService - ) { - try { - ctx.apnsService.configure({ - keyP8Pem: trimmed, - keyId: apnsConfig.keyId, - teamId: apnsConfig.teamId, - bundleId: apnsConfig.bundleId, - env: apnsConfig.env === "production" ? "production" : "sandbox", - }); - } catch (error) { - throw new Error( - `APNs configure failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - ctx.apnsKeyStore.save(trimmed); - return readApnsStatus(); - }, - ); - - ipcMain.handle(IPC.notificationsApnsClearKey, async (): Promise => { - const ctx = getCtx(); - ctx.apnsKeyStore?.clear?.(); - await ctx.apnsService?.reset?.(); - return readApnsStatus(); - }); - - ipcMain.handle( - IPC.notificationsApnsSendTestPush, - async (_event, args: ApnsBridgeSendTestPushArgs): Promise => { - const ctx = getCtx(); - if (!ctx.apnsService || !ctx.apnsService.isConfigured?.()) { - return { ok: false, reason: "APNs not configured. Upload a .p8 and save the config." }; - } - const registry = getOptionalSyncService()?.getDeviceRegistryService?.() ?? null; - if (!registry) return { ok: false, reason: "Device registry unavailable." }; - const effective = ctx.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - const configuredBundleId = apnsConfig?.bundleId?.trim() ?? ""; - const devices = registry - .listDevices() - .filter((d) => d.platform === "iOS" && d.deviceType === "phone"); - const kind = args.kind ?? "generic"; - - const target = args.deviceId - ? devices.find((d) => d.deviceId === args.deviceId) ?? null - : devices[0] ?? null; - if (!target) return { ok: false, reason: "No paired iOS device in the registry." }; - const meta = target.metadata ?? {}; - const deviceBundleId = - typeof meta.apnsBundleId === "string" && meta.apnsBundleId.trim().length > 0 - ? meta.apnsBundleId.trim() - : configuredBundleId; - if (!deviceBundleId) return { ok: false, reason: "No APNs bundle id found for this device or project." }; - const deviceEnv = - meta.apnsEnv === "production" - ? "production" - : meta.apnsEnv === "sandbox" - ? "sandbox" - : apnsConfig?.env === "production" - ? "production" - : "sandbox"; - - // Pick the right (token, topic, pushType, payload) quadruple based on kind. - let deviceToken: string | null; - let topic: string; - let pushType: "alert" | "liveactivity"; - let payload: Record; - - if (kind === "la_start") { - deviceToken = typeof meta.apnsActivityStartToken === "string" ? meta.apnsActivityStartToken : null; - if (!deviceToken) { - return { - ok: false, - reason: "Device has no Live Activity push-to-start token yet (iOS 17.2+ registers this shortly after launch).", - }; - } - topic = `${deviceBundleId}.push-type.liveactivity`; - pushType = "liveactivity"; - payload = buildLiveActivityStartPayload(); - } else if (kind === "la_update_running" || kind === "la_update_attention" || kind === "la_update_multi") { - const tokenMap = (meta.apnsActivityUpdateTokens ?? null) as Record | null; - const tokens = tokenMap ? Object.values(tokenMap).filter((t): t is string => typeof t === "string" && t.length > 0) : []; - deviceToken = tokens[0] ?? null; - if (!deviceToken) { - return { - ok: false, - reason: "No active Live Activity on device to update. Start one first (or fire 'Live Activity · start').", - }; - } - topic = `${deviceBundleId}.push-type.liveactivity`; - pushType = "liveactivity"; - payload = buildLiveActivityUpdatePayload(kind); - } else if (kind === "la_end") { - const tokenMap = (meta.apnsActivityUpdateTokens ?? null) as Record | null; - const tokens = tokenMap ? Object.values(tokenMap).filter((t): t is string => typeof t === "string" && t.length > 0) : []; - deviceToken = tokens[0] ?? null; - if (!deviceToken) { - return { ok: false, reason: "No active Live Activity on device to end." }; - } - topic = `${deviceBundleId}.push-type.liveactivity`; - pushType = "liveactivity"; - payload = buildLiveActivityEndPayload(); - } else { - deviceToken = typeof meta.apnsAlertToken === "string" ? meta.apnsAlertToken : null; - if (!deviceToken) { - return { - ok: false, - reason: - "Device has no APNs alert token yet. Make sure you accepted the notification permission prompt on the iOS app (Settings → Notifications → ADE → Allow).", - }; - } - topic = deviceBundleId; - pushType = "alert"; - payload = buildTestPushPayload(kind); - } - - try { - const result = await ctx.apnsService.send({ - deviceToken, - env: deviceEnv, - pushType, - topic, - priority: 10, - payload, - }); - if (result.ok) return { ok: true }; - return { ok: false, reason: result.reason ?? "APNs rejected the push." }; - } catch (error) { - return { - ok: false, - reason: error instanceof Error ? error.message : "Unknown send error.", - }; - } - }, - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Live Activity payload helpers -// ════════════════════════════════════════════════════════════════════════════ - -/** - * Swift Codable default for `Date` is seconds since 2001-01-01 00:00:00 UTC - * (NSDate reference date). Convert Unix seconds so the ContentState - * decoder on-device parses our dates correctly. - */ -const NSDATE_REFERENCE_OFFSET_SECONDS = 978_307_200; -function toNSDateSeconds(unixSeconds: number): number { - return unixSeconds - NSDATE_REFERENCE_OFFSET_SECONDS; -} - -/** - * Build a minimal valid `ContentState` matching `ADESessionAttributes.ContentState` - * on-device. `variant` selects which UI state to drive the island into. - */ -function buildContentState( - variant: "running" | "attention" | "multi", -): Record { - const nowUnix = Math.floor(Date.now() / 1000); - const nowRef = toNSDateSeconds(nowUnix); - - const sessionRunning = { - id: "test-la-claude", - providerSlug: "claude", - title: "Push test · Claude", - isAwaitingInput: false, - isFailed: false, - startedAt: nowRef - 60, - toolCalls: 4, - preview: "Reading src/auth/oauth.ts", - progress: 0.32, - }; - const sessionAwaiting = { - id: "test-la-claude", - providerSlug: "claude", - title: "Push test · Claude", - isAwaitingInput: true, - isFailed: false, - startedAt: nowRef - 120, - toolCalls: 7, - preview: "Approve 3 file writes to continue", - }; - const sessionCodex = { - id: "test-la-codex", - providerSlug: "codex", - title: "tests-fix", - isAwaitingInput: false, - isFailed: false, - startedAt: nowRef - 30, - toolCalls: 2, - }; - const sessionCto = { - id: "test-la-cto", - providerSlug: "cto", - title: "daily-review", - isAwaitingInput: false, - isFailed: false, - startedAt: nowRef - 240, - toolCalls: 11, - }; - - if (variant === "attention") { - return { - sessions: [sessionAwaiting], - attention: { - kind: "awaitingInput", - title: "Claude · Push test", - subtitle: "3 file writes need approval", - providerSlug: "claude", - sessionId: sessionAwaiting.id, - itemId: "test-item-1", - }, - failingCheckCount: 0, - awaitingReviewCount: 0, - mergeReadyCount: 0, - generatedAt: nowRef, - }; - } - if (variant === "multi") { - return { - sessions: [sessionRunning, sessionCodex, sessionCto], - attention: null, - failingCheckCount: 1, - awaitingReviewCount: 2, - mergeReadyCount: 0, - generatedAt: nowRef, - }; - } - // variant === "running" - return { - sessions: [sessionRunning], - attention: null, - failingCheckCount: 0, - awaitingReviewCount: 0, - mergeReadyCount: 0, - generatedAt: nowRef, - }; -} - -function buildLiveActivityStartPayload(): Record { - const nowUnix = Math.floor(Date.now() / 1000); - return { - aps: { - timestamp: nowUnix, - event: "start", - "attributes-type": "ADESessionAttributes", - attributes: { workspaceId: "default", workspaceName: "Test Workspace" }, - "content-state": buildContentState("running"), - "stale-date": nowUnix + 300, - "relevance-score": 100, - alert: { - title: "ADE · Live Activity started", - body: "Tap to open.", - }, - }, - }; -} - -function buildLiveActivityUpdatePayload( - kind: "la_update_running" | "la_update_attention" | "la_update_multi", -): Record { - const nowUnix = Math.floor(Date.now() / 1000); - const variant = - kind === "la_update_attention" ? "attention" : kind === "la_update_multi" ? "multi" : "running"; - return { - aps: { - timestamp: nowUnix, - event: "update", - "content-state": buildContentState(variant), - "stale-date": nowUnix + 300, - "relevance-score": variant === "attention" ? 100 : variant === "multi" ? 60 : 40, - alert: - variant === "attention" - ? { - title: "Claude · Push test", - body: "Approval needed — tap Approve/Deny in the island.", - } - : variant === "multi" - ? { title: "ADE", body: "3 chats running · 1 CI failing · 2 reviews pending" } - : { title: "Claude · Push test", body: "Reading src/auth/oauth.ts" }, - }, - }; -} - -function buildLiveActivityEndPayload(): Record { - const nowUnix = Math.floor(Date.now() / 1000); - return { - aps: { - timestamp: nowUnix, - event: "end", - "content-state": buildContentState("running"), - "dismissal-date": nowUnix + 30, - alert: { title: "ADE", body: "Live Activity ended." }, - }, - }; -} - -/** - * Build a self-contained APNs payload for each test-push category. Each - * payload is shaped to exercise the exact code path a real notification - * of that kind would go through on iOS: category identifier, mutable-content - * for the NotificationServiceExtension, thread-id for grouping, - * interruption-level, and any custom metadata the action handlers need - * (sessionId, itemId, prId, prNumber). - */ -function buildTestPushPayload(kind: ApnsTestPushKind): Record { - switch (kind) { - case "awaiting_input": - return { - aps: { - alert: { - title: "Claude · ADE mobile", - body: "3 file writes need approval before I continue.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "time-sensitive", - "relevance-score": 1.0, - "thread-id": "chat:test-approval-session:approval", - category: "CHAT_AWAITING_INPUT", - }, - providerSlug: "claude", - sessionId: "test-approval-session", - itemId: "test-item-001", - kind: "approval", - }; - case "chat_failed": - return { - aps: { - alert: { - title: "Codex · tests-fix", - body: "Session failed: rate limit exceeded after 24 tool calls.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.7, - "thread-id": "chat:test-failed-session", - category: "CHAT_FAILED", - }, - providerSlug: "codex", - sessionId: "test-failed-session", - }; - case "chat_turn_completed": - return { - aps: { - alert: { - title: "Claude · auth-refactor", - body: "Finished replying. 14 file edits, 3 new tests added.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.4, - "thread-id": "chat:test-completed-session", - category: "CHAT_TURN_COMPLETED", - }, - providerSlug: "claude", - sessionId: "test-completed-session", - }; - case "ci_failing": - return { - aps: { - alert: { - title: "PR #412 · auth-refactor", - body: "3 checks failing: lint, tsc, integration-tests.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.8, - "thread-id": "pr:412", - category: "PR_CI_FAILING", - }, - prId: "test-pr-412", - prNumber: 412, - }; - case "review_requested": - return { - aps: { - alert: { - title: "PR #408 · new-widget", - body: "alice requested your review.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.7, - "thread-id": "pr:408", - category: "PR_REVIEW_REQUESTED", - }, - prId: "test-pr-408", - prNumber: 408, - }; - case "merge_ready": - return { - aps: { - alert: { - title: "PR #401 · refactor-auth", - body: "All checks passed and approved. Ready to merge.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.6, - "thread-id": "pr:401", - category: "PR_MERGE_READY", - }, - prId: "test-pr-401", - prNumber: 401, - }; - case "cto_subagent_finished": - return { - aps: { - alert: { - title: "CTO · daily-review", - body: "Sub-agent 'Lint cleanup' finished (3 PRs opened).", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.5, - "thread-id": "cto:test-subagent", - category: "CTO_SUBAGENT_FINISHED", - }, - providerSlug: "cto", - }; - case "generic": - default: - return { - aps: { - alert: { - title: "ADE", - body: "Mobile push is working. Tap to open ADE.", - }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.5, - category: "SYSTEM_ALERT", - }, - providerSlug: "ade", - testPush: true, - }; - } } diff --git a/apps/desktop/src/main/services/notifications/apnsBridgeService.test.ts b/apps/desktop/src/main/services/notifications/apnsBridgeService.test.ts deleted file mode 100644 index 1de5c990d..000000000 --- a/apps/desktop/src/main/services/notifications/apnsBridgeService.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { - ApnsBridgeSaveConfigArgs, - ApnsBridgeUploadKeyArgs, -} from "../../../shared/types/sync"; -import { createApnsBridgeService } from "./apnsBridgeService"; -import type { ApnsEnvelope, ApnsSendResult } from "./apnsService"; - -type ApnsConfigSnapshot = { - enabled: boolean; - keyId: string; - teamId: string; - bundleId: string; - env: "sandbox" | "production"; -}; - -function makeProjectConfigService(initial: { - apns?: Partial | null; - shared?: Record; - local?: Record; -}) { - const state = { - shared: initial.shared ?? {}, - local: initial.local ?? {}, - apns: initial.apns ?? null, - }; - const save = vi.fn( - ( - next: { shared: Record; local: Record }, - ) => { - state.shared = next.shared; - state.local = next.local; - const notifications = - next.shared.notifications as Record | undefined; - const apns = notifications?.apns as Partial | undefined; - state.apns = apns ?? null; - }, - ); - const get = vi.fn(() => ({ - shared: state.shared, - local: state.local, - effective: { - notifications: state.apns ? { apns: state.apns } : undefined, - }, - })); - return { get, save, _state: state }; -} - -function makeKeyStore(initialPem: string | null = null) { - let stored = initialPem; - return { - has: vi.fn(() => stored !== null), - load: vi.fn(() => stored), - save: vi.fn((pem: string) => { - stored = pem; - }), - clear: vi.fn(() => { - stored = null; - }), - }; -} - -function makeApnsService(overrides?: { - isConfigured?: boolean; - sendResult?: ApnsSendResult; - configureThrows?: Error; -}) { - return { - configure: vi.fn(() => { - if (overrides?.configureThrows) { - throw overrides.configureThrows; - } - }), - isConfigured: vi.fn(() => overrides?.isConfigured ?? false), - reset: vi.fn(async () => {}), - send: vi.fn( - async (_envelope: ApnsEnvelope): Promise => - overrides?.sendResult ?? { ok: true, status: 200 }, - ), - }; -} - -function makeDeviceRegistry(devices: any[]) { - return { - listDevices: vi.fn(() => devices), - }; -} - -const baseConfig: ApnsBridgeSaveConfigArgs = { - enabled: true, - keyId: "ABCDE12345", - teamId: "12345ABCDE", - bundleId: "com.ade.ios", - env: "sandbox", -}; - -describe("apnsBridgeService.getStatus", () => { - it("reflects enabled/configured/keyStored independently from the underlying services", async () => { - const projectConfigService = makeProjectConfigService({ - apns: { ...baseConfig }, - }); - const apnsKeyStore = makeKeyStore("PEM"); - const apnsService = makeApnsService({ isConfigured: true }); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - await expect(bridge.getStatus()).resolves.toEqual({ - enabled: true, - configured: true, - keyStored: true, - keyId: "ABCDE12345", - teamId: "12345ABCDE", - bundleId: "com.ade.ios", - env: "sandbox", - }); - }); - - it("defaults to a sandbox/disabled status when no project config exists", async () => { - const bridge = createApnsBridgeService({ - projectConfigService: null, - apnsService: null, - apnsKeyStore: null, - }); - - await expect(bridge.getStatus()).resolves.toEqual({ - enabled: false, - configured: false, - keyStored: false, - keyId: null, - teamId: null, - bundleId: null, - env: "sandbox", - }); - }); - - it("coerces unrecognized env values to sandbox", async () => { - const projectConfigService = makeProjectConfigService({ - apns: { ...baseConfig, env: "weird-env" as any }, - }); - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: null, - apnsKeyStore: null, - }); - - await expect(bridge.getStatus()).resolves.toMatchObject({ env: "sandbox" }); - }); -}); - -describe("apnsBridgeService.saveConfig", () => { - it("disables and resets the apns service without configuring it", async () => { - const projectConfigService = makeProjectConfigService({ - apns: { ...baseConfig, enabled: true }, - }); - const apnsKeyStore = makeKeyStore("PEM"); - const apnsService = makeApnsService({ isConfigured: true }); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - const result = await bridge.saveConfig({ ...baseConfig, enabled: false }); - - expect(apnsService.configure).not.toHaveBeenCalled(); - expect(apnsService.reset).toHaveBeenCalledTimes(1); - expect(projectConfigService.save).toHaveBeenCalledTimes(1); - expect(result.enabled).toBe(false); - }); - - it("configures the apns service when enabling with a stored key", async () => { - const projectConfigService = makeProjectConfigService({}); - const apnsKeyStore = makeKeyStore("PEM-DATA"); - const apnsService = makeApnsService({ isConfigured: true }); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - await bridge.saveConfig(baseConfig); - - expect(apnsService.configure).toHaveBeenCalledWith({ - keyP8Pem: "PEM-DATA", - keyId: "ABCDE12345", - teamId: "12345ABCDE", - bundleId: "com.ade.ios", - env: "sandbox", - }); - expect(projectConfigService.save).toHaveBeenCalledTimes(1); - }); - - it("resets the apns service when enabling without a stored key", async () => { - const projectConfigService = makeProjectConfigService({}); - const apnsKeyStore = makeKeyStore(null); - const apnsService = makeApnsService(); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - await bridge.saveConfig(baseConfig); - - expect(apnsService.configure).not.toHaveBeenCalled(); - expect(apnsService.reset).toHaveBeenCalledTimes(1); - expect(projectConfigService.save).toHaveBeenCalledTimes(1); - }); - - it("wraps a configure() failure with a contextual error message", async () => { - const projectConfigService = makeProjectConfigService({}); - const apnsKeyStore = makeKeyStore("PEM"); - const apnsService = makeApnsService({ - configureThrows: new Error("bad keyId"), - }); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - await expect(bridge.saveConfig(baseConfig)).rejects.toThrow( - /APNs configure failed: bad keyId/, - ); - expect(projectConfigService.save).not.toHaveBeenCalled(); - }); -}); - -describe("apnsBridgeService.uploadKey", () => { - it("refuses to upload an empty .p8 payload", async () => { - const apnsKeyStore = makeKeyStore(null); - const bridge = createApnsBridgeService({ - projectConfigService: null, - apnsService: null, - apnsKeyStore: apnsKeyStore as any, - }); - - await expect(bridge.uploadKey({ p8Pem: " " })).rejects.toThrow(/Empty/); - expect(apnsKeyStore.save).not.toHaveBeenCalled(); - }); - - it("refuses to upload when the keystore is unavailable", async () => { - const bridge = createApnsBridgeService({ - projectConfigService: null, - apnsService: null, - apnsKeyStore: null, - }); - - await expect( - bridge.uploadKey({ p8Pem: "-----BEGIN PRIVATE KEY-----" } as ApnsBridgeUploadKeyArgs), - ).rejects.toThrow(/ApnsKeyStore unavailable/); - }); - - it("hot-reconfigures the apns service when a project is already enabled", async () => { - const projectConfigService = makeProjectConfigService({ - apns: { ...baseConfig, enabled: true, env: "production" }, - }); - const apnsKeyStore = makeKeyStore(null); - const apnsService = makeApnsService(); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - await bridge.uploadKey({ p8Pem: " PEM-PAYLOAD " }); - - expect(apnsService.configure).toHaveBeenCalledWith({ - keyP8Pem: "PEM-PAYLOAD", - keyId: "ABCDE12345", - teamId: "12345ABCDE", - bundleId: "com.ade.ios", - env: "production", - }); - expect(apnsKeyStore.save).toHaveBeenCalledWith("PEM-PAYLOAD"); - }); - - it("stores the key without configuring when the project is disabled", async () => { - const projectConfigService = makeProjectConfigService({ - apns: { ...baseConfig, enabled: false }, - }); - const apnsKeyStore = makeKeyStore(null); - const apnsService = makeApnsService(); - - const bridge = createApnsBridgeService({ - projectConfigService: projectConfigService as any, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - await bridge.uploadKey({ p8Pem: "PEM-PAYLOAD" }); - - expect(apnsService.configure).not.toHaveBeenCalled(); - expect(apnsKeyStore.save).toHaveBeenCalledWith("PEM-PAYLOAD"); - }); -}); - -describe("apnsBridgeService.clearKey", () => { - it("clears the keystore and resets the apns service", async () => { - const apnsKeyStore = makeKeyStore("PEM"); - const apnsService = makeApnsService({ isConfigured: true }); - - const bridge = createApnsBridgeService({ - projectConfigService: null, - apnsService: apnsService as any, - apnsKeyStore: apnsKeyStore as any, - }); - - const status = await bridge.clearKey(); - - expect(apnsKeyStore.clear).toHaveBeenCalledTimes(1); - expect(apnsService.reset).toHaveBeenCalledTimes(1); - expect(status.keyStored).toBe(false); - }); -}); - -describe("apnsBridgeService.sendTestPush", () => { - function withConfig(extra?: Partial) { - return makeProjectConfigService({ - apns: { ...baseConfig, ...extra }, - }); - } - - function iosDevice(metadata: Record) { - return { - deviceId: "iphone-1", - platform: "iOS", - deviceType: "phone", - metadata, - }; - } - - it("returns ok=false when the apns service is not configured", async () => { - const apnsService = makeApnsService({ isConfigured: false }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => makeDeviceRegistry([]) as any, - }); - - const result = await bridge.sendTestPush({}); - expect(result.ok).toBe(false); - expect(result.reason).toMatch(/not configured/i); - expect(apnsService.send).not.toHaveBeenCalled(); - }); - - it("returns ok=false when no iOS devices are registered", async () => { - const apnsService = makeApnsService({ isConfigured: true }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([ - { deviceId: "mac-1", platform: "macOS", deviceType: "desktop", metadata: {} }, - { deviceId: "ipad-1", platform: "iOS", deviceType: "tablet", metadata: {} }, - ]) as any, - }); - - const result = await bridge.sendTestPush({}); - expect(result.ok).toBe(false); - expect(result.reason).toMatch(/no paired ios device/i); - }); - - it("requires an alert token when sending a generic alert push", async () => { - const apnsService = makeApnsService({ isConfigured: true }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([iosDevice({})]) as any, - }); - - const result = await bridge.sendTestPush({ kind: "generic" }); - expect(result.ok).toBe(false); - expect(result.reason).toMatch(/no APNs alert token/i); - expect(apnsService.send).not.toHaveBeenCalled(); - }); - - it("sends a generic alert push to the device's bundle id with priority 10", async () => { - const apnsService = makeApnsService({ - isConfigured: true, - sendResult: { ok: true, status: 200 }, - }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig({ env: "production" }) as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([ - iosDevice({ - apnsAlertToken: "alert-token", - apnsBundleId: "com.device.override", - apnsEnv: "sandbox", - }), - ]) as any, - }); - - const result = await bridge.sendTestPush({ kind: "generic" }); - - expect(result).toEqual({ ok: true }); - expect(apnsService.send).toHaveBeenCalledTimes(1); - const envelope = apnsService.send.mock.calls[0]![0]; - expect(envelope.deviceToken).toBe("alert-token"); - expect(envelope.topic).toBe("com.device.override"); - expect(envelope.pushType).toBe("alert"); - expect(envelope.env).toBe("sandbox"); - expect(envelope.priority).toBe(10); - expect(envelope.payload).toMatchObject({ - aps: expect.objectContaining({ category: "SYSTEM_ALERT" }), - }); - }); - - it("routes la_start to the activity-start token and the .push-type.liveactivity topic", async () => { - const apnsService = makeApnsService({ - isConfigured: true, - sendResult: { ok: true, status: 200 }, - }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([ - iosDevice({ - apnsActivityStartToken: "la-start-token", - }), - ]) as any, - }); - - const result = await bridge.sendTestPush({ kind: "la_start" }); - - expect(result).toEqual({ ok: true }); - const envelope = apnsService.send.mock.calls[0]![0]; - expect(envelope.pushType).toBe("liveactivity"); - expect(envelope.topic).toBe("com.ade.ios.push-type.liveactivity"); - expect(envelope.deviceToken).toBe("la-start-token"); - expect((envelope.payload as any).aps.event).toBe("start"); - }); - - it("rejects la_update_running when the device has no active Live Activity token", async () => { - const apnsService = makeApnsService({ isConfigured: true }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([ - iosDevice({ apnsActivityStartToken: "start-only" }), - ]) as any, - }); - - const result = await bridge.sendTestPush({ kind: "la_update_running" }); - expect(result.ok).toBe(false); - expect(result.reason).toMatch(/no active live activity/i); - expect(apnsService.send).not.toHaveBeenCalled(); - }); - - it("ends a live activity using an update token and emits an event=end payload", async () => { - const apnsService = makeApnsService({ - isConfigured: true, - sendResult: { ok: true, status: 200 }, - }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([ - iosDevice({ - apnsActivityUpdateTokens: { "session-1": "update-token-1" }, - }), - ]) as any, - }); - - const result = await bridge.sendTestPush({ kind: "la_end" }); - expect(result).toEqual({ ok: true }); - const envelope = apnsService.send.mock.calls[0]![0]; - expect(envelope.deviceToken).toBe("update-token-1"); - expect((envelope.payload as any).aps.event).toBe("end"); - }); - - it("propagates apns send rejections as ok=false with the upstream reason", async () => { - const apnsService = makeApnsService({ - isConfigured: true, - sendResult: { ok: false, status: 410, reason: "BadDeviceToken" }, - }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => - makeDeviceRegistry([ - iosDevice({ apnsAlertToken: "alert-token" }), - ]) as any, - }); - - const result = await bridge.sendTestPush({ kind: "generic" }); - expect(result).toEqual({ ok: false, reason: "BadDeviceToken" }); - }); - - it("returns ok=false when no device registry is available", async () => { - const apnsService = makeApnsService({ isConfigured: true }); - const bridge = createApnsBridgeService({ - projectConfigService: withConfig() as any, - apnsService: apnsService as any, - apnsKeyStore: makeKeyStore() as any, - getDeviceRegistryService: () => null, - }); - - const result = await bridge.sendTestPush({}); - expect(result.ok).toBe(false); - expect(result.reason).toMatch(/device registry unavailable/i); - }); -}); diff --git a/apps/desktop/src/main/services/notifications/apnsBridgeService.ts b/apps/desktop/src/main/services/notifications/apnsBridgeService.ts deleted file mode 100644 index 06aa16a23..000000000 --- a/apps/desktop/src/main/services/notifications/apnsBridgeService.ts +++ /dev/null @@ -1,460 +0,0 @@ -import type { - ApnsBridgeSaveConfigArgs, - ApnsBridgeSendTestPushArgs, - ApnsBridgeSendTestPushResult, - ApnsBridgeStatus, - ApnsBridgeUploadKeyArgs, - ApnsTestPushKind, -} from "../../../shared/types/sync"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { DeviceRegistryService } from "../sync/deviceRegistryService"; -import type { ApnsService, ApnsKeyStore } from "./apnsService"; - -type ApnsBridgeServiceArgs = { - projectConfigService: ReturnType | null | undefined; - apnsService: ApnsService | null | undefined; - apnsKeyStore: ApnsKeyStore | null | undefined; - getDeviceRegistryService?: () => DeviceRegistryService | null | undefined; -}; - -export function createApnsBridgeService(args: ApnsBridgeServiceArgs) { - const readStatus = (): ApnsBridgeStatus => { - const effective = args.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - return { - enabled: apnsConfig?.enabled === true, - configured: args.apnsService?.isConfigured?.() === true, - keyStored: args.apnsKeyStore?.has?.() === true, - keyId: apnsConfig?.keyId ?? null, - teamId: apnsConfig?.teamId ?? null, - bundleId: apnsConfig?.bundleId ?? null, - env: apnsConfig?.env === "production" ? "production" : "sandbox", - }; - }; - - const saveConfigToProject = (next: ApnsBridgeSaveConfigArgs): void => { - if (!args.projectConfigService) return; - const snapshot = args.projectConfigService.get(); - const shared = snapshot.shared ?? {}; - const sharedRecord = shared as Record; - const sharedNotifications = - sharedRecord.notifications && typeof sharedRecord.notifications === "object" - ? (sharedRecord.notifications as Record) - : {}; - args.projectConfigService.save({ - shared: { - ...shared, - notifications: { - ...sharedNotifications, - apns: { - enabled: next.enabled, - keyId: next.keyId, - teamId: next.teamId, - bundleId: next.bundleId, - env: next.env, - }, - }, - }, - local: snapshot.local ?? {}, - }); - }; - - return { - async getStatus(): Promise { - return readStatus(); - }, - - async saveConfig(next: ApnsBridgeSaveConfigArgs): Promise { - if (!next.enabled) { - saveConfigToProject(next); - await args.apnsService?.reset?.(); - return readStatus(); - } - if (args.apnsService && args.apnsKeyStore?.has()) { - const pem = args.apnsKeyStore.load(); - if (pem) { - try { - args.apnsService.configure({ - keyP8Pem: pem, - keyId: next.keyId, - teamId: next.teamId, - bundleId: next.bundleId, - env: next.env, - }); - } catch (error) { - throw new Error( - `APNs configure failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } else { - await args.apnsService?.reset?.(); - } - saveConfigToProject(next); - return readStatus(); - }, - - async uploadKey(next: ApnsBridgeUploadKeyArgs): Promise { - if (!args.apnsKeyStore) throw new Error("ApnsKeyStore unavailable."); - const trimmed = (next.p8Pem ?? "").trim(); - if (!trimmed) throw new Error("Empty .p8 payload."); - const effective = args.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - if ( - apnsConfig?.enabled && - apnsConfig.keyId && - apnsConfig.teamId && - apnsConfig.bundleId && - args.apnsService - ) { - try { - args.apnsService.configure({ - keyP8Pem: trimmed, - keyId: apnsConfig.keyId, - teamId: apnsConfig.teamId, - bundleId: apnsConfig.bundleId, - env: apnsConfig.env === "production" ? "production" : "sandbox", - }); - } catch (error) { - throw new Error( - `APNs configure failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - args.apnsKeyStore.save(trimmed); - return readStatus(); - }, - - async clearKey(): Promise { - args.apnsKeyStore?.clear?.(); - await args.apnsService?.reset?.(); - return readStatus(); - }, - - async sendTestPush(next: ApnsBridgeSendTestPushArgs): Promise { - if (!args.apnsService || !args.apnsService.isConfigured?.()) { - return { ok: false, reason: "APNs not configured. Upload a .p8 and save the config." }; - } - const registry = args.getDeviceRegistryService?.() ?? null; - if (!registry) return { ok: false, reason: "Device registry unavailable." }; - const effective = args.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - const configuredBundleId = apnsConfig?.bundleId?.trim() ?? ""; - const devices = registry - .listDevices() - .filter((device) => device.platform === "iOS" && device.deviceType === "phone"); - const kind = next.kind ?? "generic"; - const target = next.deviceId - ? devices.find((device) => device.deviceId === next.deviceId) ?? null - : devices[0] ?? null; - if (!target) return { ok: false, reason: "No paired iOS device in the registry." }; - const meta = target.metadata ?? {}; - const deviceBundleId = - typeof meta.apnsBundleId === "string" && meta.apnsBundleId.trim().length > 0 - ? meta.apnsBundleId.trim() - : configuredBundleId; - if (!deviceBundleId) return { ok: false, reason: "No APNs bundle id found for this device or project." }; - const deviceEnv: "production" | "sandbox" = - meta.apnsEnv === "production" || meta.apnsEnv === "sandbox" - ? meta.apnsEnv - : apnsConfig?.env === "production" ? "production" : "sandbox"; - - const laTopic = `${deviceBundleId}.push-type.liveactivity`; - - const resolveActivityUpdateToken = (): string | null => { - const tokens = Object.values((meta.apnsActivityUpdateTokens ?? {}) as Record) - .filter((token): token is string => typeof token === "string" && token.length > 0); - return tokens[0] ?? null; - }; - - let deviceToken: string | null; - let topic: string; - let pushType: "alert" | "liveactivity"; - let payload: Record; - if (kind === "la_start") { - deviceToken = typeof meta.apnsActivityStartToken === "string" ? meta.apnsActivityStartToken : null; - if (!deviceToken) { - return { ok: false, reason: "Device has no Live Activity push-to-start token yet (iOS 17.2+ registers this shortly after launch)." }; - } - topic = laTopic; - pushType = "liveactivity"; - payload = buildLiveActivityStartPayload(); - } else if (kind === "la_update_running" || kind === "la_update_attention" || kind === "la_update_multi") { - deviceToken = resolveActivityUpdateToken(); - if (!deviceToken) return { ok: false, reason: "No active Live Activity on device to update. Start one first (or fire 'Live Activity - start')." }; - topic = laTopic; - pushType = "liveactivity"; - payload = buildLiveActivityUpdatePayload(kind); - } else if (kind === "la_end") { - deviceToken = resolveActivityUpdateToken(); - if (!deviceToken) return { ok: false, reason: "No active Live Activity on device to end." }; - topic = laTopic; - pushType = "liveactivity"; - payload = buildLiveActivityEndPayload(); - } else { - deviceToken = typeof meta.apnsAlertToken === "string" ? meta.apnsAlertToken : null; - if (!deviceToken) { - return { ok: false, reason: "Device has no APNs alert token yet. Make sure you accepted the notification permission prompt on the iOS app (Settings -> Notifications -> ADE -> Allow)." }; - } - topic = deviceBundleId; - pushType = "alert"; - payload = buildTestPushPayload(kind); - } - - try { - const result = await args.apnsService.send({ - deviceToken, - env: deviceEnv, - pushType, - topic, - priority: 10, - payload, - }); - if (result.ok) return { ok: true }; - return { ok: false, reason: result.reason ?? "APNs rejected the push." }; - } catch (error) { - return { ok: false, reason: error instanceof Error ? error.message : "Unknown send error." }; - } - }, - }; -} - -const NSDATE_REFERENCE_OFFSET_SECONDS = 978_307_200; -function toNSDateSeconds(unixSeconds: number): number { - return unixSeconds - NSDATE_REFERENCE_OFFSET_SECONDS; -} - -function buildContentState(variant: "running" | "attention" | "multi"): Record { - const nowRef = toNSDateSeconds(Math.floor(Date.now() / 1000)); - const sessionRunning = { - id: "test-la-claude", - providerSlug: "claude", - title: "Push test - Claude", - isAwaitingInput: false, - isFailed: false, - startedAt: nowRef - 60, - toolCalls: 4, - preview: "Reading src/auth/oauth.ts", - progress: 0.32, - }; - const sessionAwaiting = { - ...sessionRunning, - isAwaitingInput: true, - startedAt: nowRef - 120, - toolCalls: 7, - preview: "Approve 3 file writes to continue", - }; - const sessionCodex = { - id: "test-la-codex", - providerSlug: "codex", - title: "tests-fix", - isAwaitingInput: false, - isFailed: false, - startedAt: nowRef - 30, - toolCalls: 2, - }; - const sessionCto = { - id: "test-la-cto", - providerSlug: "cto", - title: "daily-review", - isAwaitingInput: false, - isFailed: false, - startedAt: nowRef - 240, - toolCalls: 11, - }; - if (variant === "attention") { - return { - sessions: [sessionAwaiting], - attention: { - kind: "awaitingInput", - title: "Claude - Push test", - subtitle: "3 file writes need approval", - providerSlug: "claude", - sessionId: sessionAwaiting.id, - itemId: "test-item-1", - }, - failingCheckCount: 0, - awaitingReviewCount: 0, - mergeReadyCount: 0, - generatedAt: nowRef, - }; - } - if (variant === "multi") { - return { - sessions: [sessionRunning, sessionCodex, sessionCto], - attention: null, - failingCheckCount: 1, - awaitingReviewCount: 2, - mergeReadyCount: 1, - generatedAt: nowRef, - }; - } - return { - sessions: [sessionRunning], - attention: null, - failingCheckCount: 0, - awaitingReviewCount: 0, - mergeReadyCount: 0, - generatedAt: nowRef, - }; -} - -function buildLiveActivityStartPayload(): Record { - const nowUnix = Math.floor(Date.now() / 1000); - return { - aps: { - timestamp: nowUnix, - event: "start", - "attributes-type": "ADESessionAttributes", - attributes: { workspaceId: "default", workspaceName: "Test Workspace" }, - "content-state": buildContentState("running"), - "stale-date": nowUnix + 300, - "relevance-score": 100, - alert: { title: "ADE - Live Activity started", body: "Tap to open." }, - }, - }; -} - -function buildLiveActivityUpdatePayload(kind: "la_update_running" | "la_update_attention" | "la_update_multi"): Record { - const nowUnix = Math.floor(Date.now() / 1000); - const variants: Record = { - la_update_attention: { variant: "attention", relevanceScore: 100, alert: { title: "Claude - Push test", body: "Approval needed - tap Approve/Deny in the island." } }, - la_update_multi: { variant: "multi", relevanceScore: 60, alert: { title: "ADE", body: "3 chats running - 1 CI failing - 2 reviews pending" } }, - la_update_running: { variant: "running", relevanceScore: 40, alert: { title: "Claude - Push test", body: "Reading src/auth/oauth.ts" } }, - }; - const { variant, relevanceScore, alert } = variants[kind]; - return { - aps: { - timestamp: nowUnix, - event: "update", - "content-state": buildContentState(variant), - "stale-date": nowUnix + 300, - "relevance-score": relevanceScore, - alert, - }, - }; -} - -function buildLiveActivityEndPayload(): Record { - const nowUnix = Math.floor(Date.now() / 1000); - return { - aps: { - timestamp: nowUnix, - event: "end", - "content-state": buildContentState("running"), - "dismissal-date": nowUnix + 30, - alert: { title: "ADE", body: "Live Activity ended." }, - }, - }; -} - -function buildTestPushPayload(kind: ApnsTestPushKind): Record { - const payloads: Record, Record> = { - awaiting_input: { - aps: { - alert: { title: "Claude - ADE mobile", body: "3 file writes need approval before I continue." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "time-sensitive", - "relevance-score": 1.0, - "thread-id": "chat:test-approval-session:approval", - category: "CHAT_AWAITING_INPUT", - }, - providerSlug: "claude", - sessionId: "test-approval-session", - itemId: "test-item-001", - kind: "approval", - }, - chat_failed: { - aps: { - alert: { title: "Codex - tests-fix", body: "Session failed: rate limit exceeded after 24 tool calls." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.7, - "thread-id": "chat:test-failed-session", - category: "CHAT_FAILED", - }, - providerSlug: "codex", - sessionId: "test-failed-session", - }, - chat_turn_completed: { - aps: { - alert: { title: "Claude - auth-refactor", body: "Finished replying. 14 file edits, 3 new tests added." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.4, - "thread-id": "chat:test-completed-session", - category: "CHAT_TURN_COMPLETED", - }, - providerSlug: "claude", - sessionId: "test-completed-session", - }, - ci_failing: { - aps: { - alert: { title: "PR #412 - auth-refactor", body: "3 checks failing: lint, tsc, integration-tests." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.8, - "thread-id": "pr:412", - category: "PR_CI_FAILING", - }, - prId: "test-pr-412", - prNumber: 412, - }, - review_requested: { - aps: { - alert: { title: "PR #408 - new-widget", body: "alice requested your review." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.7, - "thread-id": "pr:408", - category: "PR_REVIEW_REQUESTED", - }, - prId: "test-pr-408", - prNumber: 408, - }, - merge_ready: { - aps: { - alert: { title: "PR #401 - refactor-auth", body: "All checks passed and approved. Ready to merge." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.6, - "thread-id": "pr:401", - category: "PR_MERGE_READY", - }, - prId: "test-pr-401", - prNumber: 401, - }, - cto_subagent_finished: { - aps: { - alert: { title: "CTO - daily-review", body: "Sub-agent 'Lint cleanup' finished (3 PRs opened)." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.5, - "thread-id": "cto:test-subagent", - category: "CTO_SUBAGENT_FINISHED", - }, - providerSlug: "cto", - }, - generic: { - aps: { - alert: { title: "ADE", body: "Mobile push is working. Tap to open ADE." }, - sound: "default", - "mutable-content": 1, - "interruption-level": "active", - "relevance-score": 0.5, - category: "SYSTEM_ALERT", - }, - providerSlug: "ade", - testPush: true, - }, - }; - if (kind in payloads) return payloads[kind as keyof typeof payloads]; - return payloads.generic; -} diff --git a/apps/desktop/src/main/services/notifications/apnsService.test.ts b/apps/desktop/src/main/services/notifications/apnsService.test.ts deleted file mode 100644 index d7a428ef7..000000000 --- a/apps/desktop/src/main/services/notifications/apnsService.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { generateKeyPairSync } from "node:crypto"; -import { ApnsService, type ApnsTransport, signApnsJwt } from "./apnsService"; - -function createLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any; -} - -/** Generates a throwaway EC P-256 key PEM in PKCS#8 form (the format .p8 files use). */ -function makeP8Pem(): string { - const { privateKey } = generateKeyPairSync("ec", { - namedCurve: "P-256", - privateKeyEncoding: { type: "pkcs8", format: "pem" }, - publicKeyEncoding: { type: "spki", format: "pem" }, - }); - return privateKey as string; -} - -function createTransport(): { - transport: ApnsTransport; - requests: Array<{ host: string; headers: Record; path: string; body: string }>; - queue: Array<{ status: number; body: string; headers?: Record }>; -} { - const requests: Array<{ host: string; headers: Record; path: string; body: string }> = []; - const queue: Array<{ status: number; body: string; headers?: Record }> = []; - const transport: ApnsTransport = { - async send(args) { - requests.push({ host: args.host, headers: args.headers, path: args.path, body: args.body.toString("utf8") }); - const next = queue.shift() ?? { status: 200, body: "" }; - return { status: next.status, body: next.body, headers: next.headers ?? { "apns-id": "abc-123" } }; - }, - async close() { - /* no-op */ - }, - }; - return { transport, requests, queue }; -} - -describe("signApnsJwt", () => { - it("produces a 3-segment compact JWS", () => { - const pem = makeP8Pem(); - const token = signApnsJwt({ keyPem: pem, keyId: "ABCDE12345", teamId: "12345ABCDE", issuedAtSeconds: 1_700_000_000 }); - const parts = token.split("."); - expect(parts).toHaveLength(3); - const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")); - expect(header.alg).toBe("ES256"); - expect(header.kid).toBe("ABCDE12345"); - const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); - expect(claims.iss).toBe("12345ABCDE"); - }); - - it("rejects garbage PEM", () => { - expect(() => - signApnsJwt({ keyPem: "not a key", keyId: "ABCDE12345", teamId: "12345ABCDE", issuedAtSeconds: 0 }), - ).toThrow(); - }); -}); - -describe("ApnsService", () => { - const configureArgs = { - keyP8Pem: "", - keyId: "ABCDE12345", - teamId: "12345ABCDE", - bundleId: "com.ade.ios", - env: "sandbox" as const, - }; - - function build() { - const { transport, requests, queue } = createTransport(); - const service = new ApnsService({ logger: createLogger(), transport, now: () => 1_700_000_000_000 }); - service.configure({ ...configureArgs, keyP8Pem: makeP8Pem() }); - return { service, transport, requests, queue }; - } - - it("throws on send() before configure()", async () => { - const service = new ApnsService({ logger: createLogger(), transport: createTransport().transport }); - await expect( - service.send({ deviceToken: "aaaa", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }), - ).rejects.toThrow(/not configured/i); - }); - - it("rejects malformed keyId / teamId", () => { - const service = new ApnsService({ logger: createLogger(), transport: createTransport().transport }); - expect(() => service.configure({ ...configureArgs, keyP8Pem: makeP8Pem(), keyId: "bad" })).toThrow(); - expect(() => service.configure({ ...configureArgs, keyP8Pem: makeP8Pem(), teamId: "bad" })).toThrow(); - }); - - it("sends to sandbox host with correct APNs headers", async () => { - const { service, requests } = build(); - const result = await service.send({ - deviceToken: "deadbeef", - pushType: "alert", - topic: "com.ade.ios", - priority: 10, - payload: { aps: { alert: { title: "t", body: "b" } } }, - collapseId: "cid", - }); - expect(result.ok).toBe(true); - expect(requests).toHaveLength(1); - expect(requests[0].host).toBe("api.sandbox.push.apple.com"); - expect(requests[0].path).toBe("/3/device/deadbeef"); - expect(requests[0].headers["apns-topic"]).toBe("com.ade.ios"); - expect(requests[0].headers["apns-push-type"]).toBe("alert"); - expect(requests[0].headers["apns-priority"]).toBe(10); - expect(requests[0].headers["apns-collapse-id"]).toBe("cid"); - expect(String(requests[0].headers.authorization)).toMatch(/^bearer /); - }); - - it("emits tokenInvalidated when APNs reports BadDeviceToken", async () => { - const { service, queue } = build(); - queue.push({ status: 400, body: JSON.stringify({ reason: "BadDeviceToken" }) }); - const received: string[] = []; - service.onTokenInvalidated((event) => received.push(event.reason)); - const result = await service.send({ - deviceToken: "badbad", - pushType: "alert", - topic: "com.ade.ios", - priority: 10, - payload: {}, - }); - expect(result.ok).toBe(false); - expect(result.reason).toBe("BadDeviceToken"); - expect(received).toEqual(["BadDeviceToken"]); - }); - - it("does NOT emit tokenInvalidated for transient errors", async () => { - const { service, queue } = build(); - queue.push({ status: 429, body: JSON.stringify({ reason: "TooManyRequests" }) }); - const received: string[] = []; - service.onTokenInvalidated((event) => received.push(event.reason)); - const result = await service.send({ - deviceToken: "abc", - pushType: "alert", - topic: "com.ade.ios", - priority: 10, - payload: {}, - }); - expect(result.ok).toBe(false); - expect(result.reason).toBe("TooManyRequests"); - expect(received).toEqual([]); - }); - - it("reuses the same JWT within the 50-min window, re-mints after", async () => { - let currentMs = 1_700_000_000_000; - const { transport, requests } = createTransport(); - const service = new ApnsService({ logger: createLogger(), transport, now: () => currentMs }); - service.configure({ ...configureArgs, keyP8Pem: makeP8Pem() }); - - await service.send({ deviceToken: "t1", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }); - const jwt1 = String(requests[0].headers.authorization); - - currentMs += 10 * 60 * 1000; // 10 min - await service.send({ deviceToken: "t2", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }); - const jwt2 = String(requests[1].headers.authorization); - expect(jwt2).toBe(jwt1); - - currentMs += 60 * 60 * 1000; // +60 min - await service.send({ deviceToken: "t3", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }); - const jwt3 = String(requests[2].headers.authorization); - expect(jwt3).not.toBe(jwt1); - }); - - it("uses production host when env is production", async () => { - const { transport, requests } = createTransport(); - const service = new ApnsService({ logger: createLogger(), transport }); - service.configure({ ...configureArgs, keyP8Pem: makeP8Pem(), env: "production" }); - await service.send({ deviceToken: "t", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }); - expect(requests[0].host).toBe("api.push.apple.com"); - }); - - it("uses per-envelope env when it differs from configured default", async () => { - const { service, requests } = build(); - await service.send({ - deviceToken: "t", - env: "production", - pushType: "alert", - topic: "com.ade.ios", - priority: 10, - payload: {}, - }); - expect(requests[0].host).toBe("api.push.apple.com"); - }); - - it("forces JWT re-mint after ExpiredProviderToken", async () => { - const { transport, requests, queue } = createTransport(); - const service = new ApnsService({ logger: createLogger(), transport, now: () => 1 }); - service.configure({ ...configureArgs, keyP8Pem: makeP8Pem() }); - await service.send({ deviceToken: "t1", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }); - const jwtA = String(requests[0].headers.authorization); - - queue.push({ status: 403, body: JSON.stringify({ reason: "ExpiredProviderToken" }) }); - const result = await service.send({ deviceToken: "t2", pushType: "alert", topic: "com.ade.ios", priority: 10, payload: {} }); - - expect(result.ok).toBe(true); - expect(requests).toHaveLength(3); - expect(String(requests[1].headers.authorization)).toBe(jwtA); - expect(String(requests[2].headers.authorization)).not.toBe(jwtA); - }); -}); diff --git a/apps/desktop/src/main/services/notifications/apnsService.ts b/apps/desktop/src/main/services/notifications/apnsService.ts deleted file mode 100644 index 41e965ec2..000000000 --- a/apps/desktop/src/main/services/notifications/apnsService.ts +++ /dev/null @@ -1,463 +0,0 @@ -/** - * Apple Push Notification service client for the desktop host. - * - * We talk HTTP/2 to APNs directly with `node:http2` + JWT signed via - * `node:crypto` — this avoids a native dependency for what's otherwise a - * thin wrapper. A `@parse/node-apn`-shaped transport can be swapped in via - * the `transport` option; the unit tests exercise that seam with a mock. - * - * Key material is never written in plaintext: the `.p8` bytes are persisted - * to a per-project encrypted path via Electron `safeStorage.encryptString`. - * Decrypted key lives in memory only while a notification is being signed. - */ - -import { createSign, createPrivateKey, type KeyObject } from "node:crypto"; -import * as http2 from "node:http2"; -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import path from "node:path"; -import type { Logger } from "../logging/logger"; - -const APNS_HOST_PRODUCTION = "api.push.apple.com"; -const APNS_HOST_SANDBOX = "api.sandbox.push.apple.com"; -const APNS_PORT = 443; -/** APNs JWTs must be refreshed ~every hour (Apple enforces ≤60 min). */ -const APNS_JWT_MAX_AGE_MS = 50 * 60 * 1000; -const APNS_REQUEST_TIMEOUT_MS = 30_000; - -export type ApnsEnvironment = "sandbox" | "production"; - -export type ApnsPushType = "alert" | "liveactivity" | "background" | "voip"; - -export type ApnsPriority = 5 | 10; - -export type ApnsEnvelope = { - deviceToken: string; - /** Per-device routing environment; falls back to service config when absent. */ - env?: ApnsEnvironment; - pushType: ApnsPushType; - /** `bundleId`, or `bundleId + .push-type.liveactivity` for Live Activities. */ - topic: string; - priority: ApnsPriority; - payload: Record; - /** Used by APNs for de-duplication of rapid updates for the same entity. */ - collapseId?: string; - /** Apple's `apns-expiration` header value (epoch seconds). `0` = drop if undeliverable. */ - expirationEpochSeconds?: number; -}; - -export type ApnsConfigureOptions = { - keyP8Pem: string; - keyId: string; - teamId: string; - bundleId: string; - env: ApnsEnvironment; -}; - -export type ApnsSendResult = { - ok: boolean; - status: number; - reason?: string; - apnsId?: string | null; - timestamp?: number; -}; - -/** - * APNs rejection codes that mean the token is dead and should be purged. - * See https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns - */ -export const APNS_INVALID_TOKEN_REASONS = new Set([ - "BadDeviceToken", - "Unregistered", - "DeviceTokenNotForTopic", -]); - -export type ApnsTokenInvalidatedEvent = { - deviceToken: string; - reason: string; - timestampMs: number; -}; - -/** - * Minimal HTTP transport seam. The default implementation hits `api.push.apple.com` - * over HTTP/2; tests inject a mock that records requests and returns canned responses. - */ -export interface ApnsTransport { - send(args: { - host: string; - headers: Record; - path: string; - body: Buffer; - }): Promise<{ status: number; headers: Record; body: string }>; - close(): Promise; -} - -export class Http2ApnsTransport implements ApnsTransport { - private sessions = new Map(); - - private getSession(host: string): http2.ClientHttp2Session { - const existing = this.sessions.get(host); - if (existing && !existing.closed && !existing.destroyed) return existing; - const session = http2.connect(`https://${host}:${APNS_PORT}`); - session.on("error", () => { - this.sessions.delete(host); - }); - session.on("close", () => { - this.sessions.delete(host); - }); - this.sessions.set(host, session); - return session; - } - - send(args: { - host: string; - headers: Record; - path: string; - body: Buffer; - }): Promise<{ status: number; headers: Record; body: string }> { - return new Promise((resolve, reject) => { - const session = this.getSession(args.host); - const req = session.request({ - ...args.headers, - ":method": "POST", - ":path": args.path, - }); - req.setEncoding("utf8"); - let status = 0; - let responseHeaders: Record = {}; - const chunks: string[] = []; - let settled = false; - const resolveOnce = (value: { status: number; headers: Record; body: string }) => { - if (settled) return; - settled = true; - req.setTimeout(0); - resolve(value); - }; - const rejectOnce = (error: Error) => { - if (settled) return; - settled = true; - req.setTimeout(0); - reject(error); - }; - req.setTimeout(APNS_REQUEST_TIMEOUT_MS, () => { - rejectOnce(new Error(`APNs request timed out after ${APNS_REQUEST_TIMEOUT_MS}ms`)); - req.close(http2.constants.NGHTTP2_CANCEL); - }); - req.on("response", (headers) => { - status = Number(headers[":status"]) || 0; - responseHeaders = headers as Record; - }); - req.on("data", (chunk: string) => chunks.push(chunk)); - req.on("end", () => { - resolveOnce({ status, headers: responseHeaders, body: chunks.join("") }); - }); - req.on("error", (error) => rejectOnce(error)); - req.end(args.body); - }); - } - - async close(): Promise { - for (const session of this.sessions.values()) { - try { - session.close(); - } catch { - // ignore - } - } - this.sessions.clear(); - } -} - -type JwtCacheEntry = { - token: string; - mintedAtMs: number; -}; - -type KeyStoreArgs = { - /** - * Absolute path where the encrypted `.p8` lives. - */ - encryptedKeyPath: string; - /** - * Electron's `safeStorage` bindings, passed via the service graph so - * the module remains unit-testable without spinning up Electron. - */ - safeStorage?: { - isEncryptionAvailable(): boolean; - encryptString(plainText: string): Buffer; - decryptString(buffer: Buffer): string; - }; - credentialStore?: { - getSync(key: string): string | null; - setSync(key: string, value: string): void; - deleteSync(key: string): void; - }; - credentialKey?: string; -}; - -export class ApnsKeyStore { - private readonly credentialKey: string; - - constructor(private readonly args: KeyStoreArgs) { - this.credentialKey = args.credentialKey ?? "notifications.apns.keyP8Pem"; - } - - save(p8Pem: string): void { - if (this.args.credentialStore) { - this.args.credentialStore.setSync(this.credentialKey, p8Pem); - return; - } - if (!this.args.safeStorage || !this.args.safeStorage.isEncryptionAvailable()) { - throw new Error("safeStorage is unavailable; refusing to persist .p8 in plaintext."); - } - fs.mkdirSync(path.dirname(this.args.encryptedKeyPath), { recursive: true }); - const encrypted = this.args.safeStorage.encryptString(p8Pem); - fs.writeFileSync(this.args.encryptedKeyPath, encrypted, { mode: 0o600 }); - } - - load(): string | null { - if (this.args.credentialStore) { - return this.args.credentialStore.getSync(this.credentialKey); - } - if (!fs.existsSync(this.args.encryptedKeyPath)) return null; - if (!this.args.safeStorage || !this.args.safeStorage.isEncryptionAvailable()) { - throw new Error("safeStorage is unavailable; cannot decrypt .p8."); - } - const encrypted = fs.readFileSync(this.args.encryptedKeyPath); - return this.args.safeStorage.decryptString(encrypted); - } - - clear(): void { - if (this.args.credentialStore) { - this.args.credentialStore.deleteSync(this.credentialKey); - return; - } - if (fs.existsSync(this.args.encryptedKeyPath)) { - fs.rmSync(this.args.encryptedKeyPath); - } - } - - has(): boolean { - if (this.args.credentialStore) { - return this.args.credentialStore.getSync(this.credentialKey) != null; - } - return fs.existsSync(this.args.encryptedKeyPath); - } -} - -export type ApnsServiceArgs = { - logger: Logger; - transport?: ApnsTransport; - /** Injectable clock for tests. */ - now?: () => number; -}; - -/** - * Signs a JWT for the APNs provider token auth flow. - * - * APNs accepts an ES256-signed JWT where: - * header = { alg: "ES256", kid: } - * claims = { iss: , iat: } - */ -export function signApnsJwt(args: { - keyPem: string; - keyId: string; - teamId: string; - issuedAtSeconds: number; -}): string { - const header = base64UrlEncode(JSON.stringify({ alg: "ES256", kid: args.keyId })); - const claims = base64UrlEncode( - JSON.stringify({ iss: args.teamId, iat: args.issuedAtSeconds }), - ); - const signingInput = `${header}.${claims}`; - let keyObject: KeyObject; - try { - keyObject = createPrivateKey(args.keyPem); - } catch (error) { - throw new Error(`Invalid APNs .p8 key: ${error instanceof Error ? error.message : String(error)}`); - } - const signer = createSign("sha256"); - signer.update(signingInput); - signer.end(); - const signature = signer.sign({ key: keyObject, dsaEncoding: "ieee-p1363" }); - return `${signingInput}.${base64UrlEncodeBuffer(signature)}`; -} - -function base64UrlEncode(value: string): string { - return base64UrlEncodeBuffer(Buffer.from(value, "utf8")); -} - -function base64UrlEncodeBuffer(value: Buffer): string { - return value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -export class ApnsService extends EventEmitter { - private readonly logger: Logger; - private readonly transport: ApnsTransport; - private readonly now: () => number; - private config: ApnsConfigureOptions | null = null; - private jwt: JwtCacheEntry | null = null; - - constructor(args: ApnsServiceArgs) { - super(); - this.logger = args.logger; - this.transport = args.transport ?? new Http2ApnsTransport(); - this.now = args.now ?? (() => Date.now()); - } - - /** Load key + metadata; throws on malformed input. */ - configure(options: ApnsConfigureOptions): void { - if (!options.keyP8Pem.trim()) throw new Error("APNs .p8 PEM must not be empty."); - if (!/^\w{10}$/.test(options.keyId)) throw new Error("APNs keyId must be 10 alphanumerics."); - if (!/^\w{10}$/.test(options.teamId)) throw new Error("APNs teamId must be 10 alphanumerics."); - if (!options.bundleId.trim()) throw new Error("APNs bundleId must not be empty."); - // Probe that the PEM actually parses now so we fail fast instead of at first send. - createPrivateKey(options.keyP8Pem); - this.config = options; - this.jwt = null; - } - - isConfigured(): boolean { - return this.config != null; - } - - async reset(): Promise { - this.config = null; - this.jwt = null; - await this.transport.close().catch(() => {}); - } - - onTokenInvalidated(listener: (event: ApnsTokenInvalidatedEvent) => void): () => void { - this.on("tokenInvalidated", listener); - return () => this.off("tokenInvalidated", listener); - } - - /** Build and send a single push envelope. */ - async send(envelope: ApnsEnvelope): Promise { - if (!this.config) { - throw new Error("ApnsService is not configured. Call configure() first."); - } - const env = envelope.env ?? this.config.env; - const host = env === "production" ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX; - const body = Buffer.from(JSON.stringify(envelope.payload), "utf8"); - const sendOnce = async (jwt: string) => { - const headers: Record = { - authorization: `bearer ${jwt}`, - "apns-topic": envelope.topic, - "apns-push-type": envelope.pushType, - "apns-priority": envelope.priority, - }; - if (envelope.collapseId) headers["apns-collapse-id"] = envelope.collapseId; - if (typeof envelope.expirationEpochSeconds === "number") { - headers["apns-expiration"] = envelope.expirationEpochSeconds; - } - return await this.transport.send({ - host, - headers, - path: `/3/device/${envelope.deviceToken}`, - body, - }); - }; - let status = 0; - let rawBody = ""; - let responseHeaders: Record = {}; - try { - let response = await sendOnce(this.mintJwtIfStale()); - status = response.status; - rawBody = response.body; - responseHeaders = response.headers; - if (status !== 200 && parseApnsReason(rawBody) === "ExpiredProviderToken") { - this.jwt = null; - response = await sendOnce(this.mintJwtIfStale()); - status = response.status; - rawBody = response.body; - responseHeaders = response.headers; - } - } catch (error) { - this.logger.warn("apns.transport_error", { - error: error instanceof Error ? error.message : String(error), - host, - // NEVER log envelope.deviceToken or payload verbatim. - }); - return { ok: false, status: 0, reason: "TransportError" }; - } - - if (status === 200) { - return { - ok: true, - status, - apnsId: stringHeader(responseHeaders["apns-id"]) ?? null, - }; - } - const reason = parseApnsReason(rawBody); - const timestamp = parseApnsTimestamp(rawBody); - if (reason && APNS_INVALID_TOKEN_REASONS.has(reason)) { - this.emit("tokenInvalidated", { - deviceToken: envelope.deviceToken, - reason, - timestampMs: this.now(), - } satisfies ApnsTokenInvalidatedEvent); - } - if (reason === "ExpiredProviderToken") { - // The retry above still failed; force a re-mint on the next send too. - this.jwt = null; - } - this.logger.warn("apns.push_rejected", { - status, - reason: reason ?? null, - apnsId: stringHeader(responseHeaders["apns-id"]) ?? null, - host, - }); - return { ok: false, status, reason: reason ?? undefined, timestamp }; - } - - /** Explicit teardown; called from main.ts on quit. */ - async dispose(): Promise { - await this.reset(); - this.removeAllListeners(); - } - - private mintJwtIfStale(): string { - const config = this.config; - if (!config) throw new Error("ApnsService not configured."); - const now = this.now(); - if (this.jwt && now - this.jwt.mintedAtMs < APNS_JWT_MAX_AGE_MS) { - return this.jwt.token; - } - const issuedAtSeconds = Math.floor(now / 1000); - const token = signApnsJwt({ - keyPem: config.keyP8Pem, - keyId: config.keyId, - teamId: config.teamId, - issuedAtSeconds, - }); - this.jwt = { token, mintedAtMs: now }; - return token; - } -} - -function parseApnsReason(body: string): string | undefined { - if (!body) return undefined; - try { - const parsed = JSON.parse(body) as { reason?: unknown }; - return typeof parsed.reason === "string" ? parsed.reason : undefined; - } catch { - return undefined; - } -} - -function parseApnsTimestamp(body: string): number | undefined { - if (!body) return undefined; - try { - const parsed = JSON.parse(body) as { timestamp?: unknown }; - return typeof parsed.timestamp === "number" ? parsed.timestamp : undefined; - } catch { - return undefined; - } -} - -function stringHeader(value: string | string[] | undefined): string | undefined { - if (typeof value === "string") return value; - if (Array.isArray(value) && typeof value[0] === "string") return value[0]; - return undefined; -} diff --git a/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts b/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts deleted file mode 100644 index 7a71dde31..000000000 --- a/apps/desktop/src/main/services/notifications/notificationEventBus.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createNotificationEventBus, type DevicePushTarget } from "./notificationEventBus"; -import { DEFAULT_NOTIFICATION_PREFERENCES, type NotificationPreferences } from "../../../shared/types/sync"; -import type { AgentChatEventEnvelope } from "../../../shared/types/chat"; -import type { PrSummary } from "../../../shared/types/prs"; - -function createLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any; -} - -function deferredFlush() { - // Fire-and-forget sends are queued via `void deliver(…)`; nextTick+microtask - // flush before we assert. - return new Promise((resolve) => setImmediate(resolve)); -} - -type ApnsSendCall = { - deviceToken: string; - env?: "sandbox" | "production"; - topic: string; - priority: number; - pushType: string; - payload: Record; -}; - -function makeApnsService() { - const calls: ApnsSendCall[] = []; - const service = { - isConfigured: () => true, - send: vi.fn(async (envelope: any) => { - calls.push({ - deviceToken: envelope.deviceToken, - env: envelope.env, - topic: envelope.topic, - priority: envelope.priority, - pushType: envelope.pushType, - payload: envelope.payload, - }); - return { ok: true, status: 200 }; - }), - }; - return { service, calls }; -} - -function makeTarget(overrides: Partial = {}): DevicePushTarget { - return { - deviceId: "device-A", - bundleId: "com.ade.ios", - env: "sandbox", - alertToken: "alert-token-A", - activityStartToken: null, - activityUpdateTokens: null, - ...overrides, - }; -} - -const prefsOn: NotificationPreferences = { - ...DEFAULT_NOTIFICATION_PREFERENCES, - chat: { - awaitingInput: true, - chatFailed: true, - turnCompleted: true, - }, -}; - -describe("notificationEventBus", () => { - const sampleEnvelope: AgentChatEventEnvelope = { - sessionId: "session-1", - timestamp: "2026-04-20T10:00:00.000Z", - event: { - type: "approval_request", - itemId: "item-1", - kind: "command", - description: "Run `git push`?", - }, - }; - - it("fires an APNs push when category is enabled and a token is present", async () => { - const { service, calls } = makeApnsService(); - const inAppSpy = vi.fn(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [makeTarget()], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: inAppSpy, - isDeviceConnected: () => false, - }); - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - expect(calls).toHaveLength(1); - expect(calls[0].deviceToken).toBe("alert-token-A"); - expect(calls[0].topic).toBe("com.ade.ios"); - expect(calls[0].priority).toBe(10); - expect(inAppSpy).not.toHaveBeenCalled(); - }); - - it("delivers in-app when device is connected, in addition to APNs", async () => { - const { service } = makeApnsService(); - const inAppSpy = vi.fn(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [makeTarget()], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: inAppSpy, - isDeviceConnected: () => true, - }); - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - expect(inAppSpy).toHaveBeenCalledTimes(1); - expect(service.send).toHaveBeenCalledTimes(1); - }); - - it("skips APNs when the category is disabled in prefs, but still sends in-app", async () => { - const { service } = makeApnsService(); - const inAppSpy = vi.fn(); - const deniedPrefs = { ...prefsOn, chat: { ...prefsOn.chat, awaitingInput: false } }; - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [makeTarget()], - getPrefsForDevice: () => deniedPrefs, - sendInAppNotification: inAppSpy, - isDeviceConnected: () => true, - }); - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - expect(service.send).not.toHaveBeenCalled(); - expect(inAppSpy).toHaveBeenCalledTimes(1); - }); - - it("fans out to multiple devices independently", async () => { - const { service, calls } = makeApnsService(); - const inAppSpy = vi.fn(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [ - makeTarget({ deviceId: "A", alertToken: "t1" }), - makeTarget({ deviceId: "B", alertToken: "t2" }), - ], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: inAppSpy, - isDeviceConnected: () => false, - }); - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - expect(calls.map((c) => c.deviceToken).sort()).toEqual(["t1", "t2"]); - }); - - it("never calls APNs when apnsService is null", async () => { - const inAppSpy = vi.fn(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: null, - listPushTargets: () => [makeTarget()], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: inAppSpy, - isDeviceConnected: () => true, - }); - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - expect(inAppSpy).toHaveBeenCalledTimes(1); - }); - - it("publishPrEvent targets the correct device token and deep-link", async () => { - const { service, calls } = makeApnsService(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [makeTarget()], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: vi.fn(), - isDeviceConnected: () => false, - }); - const pr: PrSummary = { - id: "pr-1", - laneId: "lane-1", - projectId: "proj-1", - repoOwner: "arul28", - repoName: "ADE", - githubPrNumber: 412, - githubUrl: "https://github.com/arul28/ADE/pull/412", - githubNodeId: null, - title: "Refactor", - state: "open", - baseBranch: "main", - headBranch: "feat", - checksStatus: "failing", - reviewStatus: "requested", - additions: 1, - deletions: 1, - lastSyncedAt: null, - createdAt: "2026-04-20T00:00:00Z", - updatedAt: "2026-04-20T00:00:00Z", - }; - bus.publishPrEvent({ kind: "checks_failing", pr }); - await deferredFlush(); - expect(calls).toHaveLength(1); - expect(calls[0].priority).toBe(10); - expect((calls[0].payload as any).deepLink).toBe("ade://pr/412"); - }); - - it("sendTestPush fails cleanly when no token is stored", async () => { - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: makeApnsService().service as any, - listPushTargets: () => [makeTarget({ alertToken: null })], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: vi.fn(), - isDeviceConnected: () => false, - }); - const result = await bus.sendTestPush("device-A", "alert"); - expect(result.ok).toBe(false); - expect(result.reason).toBe("no_token"); - }); - - it("sendTestPush delivers an in-app notification when APNs is unavailable but the device is connected", async () => { - const inAppSpy = vi.fn(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: null, - listPushTargets: () => [makeTarget()], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: inAppSpy, - isDeviceConnected: () => true, - }); - const result = await bus.sendTestPush("device-A", "alert"); - expect(result).toEqual({ ok: true, reason: "in_app_only" }); - expect(inAppSpy).toHaveBeenCalledWith("device-A", expect.objectContaining({ - category: "system", - title: "ADE test push", - collapseId: "ade:test", - })); - }); - - it("sendTestPush uses `bundle.push-type.liveactivity` topic when kind=activity", async () => { - const { service, calls } = makeApnsService(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [makeTarget({ activityStartToken: "act-token" })], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: vi.fn(), - isDeviceConnected: () => false, - }); - const result = await bus.sendTestPush("device-A", "activity"); - expect(result.ok).toBe(true); - expect(calls[0].topic).toBe("com.ade.ios.push-type.liveactivity"); - expect(calls[0].pushType).toBe("liveactivity"); - }); - - it("passes each device APNs environment through to alert and Live Activity sends", async () => { - const { service, calls } = makeApnsService(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [ - makeTarget({ - env: "production", - alertToken: "alert-prod", - activityUpdateTokens: { "session-1": "live-prod" }, - }), - ], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: vi.fn(), - isDeviceConnected: () => false, - now: () => 1_777_777_777_000, - }); - - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - - expect(calls).toHaveLength(2); - expect(calls.map((call) => call.env)).toEqual(["production", "production"]); - expect(calls.map((call) => call.pushType)).toEqual(["alert", "liveactivity"]); - expect(calls[1].topic).toBe("com.ade.ios.push-type.liveactivity"); - }); - - it("sends Live Activity updates only to the matching activity id", async () => { - const { service, calls } = makeApnsService(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [ - makeTarget({ - activityUpdateTokens: { - "session-1": "live-session-1", - "session-2": "live-session-2", - }, - }), - ], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: vi.fn(), - isDeviceConnected: () => false, - }); - - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - - expect(calls.map((call) => call.deviceToken)).toEqual(["alert-token-A", "live-session-1"]); - }); - - it("uses the workspace Live Activity update token when no per-entity token is registered", async () => { - const { service, calls } = makeApnsService(); - const bus = createNotificationEventBus({ - logger: createLogger(), - apnsService: service as any, - listPushTargets: () => [ - makeTarget({ - activityUpdateTokens: { - workspace: "live-workspace", - }, - }), - ], - getPrefsForDevice: () => prefsOn, - sendInAppNotification: vi.fn(), - isDeviceConnected: () => false, - }); - - bus.publishChatEvent(sampleEnvelope); - await deferredFlush(); - - expect(calls.map((call) => call.deviceToken)).toEqual(["alert-token-A", "live-workspace"]); - }); -}); diff --git a/apps/desktop/src/main/services/notifications/notificationEventBus.ts b/apps/desktop/src/main/services/notifications/notificationEventBus.ts deleted file mode 100644 index d8e690b3a..000000000 --- a/apps/desktop/src/main/services/notifications/notificationEventBus.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * Routes ADE domain events → APNs pushes OR in-app WebSocket notifications, - * per-device + per-user prefs. This is the sole entry point callers use. - * - * Design notes: - * - The bus knows nothing about ADE domain state directly; it asks the - * device registry for tokens + prefs at send time so that a pref toggle - * takes effect immediately (no cache-invalidation races). - * - If a device is currently connected over WebSocket AND prefs say the - * category should NOT generate an alert, we still deliver an in-app - * notification via the supplied WS sender. This is what lets us e.g. - * turn off "chat completed" alerts while still updating foreground UI. - * - Every send() is fire-and-forget so that callers on hot paths (chat - * streaming, PR polling) don't block. - */ - -import type { AgentChatEventEnvelope } from "../../../shared/types/chat"; -import type { PrNotificationKind, PrSummary } from "../../../shared/types/prs"; -import type { NotificationPreferences } from "../../../shared/types/sync"; -import type { Logger } from "../logging/logger"; -import { - buildApnsPayload, - isAllowedByPrefs, - mapChatEvent, - mapPrEvent, - mapSystemEvent, - type MappedNotification, - type SystemEvent, -} from "./notificationMapper"; -import type { ApnsEnvelope, ApnsService } from "./apnsService"; - -const APPLE_REFERENCE_DATE_MS = Date.UTC(2001, 0, 1); -const WORKSPACE_ACTIVITY_ID = "workspace"; - -function activityDateValue(ms: number): number { - return Math.floor((ms - APPLE_REFERENCE_DATE_MS) / 1000); -} - -function numberMetadata(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function stringMetadata(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function attentionForLiveActivity(mapped: MappedNotification): Record | null { - const metadata = mapped.metadata ?? {}; - switch (mapped.category) { - case "CHAT_AWAITING_INPUT": - return { - kind: "awaitingInput", - title: mapped.title, - subtitle: mapped.body, - sessionId: stringMetadata(metadata.sessionId), - itemId: stringMetadata(metadata.itemId), - }; - case "CHAT_FAILED": - return { - kind: "failed", - title: mapped.title, - subtitle: mapped.body, - sessionId: stringMetadata(metadata.sessionId), - }; - case "PR_CI_FAILING": - return { - kind: "ciFailing", - title: mapped.title, - subtitle: mapped.body, - prId: stringMetadata(metadata.prId), - prNumber: numberMetadata(metadata.prNumber), - }; - case "PR_REVIEW_REQUESTED": - return { - kind: "reviewRequested", - title: mapped.title, - subtitle: mapped.body, - prId: stringMetadata(metadata.prId), - prNumber: numberMetadata(metadata.prNumber), - }; - case "PR_CHANGES_REQUESTED": - return { - kind: "reviewRequested", - title: mapped.title, - subtitle: mapped.body, - prId: stringMetadata(metadata.prId), - prNumber: numberMetadata(metadata.prNumber), - }; - case "PR_MERGE_READY": - return { - kind: "mergeReady", - title: mapped.title, - subtitle: mapped.body, - prId: stringMetadata(metadata.prId), - prNumber: numberMetadata(metadata.prNumber), - }; - default: - return null; - } -} - -function buildLiveActivityUpdatePayload(mapped: MappedNotification, nowMs: number): Record | null { - const attention = attentionForLiveActivity(mapped); - if (!attention) return null; - return { - aps: { - timestamp: Math.floor(nowMs / 1000), - event: "update", - "content-state": { - sessions: [], - attention, - failingCheckCount: mapped.category === "PR_CI_FAILING" ? 1 : 0, - awaitingReviewCount: mapped.category === "PR_REVIEW_REQUESTED" || mapped.category === "PR_CHANGES_REQUESTED" ? 1 : 0, - mergeReadyCount: mapped.category === "PR_MERGE_READY" ? 1 : 0, - generatedAt: activityDateValue(nowMs), - }, - }, - }; -} - -function matchingActivityUpdateTokens( - mapped: MappedNotification, - updateTokens: Record | null | undefined, -): string[] { - if (!updateTokens) return []; - const metadata = mapped.metadata ?? {}; - const activityIds = new Set(); - const sessionId = stringMetadata(metadata.sessionId); - const prId = stringMetadata(metadata.prId); - if (sessionId) activityIds.add(sessionId); - if (prId) activityIds.add(prId); - activityIds.add(WORKSPACE_ACTIVITY_ID); - if (activityIds.size === 0) return []; - return [...activityIds] - .map((activityId) => updateTokens[activityId]) - .filter((token): token is string => typeof token === "string" && token.trim().length > 0); -} - -export type DevicePushTarget = { - deviceId: string; - bundleId: string; - env: "sandbox" | "production"; - alertToken: string | null; - activityStartToken: string | null; - /** Currently active live-activity update tokens keyed by activity id. */ - activityUpdateTokens: Record | null; -}; - -export type NotificationEventBusArgs = { - logger: Logger; - apnsService: ApnsService | null; - /** - * Returns the list of iOS devices we should consider routing to. - * The bus filters further based on prefs + token availability. - */ - listPushTargets: () => DevicePushTarget[]; - /** Returns the prefs for a specific device, falling back to defaults when none are stored. */ - getPrefsForDevice: (deviceId: string) => NotificationPreferences | null; - /** Send an in-app notification over an already-connected WebSocket. */ - sendInAppNotification: ( - deviceId: string, - payload: { - category: MappedNotification["family"]; - title: string; - body: string; - collapseId?: string; - deepLink?: string; - metadata?: Record; - }, - ) => void; - /** Whether the device is currently connected via WS. */ - isDeviceConnected: (deviceId: string) => boolean; - /** Injectable clock for deterministic tests. */ - now?: () => number; -}; - -export type NotificationEventBus = ReturnType; - -export function createNotificationEventBus(args: NotificationEventBusArgs) { - const now = args.now ?? (() => Date.now()); - - async function deliver(mapped: MappedNotification): Promise { - const targets = args.listPushTargets(); - if (targets.length === 0) return; - for (const target of targets) { - const prefs = args.getPrefsForDevice(target.deviceId); - const nowMs = now(); - const prefsAllowed = isAllowedByPrefs(mapped, prefs, nowMs); - - const connectedInApp = args.isDeviceConnected(target.deviceId); - if (connectedInApp) { - args.sendInAppNotification(target.deviceId, { - category: mapped.family, - title: mapped.title, - body: mapped.body, - collapseId: mapped.collapseId, - deepLink: mapped.deepLink, - metadata: mapped.metadata, - }); - } - - if (!prefsAllowed) continue; - if (!args.apnsService || !args.apnsService.isConfigured()) continue; - - // `apns-expiration` drops the push if it can't be delivered within the - // window. For priority-5 / passive pushes (turn completed, - // sub-agent started) we don't want APNs queueing a stale banner if the - // device is offline for hours — 10 minutes is plenty for them to still - // feel "live". For priority-10 attention pushes (awaiting input, CI - // failing) we give APNs 1 hour so it can deliver on the next reconnect. - const nowSeconds = Math.floor(nowMs / 1000); - const expirationEpochSeconds = - mapped.priority === 10 ? nowSeconds + 60 * 60 : nowSeconds + 10 * 60; - - if (target.alertToken) { - const envelope: ApnsEnvelope = { - deviceToken: target.alertToken, - env: target.env, - pushType: mapped.pushType, - topic: target.bundleId, - priority: mapped.priority, - payload: buildApnsPayload(mapped), - collapseId: mapped.collapseId, - expirationEpochSeconds, - }; - try { - await args.apnsService.send(envelope); - } catch (error) { - args.logger.warn("notification_bus.apns_send_failed", { - deviceId: target.deviceId, - category: mapped.category, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - const livePayload = buildLiveActivityUpdatePayload(mapped, nowMs); - const updateTokens = matchingActivityUpdateTokens(mapped, target.activityUpdateTokens); - if (livePayload && updateTokens.length > 0) { - for (const token of updateTokens) { - const liveEnvelope: ApnsEnvelope = { - deviceToken: token, - env: target.env, - pushType: "liveactivity", - topic: `${target.bundleId}.push-type.liveactivity`, - priority: 10, - payload: livePayload, - collapseId: mapped.collapseId ? `${mapped.collapseId}:activity` : undefined, - expirationEpochSeconds, - }; - try { - await args.apnsService.send(liveEnvelope); - } catch (error) { - args.logger.warn("notification_bus.live_activity_send_failed", { - deviceId: target.deviceId, - category: mapped.category, - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - } - } - - function fanOut(mappedList: MappedNotification[]): void { - for (const mapped of mappedList) { - // fire-and-forget; callers shouldn't wait on push. - void deliver(mapped).catch((error) => { - args.logger.warn("notification_bus.deliver_failed", { - category: mapped.category, - error: error instanceof Error ? error.message : String(error), - }); - }); - } - } - - return { - publishChatEvent(envelope: AgentChatEventEnvelope): void { - const mapped = mapChatEvent(envelope); - if (mapped.length > 0) fanOut(mapped); - }, - publishPrEvent(event: { kind: PrNotificationKind; pr: PrSummary; titleOverride?: string; messageOverride?: string }): void { - const mapped = mapPrEvent(event); - if (mapped.length > 0) fanOut(mapped); - }, - publishSystemEvent(event: SystemEvent): void { - const mapped = mapSystemEvent(event); - if (mapped.length > 0) fanOut(mapped); - }, - /** - * Fire a canned test push to a specific device, used by the Send Test - * Push button in the mobile settings panel. - */ - async sendTestPush(deviceId: string, kind: "alert" | "activity" = "alert"): Promise<{ ok: boolean; reason?: string }> { - const target = args.listPushTargets().find((t) => t.deviceId === deviceId); - if (!target) return { ok: false, reason: "device_not_registered" }; - const activityUpdateToken = target.activityUpdateTokens ? Object.values(target.activityUpdateTokens)[0] : null; - const token = kind === "alert" ? target.alertToken : activityUpdateToken ?? target.activityStartToken; - if (!token) return { ok: false, reason: "no_token" }; - if (!args.apnsService || !args.apnsService.isConfigured()) { - if (args.isDeviceConnected(deviceId)) { - args.sendInAppNotification(deviceId, { - category: "system", - title: "ADE test push", - body: "This device reached its paired ADE machine.", - collapseId: "ade:test", - }); - return { ok: true, reason: "in_app_only" }; - } - return { ok: false, reason: "apns_not_configured" }; - } - - const topic = kind === "activity" ? `${target.bundleId}.push-type.liveactivity` : target.bundleId; - const activityEvent = activityUpdateToken ? "update" : "start"; - const liveContentState = { - sessions: [], - attention: { - kind: "awaitingInput", - title: "ADE test push", - subtitle: "Live Activity delivery is wired.", - }, - failingCheckCount: 0, - awaitingReviewCount: 0, - mergeReadyCount: 0, - generatedAt: activityDateValue(now()), - }; - const envelope: ApnsEnvelope = { - deviceToken: token, - env: target.env, - pushType: kind === "activity" ? "liveactivity" : "alert", - topic, - priority: 10, - payload: - kind === "activity" - ? { - aps: { - event: activityEvent, - timestamp: Math.floor(now() / 1000), - ...(activityEvent === "start" - ? { - "attributes-type": "ADESessionAttributes", - attributes: { workspaceId: "default", workspaceName: "ADE" }, - } - : {}), - "content-state": liveContentState, - }, - } - : buildApnsPayload({ - category: "CHAT_AWAITING_INPUT", - family: "chat", - title: "ADE test push", - body: "If you see this, mobile push is wired correctly.", - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId: "ade:test", - }), - }; - try { - const result = await args.apnsService.send(envelope); - return { ok: result.ok, reason: result.reason }; - } catch (error) { - args.logger.warn("notification_bus.test_push_failed", { - deviceId, - error: error instanceof Error ? error.message : String(error), - }); - return { ok: false, reason: "send_failed" }; - } - }, - }; -} diff --git a/apps/desktop/src/main/services/notifications/notificationMapper.test.ts b/apps/desktop/src/main/services/notifications/notificationMapper.test.ts deleted file mode 100644 index 16eb36cd8..000000000 --- a/apps/desktop/src/main/services/notifications/notificationMapper.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildApnsPayload, - isAllowedByPrefs, - mapChatEvent, - mapPrEvent, - mapSystemEvent, - type MappedNotification, -} from "./notificationMapper"; -import { DEFAULT_NOTIFICATION_PREFERENCES } from "../../../shared/types/sync"; -import type { AgentChatEventEnvelope } from "../../../shared/types/chat"; -import type { PrSummary } from "../../../shared/types/prs"; - -function chatEnvelope(event: AgentChatEventEnvelope["event"]): AgentChatEventEnvelope { - return { - sessionId: "session-123", - timestamp: "2026-04-20T10:00:00.000Z", - event, - }; -} - -const samplePr: PrSummary = { - id: "pr-1", - laneId: "lane-1", - projectId: "proj-1", - repoOwner: "arul28", - repoName: "ADE", - githubPrNumber: 412, - githubUrl: "https://github.com/arul28/ADE/pull/412", - githubNodeId: "MDExOlB1bGxSZXF1ZXN0", - title: "Refactor auth", - state: "open", - baseBranch: "main", - headBranch: "feat/auth", - checksStatus: "failing", - reviewStatus: "requested", - additions: 100, - deletions: 20, - lastSyncedAt: "2026-04-20T09:55:00.000Z", - createdAt: "2026-04-20T09:00:00.000Z", - updatedAt: "2026-04-20T09:55:00.000Z", -}; - -describe("mapChatEvent", () => { - it("maps approval_request to time-sensitive awaiting-input push", () => { - const [mapped] = mapChatEvent( - chatEnvelope({ - type: "approval_request", - itemId: "item-1", - kind: "command", - description: "Run `rm -rf /tmp/foo`", - }), - ); - expect(mapped.category).toBe("CHAT_AWAITING_INPUT"); - expect(mapped.interruptionLevel).toBe("time-sensitive"); - expect(mapped.priority).toBe(10); - expect(mapped.deepLink).toBe("ade://session/session-123"); - expect(mapped.collapseId).toContain("session-123"); - }); - - it("maps done:failed to CHAT_FAILED", () => { - const [mapped] = mapChatEvent( - chatEnvelope({ type: "done", turnId: "turn-1", status: "failed" }), - ); - expect(mapped.category).toBe("CHAT_FAILED"); - expect(mapped.priority).toBe(10); - }); - - it("maps done:completed to low-priority passive", () => { - const [mapped] = mapChatEvent( - chatEnvelope({ type: "done", turnId: "turn-1", status: "completed" }), - ); - expect(mapped.category).toBe("CHAT_COMPLETED"); - expect(mapped.iosCategory).toBe("CHAT_TURN_COMPLETED"); - expect(mapped.priority).toBe(5); - expect(mapped.interruptionLevel).toBe("passive"); - }); - - it("maps system_notice provider_health → SYSTEM_PROVIDER_OUTAGE", () => { - const [mapped] = mapChatEvent( - chatEnvelope({ type: "system_notice", noticeKind: "provider_health", message: "OpenAI is down" }), - ); - expect(mapped.category).toBe("SYSTEM_PROVIDER_OUTAGE"); - }); - - it("maps subagent_started to CTO family", () => { - const [mapped] = mapChatEvent( - chatEnvelope({ type: "subagent_started", taskId: "sub-1", description: "Planning" }), - ); - expect(mapped.family).toBe("cto"); - expect(mapped.category).toBe("CTO_SUBAGENT_STARTED"); - }); - - it("emits nothing for unrelated chat events", () => { - const mapped = mapChatEvent(chatEnvelope({ type: "text", text: "hello" })); - expect(mapped).toEqual([]); - }); - - it("truncates long notification bodies to ≤178 chars", () => { - const longMessage = "x".repeat(300); - const [mapped] = mapChatEvent( - chatEnvelope({ type: "approval_request", itemId: "i", kind: "command", description: longMessage }), - ); - expect(mapped.body.length).toBeLessThanOrEqual(178); - }); -}); - -describe("mapPrEvent", () => { - it("maps checks_failing to high-priority active alert", () => { - const [mapped] = mapPrEvent({ kind: "checks_failing", pr: samplePr }); - expect(mapped.category).toBe("PR_CI_FAILING"); - expect(mapped.priority).toBe(10); - expect(mapped.deepLink).toBe("ade://pr/412"); - expect(mapped.collapseId).toBe("pr:pr-1:checks_failing"); - }); - - it("maps merge_ready to active priority-5 alert", () => { - const [mapped] = mapPrEvent({ kind: "merge_ready", pr: samplePr }); - expect(mapped.category).toBe("PR_MERGE_READY"); - expect(mapped.priority).toBe(5); - }); - - it("exposes PR metadata for deep-link enrichment", () => { - const [mapped] = mapPrEvent({ kind: "review_requested", pr: samplePr }); - expect(mapped.metadata).toMatchObject({ - prNumber: 412, - laneId: "lane-1", - githubUrl: samplePr.githubUrl, - }); - }); -}); - -describe("mapSystemEvent", () => { - it("maps system auth_rate_limit to priority-10 alert", () => { - const [mapped] = mapSystemEvent({ - kind: "auth_rate_limit", - title: "Rate limit hit", - message: "Claude rate limit reached; retrying in 2 min.", - }); - expect(mapped.category).toBe("SYSTEM_AUTH_RATE_LIMIT"); - expect(mapped.priority).toBe(10); - }); -}); - -describe("buildApnsPayload", () => { - it("includes alert body + interruption level in aps block", () => { - const mapped: MappedNotification = { - category: "CHAT_AWAITING_INPUT", - family: "chat", - title: "Hi", - body: "Approve?", - pushType: "alert", - priority: 10, - interruptionLevel: "time-sensitive", - collapseId: "cid", - }; - const payload = buildApnsPayload(mapped); - expect(payload.aps).toMatchObject({ - alert: { title: "Hi", body: "Approve?" }, - "interruption-level": "time-sensitive", - sound: "default", - "thread-id": "cid", - category: "CHAT_AWAITING_INPUT", - }); - }); - - it("uses the registered iOS category when it differs from the internal prefs category", () => { - const payload = buildApnsPayload({ - category: "SYSTEM_PROVIDER_OUTAGE", - iosCategory: "SYSTEM_ALERT", - family: "system", - title: "Provider issue", - body: "OpenAI is down", - pushType: "alert", - priority: 5, - interruptionLevel: "active", - }); - expect((payload.aps as Record).category).toBe("SYSTEM_ALERT"); - }); - - it("silent payloads set content-available and omit alert", () => { - const payload = buildApnsPayload({ - category: "CHAT_COMPLETED", - family: "chat", - title: "t", - body: "b", - pushType: "background", - priority: 5, - interruptionLevel: "passive", - silent: true, - }); - expect((payload.aps as Record).alert).toBeUndefined(); - expect((payload.aps as Record)["content-available"]).toBe(1); - }); -}); - -describe("isAllowedByPrefs", () => { - const mapped: MappedNotification = { - category: "CHAT_COMPLETED", - family: "chat", - title: "x", - body: "y", - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - }; - const prefs = { ...DEFAULT_NOTIFICATION_PREFERENCES }; - - it("blocks when master switch is off", () => { - expect(isAllowedByPrefs(mapped, { ...prefs, enabled: false })).toBe(false); - }); - - it("honors per-category toggles", () => { - expect(isAllowedByPrefs(mapped, prefs)).toBe(false); // turn_completed off by default - expect( - isAllowedByPrefs(mapped, { - ...prefs, - chat: { ...prefs.chat, turnCompleted: true }, - }), - ).toBe(true); - }); - - it("blocks while mute is active", () => { - const future = new Date(Date.now() + 60_000).toISOString(); - expect( - isAllowedByPrefs( - { ...mapped, category: "CHAT_AWAITING_INPUT" }, - { ...prefs, muteUntil: future }, - ), - ).toBe(false); - }); - - it("allows after mute expires", () => { - const past = new Date(Date.now() - 60_000).toISOString(); - expect( - isAllowedByPrefs( - { ...mapped, category: "CHAT_AWAITING_INPUT" }, - { ...prefs, muteUntil: past }, - ), - ).toBe(true); - }); - - it("blocks during quiet hours in the configured timezone", () => { - expect( - isAllowedByPrefs( - { ...mapped, category: "CHAT_AWAITING_INPUT" }, - { - ...prefs, - quietHours: { - enabled: true, - start: "22:00", - end: "07:00", - timezone: "America/New_York", - }, - }, - Date.parse("2026-04-21T03:30:00.000Z"), - ), - ).toBe(false); - }); - - it("honors per-session muted and awaiting-input-only overrides", () => { - const sessionMapped: MappedNotification = { - ...mapped, - category: "CHAT_COMPLETED", - metadata: { sessionId: "session-123" }, - }; - expect( - isAllowedByPrefs( - sessionMapped, - { - ...prefs, - chat: { ...prefs.chat, turnCompleted: true }, - perSessionOverrides: { "session-123": { awaitingInputOnly: true } }, - }, - ), - ).toBe(false); - expect( - isAllowedByPrefs( - { ...sessionMapped, category: "CHAT_AWAITING_INPUT" }, - { - ...prefs, - perSessionOverrides: { "session-123": { awaitingInputOnly: true } }, - }, - ), - ).toBe(true); - expect( - isAllowedByPrefs( - { ...sessionMapped, category: "CHAT_AWAITING_INPUT" }, - { - ...prefs, - perSessionOverrides: { "session-123": { muted: true } }, - }, - ), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/main/services/notifications/notificationMapper.ts b/apps/desktop/src/main/services/notifications/notificationMapper.ts deleted file mode 100644 index aaa0b9d1e..000000000 --- a/apps/desktop/src/main/services/notifications/notificationMapper.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * Pure, side-effect-free mapping from ADE domain events to APNs envelopes. - * The event bus is the only caller; keep this file easy to reason about in - * isolation so mapping logic can be unit-tested without mocks. - * - * We intentionally only produce the APNs "shape" — payload body + push type + - * priority + interruption-level. The bus decides which device(s) to target - * and which kind of token (alert vs activity-update) to attach. - */ - -import type { AgentChatEventEnvelope } from "../../../shared/types/chat"; -import type { PrNotificationKind, PrSummary } from "../../../shared/types/prs"; -import type { ApnsPriority, ApnsPushType } from "./apnsService"; - -export type NotificationInterruptionLevel = "active" | "time-sensitive" | "passive" | "critical"; - -export type NotificationCategory = - | "CHAT_AWAITING_INPUT" - | "CHAT_FAILED" - | "CHAT_COMPLETED" - | "CTO_SUBAGENT_STARTED" - | "CTO_SUBAGENT_FINISHED" - | "PR_CI_FAILING" - | "PR_REVIEW_REQUESTED" - | "PR_CHANGES_REQUESTED" - | "PR_MERGE_READY" - | "SYSTEM_PROVIDER_OUTAGE" - | "SYSTEM_AUTH_RATE_LIMIT" - | "SYSTEM_HOOK_FAILURE"; - -export type IosNotificationCategory = - | "CHAT_AWAITING_INPUT" - | "CHAT_FAILED" - | "CHAT_TURN_COMPLETED" - | "CTO_SUBAGENT_STARTED" - | "CTO_SUBAGENT_FINISHED" - | "PR_CI_FAILING" - | "PR_REVIEW_REQUESTED" - | "PR_CHANGES_REQUESTED" - | "PR_MERGE_READY" - | "SYSTEM_ALERT"; - -/** - * The user-facing copy + APNs-shape hints. The bus completes this into a - * full `ApnsEnvelope` by attaching a device token and topic. - */ -export type MappedNotification = { - category: NotificationCategory; - /** Category id registered by the iOS app. Defaults to `category` when omitted. */ - iosCategory?: IosNotificationCategory; - /** Buckets above map to one of four high-level families in prefs. */ - family: "chat" | "cto" | "pr" | "system"; - title: string; - body: string; - pushType: ApnsPushType; - priority: ApnsPriority; - interruptionLevel: NotificationInterruptionLevel; - /** APNs collapse-id for de-dup; e.g. `pr:#412:checks_failing`. */ - collapseId?: string; - /** Deep link consumed by the iOS app when the banner is tapped. */ - deepLink?: string; - /** Extra fields merged into the `aps.alert`-adjacent payload by the bus. */ - metadata?: Record; - /** - * Indicates this mapped event should NOT generate a user-visible alert, - * only a silent push (pushType: "background") — e.g. turn_completed when - * the user wants stats without interruption. Bus may still skip based - * on prefs. - */ - silent?: boolean; -}; - -function truncate(value: string, max: number): string { - if (value.length <= max) return value; - return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`; -} - -function previewForChatEvent(event: AgentChatEventEnvelope["event"]): string { - if (event.type === "text" && typeof event.text === "string") return truncate(event.text, 140); - if (event.type === "reasoning" && typeof event.text === "string") return truncate(event.text, 140); - if (event.type === "error") return truncate(event.message, 140); - if (event.type === "system_notice") return truncate(event.message, 140); - return ""; -} - -/** - * Map one chat-event envelope into zero or more user-facing notifications. - * Returns an empty array for events that should never surface as a push. - */ -export function mapChatEvent(envelope: AgentChatEventEnvelope): MappedNotification[] { - const { event, sessionId } = envelope; - const deepLink = `ade://session/${sessionId}`; - // Attach sessionId to every chat-derived mapping so the iOS NotificationService - // extension can set a per-session `threadIdentifier` and so the AppDelegate - // action handler can resolve the session for Approve/Deny/Reply actions. - const sessionMeta = { sessionId }; - - switch (event.type) { - case "approval_request": { - return [ - { - category: "CHAT_AWAITING_INPUT", - family: "chat", - title: "Awaiting approval", - body: truncate(event.description || "Agent is waiting on your approval.", 178), - pushType: "alert", - priority: 10, - interruptionLevel: "time-sensitive", - collapseId: `chat:${sessionId}:approval`, - deepLink, - metadata: { ...sessionMeta, itemId: event.itemId, kind: event.kind }, - }, - ]; - } - case "error": { - return [ - { - category: "CHAT_FAILED", - family: "chat", - title: "Chat error", - body: truncate(event.message, 178), - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId: `chat:${sessionId}:error`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - case "done": { - if (event.status === "failed") { - return [ - { - category: "CHAT_FAILED", - family: "chat", - title: "Chat failed", - body: truncate( - previewForChatEvent(event) || `A chat turn failed in session ${sessionId}.`, - 178, - ), - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId: `chat:${sessionId}:done-failed`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - if (event.status === "completed") { - return [ - { - category: "CHAT_COMPLETED", - iosCategory: "CHAT_TURN_COMPLETED", - family: "chat", - title: "Chat completed", - body: truncate("The assistant finished replying.", 178), - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - collapseId: `chat:${sessionId}:done`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - return []; - } - case "status": { - if (event.turnStatus === "completed") { - return [ - { - category: "CHAT_COMPLETED", - iosCategory: "CHAT_TURN_COMPLETED", - family: "chat", - title: "Chat completed", - body: truncate(event.message ?? "The assistant finished replying.", 178), - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - collapseId: `chat:${sessionId}:status-completed`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - if (event.turnStatus === "failed") { - return [ - { - category: "CHAT_FAILED", - family: "chat", - title: "Chat failed", - body: truncate(event.message ?? "A chat turn failed.", 178), - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId: `chat:${sessionId}:status-failed`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - return []; - } - case "system_notice": { - const family = "system" as const; - const base = { - family, - pushType: "alert" as ApnsPushType, - priority: 5 as ApnsPriority, - interruptionLevel: "active" as NotificationInterruptionLevel, - deepLink, - body: truncate(event.message, 178), - collapseId: `system:${sessionId}:${event.noticeKind}`, - metadata: sessionMeta, - }; - if (event.noticeKind === "provider_health") { - return [{ ...base, category: "SYSTEM_PROVIDER_OUTAGE", iosCategory: "SYSTEM_ALERT", title: "Provider issue" }]; - } - if (event.noticeKind === "auth" || event.noticeKind === "rate_limit") { - return [{ ...base, category: "SYSTEM_AUTH_RATE_LIMIT", iosCategory: "SYSTEM_ALERT", title: "Authentication required" }]; - } - if (event.noticeKind === "hook") { - return [{ ...base, category: "SYSTEM_HOOK_FAILURE", iosCategory: "SYSTEM_ALERT", title: "Hook failed" }]; - } - return []; - } - case "subagent_started": { - return [ - { - category: "CTO_SUBAGENT_STARTED", - iosCategory: "CTO_SUBAGENT_STARTED", - family: "cto", - title: "Sub-agent started", - body: truncate(event.description || `Sub-agent ${event.taskId} started.`, 178), - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - collapseId: `cto:${sessionId}:${event.taskId}:start`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - case "subagent_result": { - return [ - { - category: "CTO_SUBAGENT_FINISHED", - family: "cto", - title: event.status === "failed" ? "Sub-agent failed" : "Sub-agent finished", - body: truncate(event.summary || `Sub-agent ${event.taskId} ${event.status}.`, 178), - pushType: "alert", - priority: event.status === "failed" ? 10 : 5, - interruptionLevel: event.status === "failed" ? "active" : "passive", - collapseId: `cto:${sessionId}:${event.taskId}:result`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - case "delegation_state": { - return [ - { - category: "CTO_SUBAGENT_FINISHED", - family: "cto", - title: "Delegation updated", - body: truncate(event.message ?? "Delegation state changed.", 178), - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - collapseId: `cto:${sessionId}:delegation`, - deepLink, - metadata: sessionMeta, - }, - ]; - } - default: - return []; - } -} - -function prHeadline(pr: PrSummary, suffix: string): string { - const repo = pr.repoOwner && pr.repoName ? `${pr.repoOwner}/${pr.repoName}` : ""; - const numberPart = `#${pr.githubPrNumber}`; - const base = repo ? `${repo} ${numberPart}` : numberPart; - return `${base} — ${suffix}`; -} - -/** - * Map a PR state transition into APNs envelopes. The polling service sends - * the `kind` it already computed; we translate it into user-facing copy. - */ -export function mapPrEvent(args: { - kind: PrNotificationKind; - pr: PrSummary; - titleOverride?: string; - messageOverride?: string; -}): MappedNotification[] { - const { kind, pr } = args; - const deepLink = `ade://pr/${pr.githubPrNumber}`; - const family = "pr" as const; - const collapseId = `pr:${pr.id}:${kind}`; - - switch (kind) { - case "checks_failing": - return [ - { - category: "PR_CI_FAILING", - family, - title: args.titleOverride ?? prHeadline(pr, "CI failing"), - body: truncate( - args.messageOverride ?? `${pr.title ?? "Pull request"} has failing required checks.`, - 178, - ), - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId, - deepLink, - metadata: { prId: pr.id, prNumber: pr.githubPrNumber, laneId: pr.laneId, githubUrl: pr.githubUrl }, - }, - ]; - case "review_requested": - return [ - { - category: "PR_REVIEW_REQUESTED", - family, - title: args.titleOverride ?? prHeadline(pr, "review requested"), - body: truncate( - args.messageOverride ?? `${pr.title ?? "Pull request"} is waiting for a review.`, - 178, - ), - pushType: "alert", - priority: 5, - interruptionLevel: "active", - collapseId, - deepLink, - metadata: { prId: pr.id, prNumber: pr.githubPrNumber, laneId: pr.laneId, githubUrl: pr.githubUrl }, - }, - ]; - case "changes_requested": - return [ - { - category: "PR_CHANGES_REQUESTED", - family, - title: args.titleOverride ?? prHeadline(pr, "changes requested"), - body: truncate( - args.messageOverride ?? `Reviewer requested changes on ${pr.title ?? "this PR"}.`, - 178, - ), - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId, - deepLink, - metadata: { prId: pr.id, prNumber: pr.githubPrNumber, laneId: pr.laneId, githubUrl: pr.githubUrl }, - }, - ]; - case "merge_ready": - return [ - { - category: "PR_MERGE_READY", - family, - title: args.titleOverride ?? prHeadline(pr, "ready to merge"), - body: truncate( - args.messageOverride ?? `${pr.title ?? "Pull request"} is approved and passing.`, - 178, - ), - pushType: "alert", - priority: 5, - interruptionLevel: "active", - collapseId, - deepLink, - metadata: { prId: pr.id, prNumber: pr.githubPrNumber, laneId: pr.laneId, githubUrl: pr.githubUrl }, - }, - ]; - default: - return []; - } -} - -export type SystemEvent = { - kind: "provider_outage" | "auth_rate_limit" | "hook_failure"; - title: string; - message: string; - deepLink?: string; -}; - -export function mapSystemEvent(event: SystemEvent): MappedNotification[] { - const commonBody = truncate(event.message, 178); - if (event.kind === "provider_outage") { - return [ - { - category: "SYSTEM_PROVIDER_OUTAGE", - iosCategory: "SYSTEM_ALERT", - family: "system", - title: event.title, - body: commonBody, - pushType: "alert", - priority: 5, - interruptionLevel: "active", - collapseId: "system:provider-outage", - deepLink: event.deepLink, - }, - ]; - } - if (event.kind === "auth_rate_limit") { - return [ - { - category: "SYSTEM_AUTH_RATE_LIMIT", - iosCategory: "SYSTEM_ALERT", - family: "system", - title: event.title, - body: commonBody, - pushType: "alert", - priority: 10, - interruptionLevel: "active", - collapseId: "system:auth-rate-limit", - deepLink: event.deepLink, - }, - ]; - } - return [ - { - category: "SYSTEM_HOOK_FAILURE", - iosCategory: "SYSTEM_ALERT", - family: "system", - title: event.title, - body: commonBody, - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - collapseId: "system:hook-failure", - deepLink: event.deepLink, - }, - ]; -} - -/** - * Given a mapped notification, convert it into the JSON payload APNs expects. - * Extracted so the event bus can call this after picking a device target. - */ -export function buildApnsPayload(mapped: MappedNotification): Record { - // `category` MUST be placed inside the `aps` dictionary — Apple's payload key - // reference requires this so iOS maps it to a registered `UNNotificationCategory` - // and renders the Approve/Deny/Reply/OpenPr/RetryChecks action buttons. - // See: developer.apple.com/library/archive/documentation/NetworkingInternet/ - // Conceptual/RemoteNotificationsPG/PayloadKeyReference.html - const aps: Record = { - alert: mapped.silent ? undefined : { title: mapped.title, body: mapped.body }, - sound: mapped.interruptionLevel === "time-sensitive" ? "default" : undefined, - "interruption-level": mapped.interruptionLevel, - "thread-id": mapped.collapseId, - "content-available": mapped.silent ? 1 : undefined, - "mutable-content": 1, - category: mapped.iosCategory ?? mapped.category, - }; - // APNs rejects explicit `undefined` values; strip them here. - for (const key of Object.keys(aps)) { - if (aps[key] === undefined) delete aps[key]; - } - return { - aps, - deepLink: mapped.deepLink ?? null, - ...mapped.metadata, - }; -} - -function parseTimeOfDayMinutes(value: string): number | null { - const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); - if (!match) return null; - const hours = Number(match[1]); - const minutes = Number(match[2]); - if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null; - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; - return hours * 60 + minutes; -} - -function zonedMinutes(nowMs: number, timezone: string): number | null { - try { - const parts = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - timeZone: timezone, - }).formatToParts(new Date(nowMs)); - const hour = Number(parts.find((part) => part.type === "hour")?.value); - const minute = Number(parts.find((part) => part.type === "minute")?.value); - if (!Number.isInteger(hour) || !Number.isInteger(minute)) return null; - return hour * 60 + minute; - } catch { - return null; - } -} - -function isWithinQuietHours( - quietHours: { - enabled: boolean; - start: string; - end: string; - timezone: string; - } | null | undefined, - nowMs: number, -): boolean { - if (!quietHours?.enabled) return false; - const start = parseTimeOfDayMinutes(quietHours.start); - const end = parseTimeOfDayMinutes(quietHours.end); - const current = zonedMinutes(nowMs, quietHours.timezone); - if (start == null || end == null || current == null || start === end) return false; - if (start < end) return current >= start && current < end; - return current >= start || current < end; -} - -/** Decide whether a mapped notification is allowed by the supplied prefs. */ -export function isAllowedByPrefs( - mapped: MappedNotification, - prefs: { - enabled: boolean; - chat: { awaitingInput: boolean; chatFailed: boolean; turnCompleted: boolean }; - cto: { subagentStarted: boolean; subagentFinished: boolean }; - prs: { ciFailing: boolean; reviewRequested: boolean; changesRequested: boolean; mergeReady: boolean }; - system: { providerOutage: boolean; authRateLimit: boolean; hookFailure: boolean }; - muteUntil?: string | null; - quietHours?: { - enabled: boolean; - start: string; - end: string; - timezone: string; - }; - perSessionOverrides?: Record; - } | null | undefined, - nowMs: number = Date.now(), -): boolean { - if (!prefs || !prefs.enabled) return false; - if (prefs.muteUntil) { - const muteMs = Date.parse(prefs.muteUntil); - if (Number.isFinite(muteMs) && muteMs > nowMs) return false; - } - if (isWithinQuietHours(prefs.quietHours, nowMs)) return false; - const sessionId = typeof mapped.metadata?.sessionId === "string" ? mapped.metadata.sessionId : null; - const sessionOverride = sessionId ? prefs.perSessionOverrides?.[sessionId] : null; - if (sessionOverride?.muted) return false; - if (sessionOverride?.awaitingInputOnly && mapped.category !== "CHAT_AWAITING_INPUT") return false; - switch (mapped.category) { - case "CHAT_AWAITING_INPUT": - return prefs.chat.awaitingInput; - case "CHAT_FAILED": - return prefs.chat.chatFailed; - case "CHAT_COMPLETED": - return prefs.chat.turnCompleted; - case "CTO_SUBAGENT_STARTED": - return prefs.cto.subagentStarted; - case "CTO_SUBAGENT_FINISHED": - return prefs.cto.subagentFinished; - case "PR_CI_FAILING": - return prefs.prs.ciFailing; - case "PR_REVIEW_REQUESTED": - return prefs.prs.reviewRequested; - case "PR_CHANGES_REQUESTED": - return prefs.prs.changesRequested; - case "PR_MERGE_READY": - return prefs.prs.mergeReady; - case "SYSTEM_PROVIDER_OUTAGE": - return prefs.system.providerOutage; - case "SYSTEM_AUTH_RATE_LIMIT": - return prefs.system.authRateLimit; - case "SYSTEM_HOOK_FAILURE": - return prefs.system.hookFailure; - default: - return false; - } -} diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index 9667decee..369127445 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -3,7 +3,6 @@ import type { createProjectConfigService } from "../config/projectConfigService" import type { createPrService } from "./prService"; import type { AdeDb } from "../state/kvDb"; import type { PrEventPayload, PrNotificationKind, PrSummary } from "../../../shared/types"; -import type { NotificationEventBus } from "../notifications/notificationEventBus"; import { nowIso } from "../shared/utils"; function clampMs(value: number, min: number, max: number): number { @@ -61,7 +60,6 @@ export function createPrPollingService({ onEvent, onPullRequestsChanged, db, - notificationEventBus, }: { logger: Logger; prService: ReturnType; @@ -80,12 +78,6 @@ export function createPrPollingService({ }) => void | Promise; /** Optional database handle used to persist `last_polled_at` per PR for delta polling. */ db?: AdeDb; - /** - * Optional notification bus. When provided, transition events (checks_failing, - * review_requested, changes_requested, merge_ready) are forwarded for mobile - * push fan-out in addition to the legacy `onEvent("pr-notification")` path. - */ - notificationEventBus?: NotificationEventBus | null; }) { const DEFAULT_INTERVAL_MS = 60_000; const MIN_INTERVAL_MS = 5_000; @@ -288,23 +280,6 @@ export function createPrPollingService({ headBranch: pr.headBranch ?? null, baseBranch: pr.baseBranch ?? null }); - // Also forward to the notification bus so the mobile push fan-out - // can route this event through APNs / in-app. Any failure here must - // not break the legacy event path. - try { - notificationEventBus?.publishPrEvent({ - kind, - pr, - titleOverride: summary.title, - messageOverride: summary.message, - }); - } catch (error) { - logger.warn("prs.notification_publish_failed", { - prId: pr.id, - kind, - error: error instanceof Error ? error.message : String(error), - }); - } } lastByPrId.set(pr.id, { diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 56c4ea6f7..a20a31c42 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../shared/types/sync"; import { isCrsqliteAvailable } from "../state/crsqliteExtension"; import { openKvDb } from "../state/kvDb"; import { nowIso } from "../shared/utils"; @@ -199,79 +198,4 @@ describe("deviceRegistryService", () => { } }); - it("persists notification preferences in device metadata across registry restarts", async () => { - const projectRoot = makeProjectRoot("ade-device-registry-prefs-"); - const dbPath = path.join(projectRoot, ".ade", "ade.db"); - - const db1 = await openKvDb(dbPath, createLogger() as any); - const registry1 = createDeviceRegistryService({ - db: db1, - logger: createLogger() as any, - projectRoot, - }); - const local = registry1.ensureLocalDevice(); - const prefs = { - ...DEFAULT_NOTIFICATION_PREFERENCES, - chat: { - ...DEFAULT_NOTIFICATION_PREFERENCES.chat, - awaitingInput: false, - }, - }; - - registry1.setNotificationPreferences(local.deviceId, prefs); - db1.close(); - - const db2 = await openKvDb(dbPath, createLogger() as any); - const registry2 = createDeviceRegistryService({ - db: db2, - logger: createLogger() as any, - projectRoot, - }); - - expect(registry2.getNotificationPreferences(local.deviceId)?.chat.awaitingInput).toBe(false); - db2.close(); - }); - - it("drops inactive per-session notification overrides while normalizing", () => { - const prefs = normalizeNotificationPreferences({ - ...DEFAULT_NOTIFICATION_PREFERENCES, - perSessionOverrides: { - inactive: { muted: false, awaitingInputOnly: false }, - muted: { muted: true, awaitingInputOnly: false }, - awaiting: { muted: false, awaitingInputOnly: true }, - }, - }); - - expect(prefs.perSessionOverrides).toEqual({ - muted: { muted: true, awaitingInputOnly: false }, - awaiting: { muted: false, awaitingInputOnly: true }, - }); - }); - - it("stores workspace Live Activity update tokens and invalidates only the rejected token", async () => { - const projectRoot = makeProjectRoot("ade-device-registry-apns-"); - const dbPath = path.join(projectRoot, ".ade", "ade.db"); - const db = await openKvDb(dbPath, createLogger() as any); - const registry = createDeviceRegistryService({ - db, - logger: createLogger() as any, - projectRoot, - }); - const local = registry.ensureLocalDevice(); - - registry.setApnsToken(local.deviceId, "alert-token", "alert", "sandbox", { bundleId: "com.ade.ios" }); - registry.setApnsToken(local.deviceId, "start-token", "activity-start", "sandbox"); - registry.setApnsToken(local.deviceId, "workspace-token", "activity-update", "sandbox"); - registry.setApnsToken(local.deviceId, "session-token", "activity-update", "sandbox", { activityId: "session-1" }); - - expect(registry.getApnsTokenForDevice(local.deviceId, "activity-update")).toBe("workspace-token"); - registry.invalidateApnsToken("session-token"); - - const metadata = registry.getDevice(local.deviceId)?.metadata ?? {}; - expect(metadata.apnsAlertToken).toBe("alert-token"); - expect(metadata.apnsActivityStartToken).toBe("start-token"); - expect(metadata.apnsActivityUpdateTokens).toEqual({ workspace: "workspace-token" }); - - db.close(); - }); }); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index c939d97ba..ab6578a53 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -203,11 +203,6 @@ import type { SyncRoleSnapshot, SyncStatusEventPayload, SyncTransferReadiness, - ApnsBridgeStatus, - ApnsBridgeSaveConfigArgs, - ApnsBridgeUploadKeyArgs, - ApnsBridgeSendTestPushArgs, - ApnsBridgeSendTestPushResult, CtoGetStateArgs, CtoEnsureSessionArgs, CtoListSessionLogsArgs, @@ -912,21 +907,6 @@ declare global { setActiveLanePresence: (args: { laneIds: string[] }) => Promise; onEvent: (cb: (event: SyncStatusEventPayload) => void) => () => void; }; - notifications: { - apns: { - getStatus: () => Promise; - saveConfig: ( - args: ApnsBridgeSaveConfigArgs, - ) => Promise; - uploadKey: ( - args: ApnsBridgeUploadKeyArgs, - ) => Promise; - clearKey: () => Promise; - sendTestPush: ( - args: ApnsBridgeSendTestPushArgs, - ) => Promise; - }; - }; agentTools: { detect: () => Promise; }; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 0e0bb5787..d56632dd9 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -3494,66 +3494,6 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.aiCursorCloudStreamRun, expect.anything()); }); - it("routes APNs settings reads through a bound remote runtime", async () => { - const binding = { - kind: "remote", - key: "remote:target-1:project-1", - targetId: "target-1", - runtimeName: "Remote", - projectId: "project-1", - rootPath: "/remote/project", - displayName: "Project", - }; - const status = { - enabled: true, - configured: true, - keyStored: true, - keyId: "KEY123", - teamId: "TEAM123", - bundleId: "com.ade.ios", - env: "sandbox", - }; - const invoke = vi.fn(async (channel: string) => { - if (channel === IPC.appGetWindowSession) { - return { windowId: 1, project: null, binding }; - } - if (channel === IPC.remoteRuntimeCallAction) { - return { ok: true, domain: "notifications_apns", action: "getStatus", result: status, statusHints: {} }; - } - return undefined; - }); - const on = vi.fn(); - const removeListener = vi.fn(); - const exposeInMainWorld = vi.fn((name: string, value: unknown) => { - (globalThis as any).__bridgeName = name; - (globalThis as any).__adeBridge = value; - }); - - vi.doMock("electron", () => ({ - contextBridge: { exposeInMainWorld }, - ipcRenderer: { invoke, on, removeListener }, - webFrame: { - getZoomLevel: vi.fn(() => 0), - setZoomLevel: vi.fn(), - getZoomFactor: vi.fn(() => 1), - }, - })); - - await import("./preload"); - - const bridge = (globalThis as any).__adeBridge; - await expect(bridge.notifications.apns.getStatus()).resolves.toEqual(status); - expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { - id: "target-1", - projectId: "project-1", - request: { - domain: "notifications_apns", - action: "getStatus", - }, - }); - expect(invoke).not.toHaveBeenCalledWith(IPC.notificationsApnsGetStatus); - }); - it("fans out remote PTY data notifications from the live runtime event stream", async () => { const binding = { kind: "remote", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bd32350bd..4aec63f85 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -83,11 +83,6 @@ import type { SyncRoleSnapshot, SyncStatusEventPayload, SyncTransferReadiness, - ApnsBridgeStatus, - ApnsBridgeSaveConfigArgs, - ApnsBridgeUploadKeyArgs, - ApnsBridgeSendTestPushArgs, - ApnsBridgeSendTestPushResult, DraftPrDescriptionArgs, CtoGetStateArgs, CtoEnsureSessionArgs, @@ -3824,36 +3819,6 @@ contextBridge.exposeInMainWorld("ade", { }; }, }, - notifications: { - apns: { - getStatus: async (): Promise => - callProjectRuntimeActionOr("notifications_apns", "getStatus", {}, () => - ipcRenderer.invoke(IPC.notificationsApnsGetStatus), - ), - saveConfig: async ( - args: ApnsBridgeSaveConfigArgs, - ): Promise => - callProjectRuntimeActionOr("notifications_apns", "saveConfig", { args }, () => - ipcRenderer.invoke(IPC.notificationsApnsSaveConfig, args), - ), - uploadKey: async ( - args: ApnsBridgeUploadKeyArgs, - ): Promise => - callProjectRuntimeActionOr("notifications_apns", "uploadKey", { args }, () => - ipcRenderer.invoke(IPC.notificationsApnsUploadKey, args), - ), - clearKey: async (): Promise => - callProjectRuntimeActionOr("notifications_apns", "clearKey", {}, () => - ipcRenderer.invoke(IPC.notificationsApnsClearKey), - ), - sendTestPush: async ( - args: ApnsBridgeSendTestPushArgs, - ): Promise => - callProjectRuntimeActionOr("notifications_apns", "sendTestPush", { args }, () => - ipcRenderer.invoke(IPC.notificationsApnsSendTestPush, args), - ), - }, - }, agentTools: { detect: async (): Promise => ipcRenderer.invoke(IPC.agentToolsDetect), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 52bf76bd3..be2dcfaa6 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3088,16 +3088,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { platform: "darwin", }; - const BROWSER_MOCK_APNS_STATUS: any = { - enabled: false, - configured: false, - keyStored: false, - keyId: null, - teamId: null, - bundleId: null, - env: "sandbox" as const, - }; - (window as any).ade = { app: { ping: resolved("pong" as const), @@ -3476,15 +3466,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { agentTools: { detect: resolved([]), }, - notifications: { - apns: { - getStatus: resolved(BROWSER_MOCK_APNS_STATUS), - saveConfig: resolvedArg({ ...BROWSER_MOCK_APNS_STATUS }), - uploadKey: resolvedArg({ ...BROWSER_MOCK_APNS_STATUS }), - clearKey: resolved(BROWSER_MOCK_APNS_STATUS), - sendTestPush: resolvedArg({ ok: false, reason: "browser mock" }), - }, - }, devTools: { detect: resolved(BROWSER_MOCK_DEVTOOLS_CHECK), }, diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 95c43cc90..6f47f3bbc 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect } from "react"; import { useSearchParams, useLocation, useNavigate } from "react-router-dom"; -import { Brain, ChartLineUp, GearSix, Stack, Plugs, Palette, DeviceMobile, Robot } from "@phosphor-icons/react"; +import { Brain, ChartLineUp, GearSix, Stack, Plugs, Palette, Robot } from "@phosphor-icons/react"; import { GeneralSection } from "../settings/GeneralSection"; import { AppearanceSection } from "../settings/AppearanceSection"; import { LaneTemplatesSection } from "../settings/LaneTemplatesSection"; @@ -8,7 +8,6 @@ import { LaneBehaviorSection } from "../settings/LaneBehaviorSection"; import { ProvidersSection } from "../settings/ProvidersSection"; import { AiFeaturesSection } from "../settings/AiFeaturesSection"; import { IntegrationsSettingsSection } from "../settings/IntegrationsSettingsSection"; -import { MobilePushPanel } from "../settings/MobilePushPanel"; import { AdeUsageSection } from "../settings/AdeUsageSection"; import { RemoteSettingsBanner } from "../settings/RemoteContextBadge"; import { COLORS, SANS_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; @@ -18,7 +17,6 @@ const SECTIONS = [ { id: "appearance", label: "Appearance", icon: Palette }, { id: "ai", label: "AI Connections", icon: Brain }, { id: "background-jobs", label: "Background Jobs", icon: Robot }, - { id: "mobile-push", label: "Mobile Push", icon: DeviceMobile }, { id: "integrations", label: "Integrations", icon: Plugs }, { id: "lane-templates", label: "Lane Templates", icon: Stack }, { id: "ade-usage", label: "Stats", icon: ChartLineUp }, @@ -32,9 +30,9 @@ const TAB_ALIASES: Record = { context: "general", providers: "ai", automations: "background-jobs", - sync: "mobile-push", - devices: "mobile-push", - "multi-device": "mobile-push", + sync: "general", + devices: "general", + "multi-device": "general", github: "integrations", linear: "integrations", "computer-use": "integrations", @@ -166,7 +164,7 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { - - )} - - - {/* Advanced: bundle id + key id for overrides */} - - {showAdvanced ? ( -
- - -
- ) : null} - - - {/* Test push grid */} -
-
- -
-
- Test pushes -
-
- {isConfigured - ? "One-click: pick a notification type, it fires to the first paired iOS device." - : "Finish the APNs setup above first — drop your .p8 and set Team ID."} -
-
-
- -
- {TEST_PUSH_CATALOG.map((def) => { - const disabled = !bridgeAvailable || !isConfigured || sendingKind != null; - const sending = sendingKind === def.kind; - return ( - - ); - })} -
- - {isConfigured ? null : ( -
- - Tests are also disabled until your iPhone registers an APNs token — that happens within seconds of - the iOS app launching and pairing with this machine. -
- )} -
- - {/* Advanced hint if configured but something's off */} - {isConfigured && !status.enabled ? ( -
Config saved but not enabled. Something went wrong — try dropping the .p8 again.
- ) : null} - - - - ); -} diff --git a/apps/desktop/src/renderer/onboarding/tours/settingsHighlightsTour.ts b/apps/desktop/src/renderer/onboarding/tours/settingsHighlightsTour.ts index 7ffa54def..e8917cfa0 100644 --- a/apps/desktop/src/renderer/onboarding/tours/settingsHighlightsTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/settingsHighlightsTour.ts @@ -11,7 +11,7 @@ export const settingsHighlightsTour: Tour = { id: "h.settings.what", target: "", title: "Settings", - body: "App-wide configuration: theme, AI providers, mobile push, lane templates, integrations.", + body: "App-wide configuration: theme, AI providers, lane templates, integrations.", docUrl: docs.settingsGeneral, }, { diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 9e84b9c48..11feb91c9 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -754,11 +754,6 @@ export const IPC = { updateQuitAndInstall: "ade.update.quitAndInstall", updateDismissInstalledNotice: "ade.update.dismissInstalledNotice", updateEvent: "ade.update.event", - notificationsApnsGetStatus: "ade.notifications.apns.getStatus", - notificationsApnsSaveConfig: "ade.notifications.apns.saveConfig", - notificationsApnsUploadKey: "ade.notifications.apns.uploadKey", - notificationsApnsClearKey: "ade.notifications.apns.clearKey", - notificationsApnsSendTestPush: "ade.notifications.apns.sendTestPush", transcriptionTranscribe: "ade.transcription.transcribe", transcriptionStatus: "ade.transcription.status", transcriptionRequestMicAccess: "ade.transcription.requestMicAccess", diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 2a2886d12..979524268 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1354,32 +1354,6 @@ export type AiConfig = { sessionIntelligence?: SessionIntelligenceConfig; }; -/** - * Mobile push notification configuration. The `.p8` key itself is never - * stored in config — it lives in an Electron `safeStorage`-encrypted blob - * under `userData/apns.key.enc`. Only metadata needed to reconstruct the - * APNs JWT sits here. - */ -export type NotificationApnsConfig = { - enabled: boolean; - /** Apple Developer Key ID (10-char). */ - keyId?: string; - /** Apple Developer Team ID (10-char). */ - teamId?: string; - /** iOS app bundle id, e.g. `com.ade.ios`. */ - bundleId?: string; - env: "sandbox" | "production"; - /** - * Set to `true` once a `.p8` has been saved to the encrypted blob. - * The config does NOT carry the key bytes themselves. - */ - keyStored?: boolean; -}; - -export type NotificationsConfig = { - apns?: NotificationApnsConfig; -}; - export type ProjectIdentityConfig = { /** * Project-root-relative path to the icon shown in ADE project tabs/catalogs. @@ -1456,8 +1430,6 @@ export type ProjectConfigFile = { providers?: Record; linearSync?: LinearSyncConfig; ui?: ProjectUiConfig; - /** Mobile push notification configuration (APNs). */ - notifications?: NotificationsConfig; }; export type ProjectConfigCandidate = { @@ -1508,7 +1480,6 @@ export type EffectiveProjectConfig = { claudeProjectsRoot?: string; }; }; - notifications?: NotificationsConfig; }; export type ProjectConfigValidationIssue = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 7084f44ee..b5bb5b720 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -934,165 +934,6 @@ export type SyncCommandResultPayload = { }; }; -// --------------------------------------------------------------------------- -// Mobile push notification types (WS2) -// --------------------------------------------------------------------------- - -export type ApnsEnvironment = "sandbox" | "production"; - -export type ApnsPushTokenKind = "alert" | "activity-start" | "activity-update"; - -/** - * Shape of the Mobile Push settings panel's read of the main-process APNs state. - * `keyStored` reflects whether a `.p8` is persisted via `safeStorage`; the bytes - * themselves never round-trip to the renderer. - */ -export type ApnsBridgeStatus = { - enabled: boolean; - configured: boolean; - keyStored: boolean; - keyId: string | null; - teamId: string | null; - bundleId: string | null; - env: ApnsEnvironment; -}; - -export type ApnsBridgeSaveConfigArgs = { - enabled: boolean; - keyId: string; - teamId: string; - bundleId: string; - env: ApnsEnvironment; -}; - -export type ApnsBridgeUploadKeyArgs = { - /** PEM-formatted `.p8` body. The main process encrypts before writing to disk. */ - p8Pem: string; -}; - -/** - * Named category of the fake notification the Mobile Push panel sends. - * Each maps to a distinct APNs payload template so the user can exercise - * every iOS code path (awaiting-input banner, CI-failing retry, etc.) - * without having to trigger a real domain event. - */ -export type ApnsTestPushKind = - | "awaiting_input" - | "chat_failed" - | "chat_turn_completed" - | "ci_failing" - | "review_requested" - | "merge_ready" - | "cto_subagent_finished" - | "generic" - // Live Activity tests — drive the workspace-pill UI on the device. - | "la_update_running" - | "la_update_attention" - | "la_update_multi" - | "la_start" - | "la_end"; - -export type ApnsBridgeSendTestPushArgs = { - /** Specific device to target. Null/undefined picks the first iOS peer with an alert token. */ - deviceId?: string | null; - /** Which fake payload to fire. Defaults to `"generic"` for back-compat. */ - kind?: ApnsTestPushKind; -}; - -export type ApnsBridgeSendTestPushResult = { - ok: boolean; - reason?: string; -}; - -/** - * Sent from an iOS peer to the desktop host whenever it registers or rotates - * an APNs token. Stored in the device registry metadata so subsequent pushes - * can target the correct device + token kind. - */ -export type SyncRegisterPushTokenPayload = { - token: string; - kind: ApnsPushTokenKind; - env: ApnsEnvironment; - bundleId: string; - /** Optional extra context that we may route on; kept open-ended. */ - activityId?: string; - /** `true` once the peer has confirmed it actually received a previous test push. */ - verified?: boolean; -}; - -/** - * 14 user-tunable toggles mirroring the iOS Notifications Center screen. - * The host uses these as a filter at send-time so toggles take effect live. - */ -export type NotificationPreferences = { - /** Master switch; if false, all of the below are short-circuited. */ - enabled: boolean; - chat: { - awaitingInput: boolean; - chatFailed: boolean; - turnCompleted: boolean; - }; - cto: { - subagentStarted: boolean; - subagentFinished: boolean; - }; - prs: { - ciFailing: boolean; - reviewRequested: boolean; - changesRequested: boolean; - mergeReady: boolean; - }; - system: { - providerOutage: boolean; - authRateLimit: boolean; - hookFailure: boolean; - }; - /** Optional quiet-hours gate in 24h `HH:MM` format, inclusive start / exclusive end. */ - quietHours?: { - enabled: boolean; - start: string; - end: string; - timezone: string; - }; - /** Per-session APNs routing overrides keyed by chat/session id. */ - perSessionOverrides?: Record; - /** Global mute applied by the Control Widget ("snooze for N minutes"). */ - muteUntil?: string | null; -}; - -export type SyncNotificationPrefsPayload = { - prefs: NotificationPreferences; -}; - -export type SyncSendTestPushPayload = { - kind: "alert" | "activity"; - /** Optional override body for the test push; otherwise a canned message is used. */ - title?: string; - body?: string; -}; - -/** - * Payload pushed to an iOS peer over the existing sync WebSocket when the - * desktop decides an event is foreground-only (no APNs fan-out needed) or - * when APNs is disabled. - */ -export type SyncInAppNotificationPayload = { - category: "chat" | "cto" | "pr" | "system"; - title: string; - body: string; - /** Used by the client for de-duplication with any parallel APNs delivery. */ - collapseId?: string; - /** Deep link target: `ade://session/` / `ade://pr/` / etc. */ - deepLink?: string; - /** Optional routing hints used by the iOS notification formatter. */ - metadata?: Record; - /** ISO8601. Helps the client reason about stale notifications. */ - generatedAt: string; -}; - type SyncEnvelopeBase = { version: SyncProtocolVersion; type: TType; @@ -1157,11 +998,6 @@ export type SyncBrainStatusEnvelope = SyncEnvelopeWithPayload<"brain_status", Sy export type SyncCommandEnvelope = SyncEnvelopeWithPayload<"command", SyncCommandPayload>; export type SyncCommandAckEnvelope = SyncEnvelopeWithPayload<"command_ack", SyncCommandAckPayload>; export type SyncCommandResultEnvelope = SyncEnvelopeWithPayload<"command_result", SyncCommandResultPayload>; -export type SyncRegisterPushTokenEnvelope = SyncEnvelopeWithPayload<"register_push_token", SyncRegisterPushTokenPayload>; -export type SyncNotificationPrefsEnvelope = SyncEnvelopeWithPayload<"notification_prefs", SyncNotificationPrefsPayload>; -export type SyncSendTestPushEnvelope = SyncEnvelopeWithPayload<"send_test_push", SyncSendTestPushPayload>; -export type SyncInAppNotificationEnvelope = SyncEnvelopeWithPayload<"in_app_notification", SyncInAppNotificationPayload>; - /** * One slice of an oversized encoded envelope. `part` is a base64 slice of the * full encoded envelope's UTF-8 bytes; clients concatenate parts in `index` @@ -1221,95 +1057,4 @@ export type SyncEnvelope = | SyncCommandEnvelope | SyncCommandAckEnvelope | SyncCommandResultEnvelope - | SyncRegisterPushTokenEnvelope - | SyncNotificationPrefsEnvelope - | SyncSendTestPushEnvelope - | SyncInAppNotificationEnvelope | SyncEnvelopeChunkEnvelope; - -export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { - enabled: true, - chat: { - awaitingInput: true, - chatFailed: true, - turnCompleted: false, - }, - cto: { - subagentStarted: false, - subagentFinished: true, - }, - prs: { - ciFailing: true, - reviewRequested: true, - changesRequested: true, - mergeReady: true, - }, - system: { - providerOutage: true, - authRateLimit: true, - hookFailure: false, - }, - muteUntil: null, -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function booleanOrDefault(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function stringOrDefault(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; -} - -function normalizePrefsGroup>( - input: unknown, - defaults: T, -): T { - const raw = isRecord(input) ? input : {}; - const next = { ...defaults }; - for (const key of Object.keys(defaults) as Array) { - next[key] = booleanOrDefault(raw[key as string], defaults[key]) as T[keyof T]; - } - return next; -} - -export function normalizeNotificationPreferences(input: unknown): NotificationPreferences { - const raw = isRecord(input) ? input : {}; - const quietHoursRaw = isRecord(raw.quietHours) ? raw.quietHours : null; - const perSessionRaw = isRecord(raw.perSessionOverrides) ? raw.perSessionOverrides : {}; - const perSessionOverrides: NonNullable = {}; - for (const [sessionId, override] of Object.entries(perSessionRaw)) { - if (!isRecord(override) || !sessionId.trim()) continue; - const normalizedOverride = { - muted: booleanOrDefault(override.muted, false), - awaitingInputOnly: booleanOrDefault(override.awaitingInputOnly, false), - }; - if (!normalizedOverride.muted && !normalizedOverride.awaitingInputOnly) continue; - perSessionOverrides[sessionId] = normalizedOverride; - } - return { - enabled: booleanOrDefault(raw.enabled, DEFAULT_NOTIFICATION_PREFERENCES.enabled), - chat: normalizePrefsGroup(raw.chat, DEFAULT_NOTIFICATION_PREFERENCES.chat), - cto: normalizePrefsGroup(raw.cto, DEFAULT_NOTIFICATION_PREFERENCES.cto), - prs: normalizePrefsGroup(raw.prs, DEFAULT_NOTIFICATION_PREFERENCES.prs), - system: normalizePrefsGroup(raw.system, DEFAULT_NOTIFICATION_PREFERENCES.system), - ...(quietHoursRaw - ? { - quietHours: { - enabled: booleanOrDefault(quietHoursRaw.enabled, false), - start: stringOrDefault(quietHoursRaw.start, "22:00"), - end: stringOrDefault(quietHoursRaw.end, "07:00"), - timezone: stringOrDefault( - quietHoursRaw.timezone, - Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", - ), - }, - } - : {}), - ...(Object.keys(perSessionOverrides).length > 0 ? { perSessionOverrides } : {}), - muteUntil: typeof raw.muteUntil === "string" || raw.muteUntil === null ? raw.muteUntil : DEFAULT_NOTIFICATION_PREFERENCES.muteUntil, - }; -} diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 72f3fd913..c390622fe 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -25,7 +25,6 @@ AA5500000000000000000014 /* WidgetAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5500000000000000000004 /* WidgetAppIntents.swift */; }; AA5500000000000000000015 /* WidgetAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5500000000000000000004 /* WidgetAppIntents.swift */; }; AA5500000000000000000016 /* WidgetAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5500000000000000000004 /* WidgetAppIntents.swift */; }; - AA5400000000000000000016 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5400000000000000000004 /* NotificationService.swift */; }; AA5100000000000000000017 /* ADELiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5100000000000000000001 /* ADELiveActivity.swift */; }; AA5100000000000000000018 /* ADELiveActivityViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5100000000000000000002 /* ADELiveActivityViews.swift */; }; AA5600000000000000000011 /* ADELiveActivityPrimitives.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5600000000000000000001 /* ADELiveActivityPrimitives.swift */; }; @@ -34,14 +33,7 @@ AA5800000000000000000012 /* DictationActivityShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5800000000000000000001 /* DictationActivityShared.swift */; }; AA5800000000000000000022 /* DictationLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5800000000000000000002 /* DictationLiveActivity.swift */; }; D1C7A70000000000000001B1 /* DictationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C7A70000000000000001A1 /* DictationController.swift */; }; - F300000000000000000000F0 /* NotificationPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F300000000000000000000E0 /* NotificationPreferences.swift */; }; - F300000000000000000000F1 /* NotificationsCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F300000000000000000000E1 /* NotificationsCenterView.swift */; }; - F300000000000000000000F2 /* SettingsNotificationsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F300000000000000000000E2 /* SettingsNotificationsSection.swift */; }; - F300000000000000000000F3 /* QuietHoursEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F300000000000000000000E3 /* QuietHoursEditorView.swift */; }; - F300000000000000000000F4 /* PerSessionOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F300000000000000000000E4 /* PerSessionOverrideView.swift */; }; - AA5300000000000000000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000011 /* AppDelegate.swift */; }; AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000012 /* DeepLinkRouter.swift */; }; - AA5300000000000000000003 /* NotificationCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000013 /* NotificationCategories.swift */; }; K10000000000000000000001 /* SendToMacCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = K20000000000000000000001 /* SendToMacCard.swift */; }; K10000000000000000000002 /* DeepLinkURLParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = K20000000000000000000002 /* DeepLinkURLParsing.swift */; }; AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000001 /* ADEWidgetBundle.swift */; }; @@ -52,7 +44,6 @@ AA5200000000000000000016 /* ADEWidgetPreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000006 /* ADEWidgetPreviewData.swift */; }; AA5700000000000000000012 /* ADELiveActivityPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5700000000000000000002 /* ADELiveActivityPreviews.swift */; }; AA2200000000000000000001 /* ADEWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000002 /* ADEWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - BB2200000000000000000001 /* ADENotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* ADENotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D5B5B87564C73F2FF34B0D /* KeychainService.swift */; }; 0A1E077A24A5367ED58900F9 /* ADEDesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9E6135ED52117E41DE95F7 /* ADEDesignSystem.swift */; }; 1558E44876BBE931C02D5640 /* ADEApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9411193AF56B236BA32EFF5 /* ADEApp.swift */; }; @@ -232,13 +223,6 @@ remoteGlobalIDString = AA0000000000000000000001; remoteInfo = ADEWidgets; }; - BB4400000000000000000001 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 9CBC925352322D208431EFAA /* Project object */; - proxyType = 1; - remoteGlobalIDString = BB0000000000000000000001; - remoteInfo = ADENotificationService; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -246,7 +230,6 @@ AA1000000000000000000002 /* ADESharedModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedModels.swift; path = ADE/Shared/ADESharedModels.swift; sourceTree = ""; }; AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = ""; }; AA0000000000000000000002 /* ADEWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ADEWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - BB0000000000000000000002 /* ADENotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ADENotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA5000000000000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ADEWidgets/Info.plist; sourceTree = ""; }; AA5000000000000000000002 /* ADEWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = ADEWidgets.entitlements; path = ADEWidgets/ADEWidgets.entitlements; sourceTree = ""; }; AA5100000000000000000001 /* ADELiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADELiveActivity.swift; path = ADEWidgets/ADELiveActivity.swift; sourceTree = ""; }; @@ -254,15 +237,7 @@ AA5100000000000000000003 /* LiveActivityCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LiveActivityCoordinator.swift; path = ADE/Services/LiveActivityCoordinator.swift; sourceTree = ""; }; AA5100000000000000000004 /* LiveActivityIntentsForward.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LiveActivityIntentsForward.swift; path = ADE/Shared/LiveActivityIntentsForward.swift; sourceTree = ""; }; AA5500000000000000000004 /* WidgetAppIntents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetAppIntents.swift; path = ADE/Shared/WidgetAppIntents.swift; sourceTree = ""; }; - AA5400000000000000000004 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = ADENotificationService/NotificationService.swift; sourceTree = ""; }; - F300000000000000000000E0 /* NotificationPreferences.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationPreferences.swift; path = ADE/Models/NotificationPreferences.swift; sourceTree = ""; }; - F300000000000000000000E1 /* NotificationsCenterView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationsCenterView.swift; path = ADE/Views/Settings/NotificationsCenterView.swift; sourceTree = ""; }; - F300000000000000000000E2 /* SettingsNotificationsSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsNotificationsSection.swift; path = ADE/Views/Settings/SettingsNotificationsSection.swift; sourceTree = ""; }; - F300000000000000000000E3 /* QuietHoursEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = QuietHoursEditorView.swift; path = ADE/Views/Settings/QuietHoursEditorView.swift; sourceTree = ""; }; - F300000000000000000000E4 /* PerSessionOverrideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PerSessionOverrideView.swift; path = ADE/Views/Settings/PerSessionOverrideView.swift; sourceTree = ""; }; - AA5300000000000000000011 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ADE/App/AppDelegate.swift; sourceTree = ""; }; AA5300000000000000000012 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkRouter.swift; path = ADE/App/DeepLinkRouter.swift; sourceTree = ""; }; - AA5300000000000000000013 /* NotificationCategories.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationCategories.swift; path = ADE/App/NotificationCategories.swift; sourceTree = ""; }; K20000000000000000000001 /* SendToMacCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SendToMacCard.swift; path = ADE/Views/Deeplinks/SendToMacCard.swift; sourceTree = ""; }; K20000000000000000000002 /* DeepLinkURLParsing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkURLParsing.swift; path = ADE/App/DeepLinkURLParsing.swift; sourceTree = ""; }; AA5200000000000000000001 /* ADEWidgetBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWidgetBundle.swift; path = ADEWidgets/ADEWidgetBundle.swift; sourceTree = ""; }; @@ -276,8 +251,6 @@ AA5800000000000000000001 /* DictationActivityShared.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DictationActivityShared.swift; path = ADE/Shared/DictationActivityShared.swift; sourceTree = ""; }; AA5800000000000000000002 /* DictationLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DictationLiveActivity.swift; path = ADEWidgets/DictationLiveActivity.swift; sourceTree = ""; }; D1C7A70000000000000001A1 /* DictationController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DictationController.swift; path = ADE/Services/Dictation/DictationController.swift; sourceTree = ""; }; - BB5000000000000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ADENotificationService/Info.plist; sourceTree = ""; }; - BB5000000000000000000002 /* ADENotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = ADENotificationService.entitlements; path = ADENotificationService/ADENotificationService.entitlements; sourceTree = ""; }; CC5000000000000000000001 /* ADE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = ADE.entitlements; path = ADE/ADE.entitlements; sourceTree = ""; }; 0C6ECFA9D57E70E57A60E8AB /* WorkTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTabView.swift; path = ADE/Views/WorkTabView.swift; sourceTree = ""; }; A10000000000000000000020 /* WorkBrowserHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkBrowserHelpers.swift; path = ADE/Views/Work/WorkBrowserHelpers.swift; sourceTree = ""; }; @@ -469,13 +442,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BB0000000000000000000011 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -697,10 +663,6 @@ F20000000000000000000005 /* SettingsAppearanceSection.swift */, AB92EA06D5B7FD42F715A80C /* SettingsVoiceInputSection.swift */, F20000000000000000000006 /* SettingsDiagnosticsSection.swift */, - F300000000000000000000E1 /* NotificationsCenterView.swift */, - F300000000000000000000E2 /* SettingsNotificationsSection.swift */, - F300000000000000000000E3 /* QuietHoursEditorView.swift */, - F300000000000000000000E4 /* PerSessionOverrideView.swift */, ); name = Settings; sourceTree = ""; @@ -742,10 +704,8 @@ C9411193AF56B236BA32EFF5 /* ADEApp.swift */, 73B17472FAC08845853BC6B4 /* ContentView.swift */, B2B52EED21B04E9700000001 /* RemoteProjectAddSheet.swift */, - AA5300000000000000000011 /* AppDelegate.swift */, AA5300000000000000000012 /* DeepLinkRouter.swift */, K20000000000000000000002 /* DeepLinkURLParsing.swift */, - AA5300000000000000000013 /* NotificationCategories.swift */, ); name = App; sourceTree = ""; @@ -757,7 +717,6 @@ 6AC81F4E8475EA47F592B212 /* Frameworks */, 7A6120AF79287BB301D63A58 /* ADE */, AA3000000000000000000001 /* ADEWidgets */, - BB3000000000000000000001 /* ADENotificationService */, C77DE76E58CC8D681F4D4619 /* ADETests */, ); sourceTree = ""; @@ -795,16 +754,6 @@ name = ADEWidgets; sourceTree = ""; }; - BB3000000000000000000001 /* ADENotificationService */ = { - isa = PBXGroup; - children = ( - BB5000000000000000000001 /* Info.plist */, - BB5000000000000000000002 /* ADENotificationService.entitlements */, - AA5400000000000000000004 /* NotificationService.swift */, - ); - name = ADENotificationService; - sourceTree = ""; - }; 6AC81F4E8475EA47F592B212 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -835,7 +784,6 @@ E3A5721EB84321D201716BC3 /* ADETests.xctest */, CCAB2414C359E971B780BF99 /* PreviewHost.app */, AA0000000000000000000002 /* ADEWidgets.appex */, - BB0000000000000000000002 /* ADENotificationService.appex */, ); name = Products; sourceTree = ""; @@ -844,7 +792,6 @@ isa = PBXGroup; children = ( 483C5F1818BAE74B19B84617 /* RemoteModels.swift */, - F300000000000000000000E0 /* NotificationPreferences.swift */, ); name = Models; sourceTree = ""; @@ -903,7 +850,6 @@ ); dependencies = ( AA4400000000000000000002 /* PBXTargetDependency */, - BB4400000000000000000002 /* PBXTargetDependency */, ); name = ADE; packageProductDependencies = ( @@ -930,23 +876,6 @@ productReference = AA0000000000000000000002 /* ADEWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; - BB0000000000000000000001 /* ADENotificationService */ = { - isa = PBXNativeTarget; - buildConfigurationList = BB6000000000000000000001 /* Build configuration list for PBXNativeTarget "ADENotificationService" */; - buildPhases = ( - BB0000000000000000000010 /* Sources */, - BB0000000000000000000011 /* Frameworks */, - BB0000000000000000000012 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = ADENotificationService; - productName = ADENotificationService; - productReference = BB0000000000000000000002 /* ADENotificationService.appex */; - productType = "com.apple.product-type.app-extension"; - }; /* End PBXNativeTarget section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -957,7 +886,6 @@ dstSubfolderSpec = 13; files = ( AA2200000000000000000001 /* ADEWidgets.appex in Embed Foundation Extensions */, - BB2200000000000000000001 /* ADENotificationService.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -999,16 +927,6 @@ }; }; }; - BB0000000000000000000001 = { - CreatedOnToolsVersion = 16.0; - DevelopmentTeam = VQ372F39G6; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - }; - }; }; }; buildConfigurationList = 966E640F465400271B948B96 /* Build configuration list for PBXProject "ADE" */; @@ -1029,7 +947,6 @@ targets = ( 928432ECB10B6E8870725B02 /* ADE */, AA0000000000000000000001 /* ADEWidgets */, - BB0000000000000000000001 /* ADENotificationService */, 62C217CE2C1C31B3127D1ACF /* ADETests */, ); }; @@ -1060,13 +977,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BB0000000000000000000012 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1237,15 +1147,8 @@ AA5100000000000000000017 /* ADELiveActivity.swift in Sources */, AA5100000000000000000018 /* ADELiveActivityViews.swift in Sources */, AA5600000000000000000011 /* ADELiveActivityPrimitives.swift in Sources */, - F300000000000000000000F0 /* NotificationPreferences.swift in Sources */, - F300000000000000000000F1 /* NotificationsCenterView.swift in Sources */, - F300000000000000000000F2 /* SettingsNotificationsSection.swift in Sources */, - F300000000000000000000F3 /* QuietHoursEditorView.swift in Sources */, - F300000000000000000000F4 /* PerSessionOverrideView.swift in Sources */, - AA5300000000000000000001 /* AppDelegate.swift in Sources */, AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */, K10000000000000000000002 /* DeepLinkURLParsing.swift in Sources */, - AA5300000000000000000003 /* NotificationCategories.swift in Sources */, K10000000000000000000001 /* SendToMacCard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1285,19 +1188,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BB0000000000000000000010 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - AA1100000000000000000021 /* ADESharedContainer.swift in Sources */, - AA1100000000000000000022 /* ADESharedModels.swift in Sources */, - AA1100000000000000000023 /* ADESharedTheme.swift in Sources */, - AA5100000000000000000016 /* LiveActivityIntentsForward.swift in Sources */, - AA5500000000000000000016 /* WidgetAppIntents.swift in Sources */, - AA5400000000000000000016 /* NotificationService.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1313,12 +1203,6 @@ target = AA0000000000000000000001 /* ADEWidgets */; targetProxy = AA4400000000000000000001 /* PBXContainerItemProxy */; }; - BB4400000000000000000002 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = ADENotificationService; - target = BB0000000000000000000001 /* ADENotificationService */; - targetProxy = BB4400000000000000000001 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1344,7 +1228,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - APS_ENVIRONMENT = development; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGNING_ALLOWED = YES; CODE_SIGN_ENTITLEMENTS = ADE/ADE.entitlements; @@ -1422,7 +1305,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - APS_ENVIRONMENT = production; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGNING_ALLOWED = YES; CODE_SIGN_ENTITLEMENTS = ADE/ADE.entitlements; @@ -1581,65 +1463,6 @@ }; name = Release; }; - BB6000000000000000000010 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGNING_ALLOWED = YES; - CODE_SIGN_ENTITLEMENTS = ADENotificationService/ADENotificationService.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = VQ372F39G6; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = ADENotificationService/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "ADE Notifications"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.notificationservice; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - BB6000000000000000000011 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGNING_ALLOWED = YES; - CODE_SIGN_ENTITLEMENTS = ADENotificationService/ADENotificationService.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = VQ372F39G6; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = ADENotificationService/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "ADE Notifications"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.notificationservice; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1679,15 +1502,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - BB6000000000000000000001 /* Build configuration list for PBXNativeTarget "ADENotificationService" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - BB6000000000000000000010 /* Debug */, - BB6000000000000000000011 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/apps/ios/ADE/ADE.entitlements b/apps/ios/ADE/ADE.entitlements index 766395701..7aa57eab8 100644 --- a/apps/ios/ADE/ADE.entitlements +++ b/apps/ios/ADE/ADE.entitlements @@ -2,13 +2,9 @@ - aps-environment - $(APS_ENVIRONMENT) com.apple.security.application-groups group.com.ade.ios - com.apple.developer.usernotifications.time-sensitive - diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 9c02dfb02..d81cc7088 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -2,7 +2,6 @@ import SwiftUI @main struct ADEApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @Environment(\.scenePhase) private var scenePhase @StateObject private var syncService = SyncService() /// App-level dictation singleton. Owning the single `SpeechDictationService` @@ -63,4 +62,3 @@ struct ADEApp: App { } } } - diff --git a/apps/ios/ADE/App/AppDelegate.swift b/apps/ios/ADE/App/AppDelegate.swift deleted file mode 100644 index d9a1dc7a1..000000000 --- a/apps/ios/ADE/App/AppDelegate.swift +++ /dev/null @@ -1,181 +0,0 @@ -import UIKit -import UserNotifications -import os - -private let appDelegateLog = Logger(subsystem: "com.ade.app", category: "notifications") - -/// Owns APNs registration, notification-category setup, and foreground / -/// response routing for push notifications. -/// -/// Wired into `ADEApp` via `@UIApplicationDelegateAdaptor` so SwiftUI still -/// drives the scene lifecycle — we only need the delegate plumbing for the -/// notification surface APNs does not expose through SwiftUI. -final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - let center = UNUserNotificationCenter.current() - center.delegate = self - NotificationCategories.register() - - // Bridge Live Activity / Control Widget intents → SyncService. The - // registry lives in the shared intents file so widget/NS extensions can - // reference the symbols; only the main app installs the forwarder. - Task { @MainActor in - ADEIntentCommandRegistry.register(ADESyncIntentBridge.shared) - } - - Task { - do { - // `.timeSensitive` is a valid UNAuthorizationOptions value on iOS 15+ and - // is required so the OS honours our `interruptionLevel = .timeSensitive` - // pushes (awaiting-input). It pairs with the - // `com.apple.developer.usernotifications.time-sensitive` entitlement. - // `.providesAppNotificationSettings` surfaces an in-app "Notification - // Settings" button in the Settings app (iOS 12+). - var options: UNAuthorizationOptions = [ - .alert, .badge, .sound, .providesAppNotificationSettings, - ] - if #available(iOS 15.0, *) { - options.insert(.timeSensitive) - } - let granted = try await center.requestAuthorization(options: options) - if granted { - await MainActor.run { - application.registerForRemoteNotifications() - } - } - } catch { - appDelegateLog.error("Notification authorization failed: \(error.localizedDescription, privacy: .public)") - } - } - - return true - } - - func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data - ) { - let hex = deviceToken.map { String(format: "%02x", $0) }.joined() - // Never log the hex token — it is effectively a per-install credential. - appDelegateLog.debug("Registered APNs alert token (\(deviceToken.count, privacy: .public) bytes)") - Task { @MainActor in - await SyncService.shared?.registerPushToken(hex, kind: .alert, sessionId: nil) - } - } - - func application( - _ application: UIApplication, - didFailToRegisterForRemoteNotificationsWithError error: Error - ) { - appDelegateLog.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") - } - - // MARK: - UNUserNotificationCenterDelegate - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - let categoryId = notification.request.content.categoryIdentifier - if categoryId == NotificationCategories.Identifier.chatTurnCompleted { - let prefs = NotificationPreferences.load(from: ADESharedContainer.defaults) - if prefs.chatTurnCompleted == false { - completionHandler([]) - return - } - } - completionHandler([.banner, .list, .sound]) - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - let userInfo = response.notification.request.content.userInfo - let actionId = response.actionIdentifier - - Task { @MainActor in - defer { completionHandler() } - - let sessionId = (userInfo["sessionId"] as? String) ?? "" - let itemId = (userInfo["itemId"] as? String) ?? "" - let prNumberValue: Any = userInfo["prNumber"] ?? "" - - switch actionId { - case NotificationCategories.Action.approve: - await SyncService.shared?.sendRemoteCommand( - .approveSession, - payload: ["sessionId": sessionId, "itemId": itemId] - ) - case NotificationCategories.Action.deny: - await SyncService.shared?.sendRemoteCommand( - .denySession, - payload: ["sessionId": sessionId, "itemId": itemId] - ) - case NotificationCategories.Action.reply: - if let textResponse = response as? UNTextInputNotificationResponse { - // Never log `userText` — it is user-authored message content. - await SyncService.shared?.sendRemoteCommand( - .replyToSession, - payload: ["sessionId": sessionId, "itemId": itemId, "text": textResponse.userText] - ) - } - case NotificationCategories.Action.open: - DeepLinkRouter.shared.handleNotificationUserInfo(userInfo) - case NotificationCategories.Action.restart: - await SyncService.shared?.sendRemoteCommand( - .restartSession, - payload: ["sessionId": sessionId] - ) - case NotificationCategories.Action.openPr: - DeepLinkRouter.shared.handleNotificationUserInfo(userInfo) - case NotificationCategories.Action.retryChecks: - let prId = (userInfo["prId"] as? String) ?? "" - await SyncService.shared?.sendRemoteCommand( - .retryPrChecks, - payload: ["prId": prId, "prNumber": prNumberValue] - ) - case UNNotificationDefaultActionIdentifier: - DeepLinkRouter.shared.handleNotificationUserInfo(userInfo) - case UNNotificationDismissActionIdentifier: - // User swiped away; nothing to do. - break - default: - DeepLinkRouter.shared.handleNotificationUserInfo(userInfo) - } - } - } -} - -// MARK: - Live Activity / Control Widget bridge - -/// Maps the cross-target `ADEIntentCommandKind` to the main-app -/// `RemoteCommandKind` and forwards through `SyncService.shared`. Kept in -/// the main target so the widget + notification-service binaries never -/// reference `SyncService` symbols. -@MainActor -final class ADESyncIntentBridge: ADEIntentCommandBridge { - static let shared = ADESyncIntentBridge() - - private init() {} - - func dispatch(_ kind: ADEIntentCommandKind, payload: [String: Any]) async { - let mapped: RemoteCommandKind - switch kind { - case .approveSession: mapped = .approveSession - case .denySession: mapped = .denySession - case .pauseSession: mapped = .pauseSession - case .replyToSession: mapped = .replyToSession - case .restartSession: mapped = .restartSession - case .retryPrChecks: mapped = .retryPrChecks - case .openPr: mapped = .openPr - case .setMutePush: mapped = .setMutePush - } - await SyncService.shared?.sendRemoteCommand(mapped, payload: payload) - } -} diff --git a/apps/ios/ADE/App/NotificationCategories.swift b/apps/ios/ADE/App/NotificationCategories.swift deleted file mode 100644 index c44f0f91d..000000000 --- a/apps/ios/ADE/App/NotificationCategories.swift +++ /dev/null @@ -1,186 +0,0 @@ -import UserNotifications - -/// Declares the `UNNotificationCategory` set backing every push ADE delivers. -/// -/// Categories map 1:1 to the categories referenced by the desktop -/// `notificationEventBus` and the iOS AppDelegate response handler. Adding a -/// new category here also requires a matching action-identifier branch in -/// `AppDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`. -enum NotificationCategories { - /// Category identifiers kept as constants so AppDelegate and the - /// notification-service extension can reference them without stringly-typed - /// duplication. - enum Identifier { - static let chatAwaitingInput = "CHAT_AWAITING_INPUT" - static let chatFailed = "CHAT_FAILED" - static let chatTurnCompleted = "CHAT_TURN_COMPLETED" - static let prCiFailing = "PR_CI_FAILING" - static let prReviewRequested = "PR_REVIEW_REQUESTED" - static let prChangesRequested = "PR_CHANGES_REQUESTED" - static let prMergeReady = "PR_MERGE_READY" - static let ctoSubagentStarted = "CTO_SUBAGENT_STARTED" - static let ctoSubagentFinished = "CTO_SUBAGENT_FINISHED" - static let systemAlert = "SYSTEM_ALERT" - } - - /// Action identifiers referenced by `AppDelegate`'s response handler. - enum Action { - static let approve = "APPROVE" - static let deny = "DENY" - static let reply = "REPLY" - static let open = "OPEN" - static let restart = "RESTART" - static let openPr = "OPEN_PR" - static let retryChecks = "RETRY_CHECKS" - } - - /// Build and register the full category set with the notification center. - /// Call once during app launch, before requesting authorization. - static func register() { - UNUserNotificationCenter.current().setNotificationCategories(makeCategorySet()) - } - - static func makeCategorySet() -> Set { - let approve = UNNotificationAction( - identifier: Action.approve, - title: "Approve", - options: [.authenticationRequired, .foreground] - ) - - let deny = UNNotificationAction( - identifier: Action.deny, - title: "Deny", - options: [.destructive] - ) - - let reply = UNTextInputNotificationAction( - identifier: Action.reply, - title: "Reply", - options: [], - textInputButtonTitle: "Send", - textInputPlaceholder: "Reply\u{2026}" - ) - - // Generic "Open" — used by CHAT_TURN_COMPLETED and SYSTEM_ALERT. Per-category - // titles are built inline below so the same action identifier can carry - // different labels; AppDelegate routes on identifier, not title. - let open = UNNotificationAction( - identifier: Action.open, - title: "Open", - options: [.foreground] - ) - - let openAgent = UNNotificationAction( - identifier: Action.open, - title: "Open agent", - options: [.foreground] - ) - - let restart = UNNotificationAction( - identifier: Action.restart, - title: "Restart", - options: [] - ) - - let openPr = UNNotificationAction( - identifier: Action.openPr, - title: "Open PR", - options: [.foreground] - ) - - let viewPr = UNNotificationAction( - identifier: Action.openPr, - title: "View PR", - options: [.foreground] - ) - - let retryChecks = UNNotificationAction( - identifier: Action.retryChecks, - title: "Retry checks", - options: [] - ) - - let chatAwaitingInput = UNNotificationCategory( - identifier: Identifier.chatAwaitingInput, - actions: [approve, deny, reply], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - let chatFailed = UNNotificationCategory( - identifier: Identifier.chatFailed, - actions: [openAgent, restart], - intentIdentifiers: [], - options: [] - ) - - let chatTurnCompleted = UNNotificationCategory( - identifier: Identifier.chatTurnCompleted, - actions: [open], - intentIdentifiers: [], - options: [] - ) - - let prCiFailing = UNNotificationCategory( - identifier: Identifier.prCiFailing, - actions: [openPr, retryChecks], - intentIdentifiers: [], - options: [] - ) - - let prReviewRequested = UNNotificationCategory( - identifier: Identifier.prReviewRequested, - actions: [openPr], - intentIdentifiers: [], - options: [] - ) - - let prChangesRequested = UNNotificationCategory( - identifier: Identifier.prChangesRequested, - actions: [openPr], - intentIdentifiers: [], - options: [] - ) - - let prMergeReady = UNNotificationCategory( - identifier: Identifier.prMergeReady, - actions: [viewPr], - intentIdentifiers: [], - options: [] - ) - - let ctoSubagentFinished = UNNotificationCategory( - identifier: Identifier.ctoSubagentFinished, - actions: [openAgent], - intentIdentifiers: [], - options: [] - ) - - let ctoSubagentStarted = UNNotificationCategory( - identifier: Identifier.ctoSubagentStarted, - actions: [openAgent], - intentIdentifiers: [], - options: [] - ) - - let systemAlert = UNNotificationCategory( - identifier: Identifier.systemAlert, - actions: [open], - intentIdentifiers: [], - options: [] - ) - - return [ - chatAwaitingInput, - chatFailed, - chatTurnCompleted, - prCiFailing, - prReviewRequested, - prChangesRequested, - prMergeReady, - ctoSubagentStarted, - ctoSubagentFinished, - systemAlert, - ] - } -} diff --git a/apps/ios/ADE/Info.plist b/apps/ios/ADE/Info.plist index 025a913d0..275e95631 100644 --- a/apps/ios/ADE/Info.plist +++ b/apps/ios/ADE/Info.plist @@ -88,7 +88,6 @@ UIBackgroundModes audio - remote-notification UILaunchScreen diff --git a/apps/ios/ADE/Models/NotificationPreferences.swift b/apps/ios/ADE/Models/NotificationPreferences.swift deleted file mode 100644 index f7158fb95..000000000 --- a/apps/ios/ADE/Models/NotificationPreferences.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation - -/// Per-session overrides let users silence a single session or restrict it to -/// awaiting-input alerts only, without touching global category toggles. -public struct SessionNotificationOverride: Codable, Equatable, Hashable { - public var muted: Bool - public var awaitingInputOnly: Bool - - public init(muted: Bool = false, awaitingInputOnly: Bool = false) { - self.muted = muted - self.awaitingInputOnly = awaitingInputOnly - } -} - -/// User-controlled toggles for the four notification category families plus -/// per-session overrides and an optional quiet-hours window. Persisted as JSON -/// in the App Group `UserDefaults` so the desktop host can read + respect the -/// same settings via the sync channel. -public struct NotificationPreferences: Codable, Equatable, Hashable { - // Chat - public var chatAwaitingInput: Bool = true - public var chatFailed: Bool = true - public var chatTurnCompleted: Bool = false - - // CTO & sub-agents - public var ctoSubagentStarted: Bool = false - public var ctoSubagentFinished: Bool = true - - // PRs & CI - public var prCiFailing: Bool = true - public var prReviewRequested: Bool = true - public var prChangesRequested: Bool = true - public var prMergeReady: Bool = true - - // System & health - public var systemProviderOutage: Bool = true - public var systemAuthRateLimit: Bool = true - public var systemHookFailure: Bool = false - - // Quiet hours (time-of-day only; year/month/day components are ignored). - public var quietHoursStart: Date? = nil - public var quietHoursEnd: Date? = nil - - // Per-session overrides keyed by sessionId. - public var perSessionOverrides: [String: SessionNotificationOverride] = [:] - - public init() {} - - /// UserDefaults key under which the JSON-encoded blob is stored. - public static let defaultKey = "ade.notifications.prefs" - - /// Count of enabled category toggles — used by the Settings row subtitle. - public var enabledCategoryCount: Int { - var n = 0 - if chatAwaitingInput { n += 1 } - if chatFailed { n += 1 } - if chatTurnCompleted { n += 1 } - if ctoSubagentStarted { n += 1 } - if ctoSubagentFinished { n += 1 } - if prCiFailing { n += 1 } - if prReviewRequested { n += 1 } - if prChangesRequested { n += 1 } - if prMergeReady { n += 1 } - if systemProviderOutage { n += 1 } - if systemAuthRateLimit { n += 1 } - if systemHookFailure { n += 1 } - return n - } - - /// Total category toggle count (excluding per-session overrides / quiet - /// hours) — useful for "N of M" style UI. - public static let totalCategoryCount = 12 -} - -public extension NotificationPreferences { - /// Returns a copy without inactive per-session entries. Rows with both - /// switches off are equivalent to no override, and keeping them around causes - /// needless payload growth as users toggle agents on and back off. - var pruningInactivePerSessionOverrides: NotificationPreferences { - var next = self - next.perSessionOverrides = perSessionOverrides.filter { _, override in - override.muted || override.awaitingInputOnly - } - return next - } - - /// Decodes stored preferences. Returns a default-initialised struct when no - /// blob exists yet or decoding fails — this keeps the Settings screen usable - /// on first launch. - static func load(from defaults: UserDefaults) -> NotificationPreferences { - guard let data = defaults.data(forKey: defaultKey) else { - return NotificationPreferences() - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - if let decoded = try? decoder.decode(NotificationPreferences.self, from: data) { - return decoded - } - return NotificationPreferences() - } - - /// Encodes + persists. Silently no-ops on encode failure so UI code doesn't - /// need error handling for the common case. - func save(to defaults: UserDefaults) { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(pruningInactivePerSessionOverrides) else { return } - defaults.set(data, forKey: NotificationPreferences.defaultKey) - } -} diff --git a/apps/ios/ADE/Services/LiveActivityCoordinator.swift b/apps/ios/ADE/Services/LiveActivityCoordinator.swift index bf38e9c05..71846f383 100644 --- a/apps/ios/ADE/Services/LiveActivityCoordinator.swift +++ b/apps/ios/ADE/Services/LiveActivityCoordinator.swift @@ -2,17 +2,6 @@ import ActivityKit import Combine import Foundation -/// Push-token kind reported up to the host so the desktop can tell which -/// APNs topic / token it should use for a given payload. -public enum PushTokenKind: String, Sendable { - /// Regular user-visible alert topic (bundle id). - case alert - /// Live Activity push-to-start token (iOS 17.2+). - case activityStart - /// Per-activity `pushTokenUpdates` token. - case activityUpdate -} - /// Host contract — wired to `SyncService` by the iOS app-wiring layer. /// The coordinator deliberately does not import `SyncService` so it can be /// unit-tested in isolation. @@ -21,14 +10,6 @@ public protocol LiveActivityHost: AnyObject { /// Snapshot of the sessions that should currently drive the workspace /// Live Activity. `reconcile(...)` consults this on each tick. var activeSessions: [AgentSnapshot] { get } - - /// Upload an APNs token acquired locally — alert token, push-to-start - /// token, or per-activity update token. - func sendPushToken( - _ token: String, - kind: PushTokenKind, - sessionId: String? - ) async } /// Owns the lifecycle of the **single** workspace `Activity`. @@ -83,10 +64,6 @@ public final class LiveActivityCoordinator: ObservableObject { /// renaming is the only way to keep the system header in sync. private var workspaceName: String - /// One listener task for push-token updates on the current activity. - private var pushTokenTask: Task? - /// Push-to-start listener (iOS 17.2+). - private var pushToStartTask: Task? /// Per-activity state listener that flips `lastUserDismissalAt` when iOS /// reports the user dismissed the LA from the Lock Screen / Dynamic Island. private var activityStateTask: Task? @@ -113,8 +90,6 @@ public final class LiveActivityCoordinator: ObservableObject { self.workspaceName = workspaceName self.configuration = configuration - startPushToStartListenerIfPossible() - // Aggressive cleanup: older builds ran one activity per chat // session, leaving the Lock Screen littered with per-chat pills. // End anything we find on launch so the user gets a clean slate — @@ -125,8 +100,6 @@ public final class LiveActivityCoordinator: ObservableObject { deinit { MainActor.assumeIsolated { - pushTokenTask?.cancel() - pushToStartTask?.cancel() activityStateTask?.cancel() reconcileTask?.cancel() } @@ -379,9 +352,8 @@ public final class LiveActivityCoordinator: ObservableObject { let activity = try Activity.request( attributes: attrs, content: content, - pushType: .token + pushType: nil ) - observePushTokenUpdates(for: activity) observeActivityStateUpdates(for: activity) } catch { // Common failure modes: user disabled Live Activities in @@ -408,14 +380,12 @@ public final class LiveActivityCoordinator: ObservableObject { for activity in Activity.activities { await activity.end(nil, dismissalPolicy: dismissalPolicy) } - pushTokenTask?.cancel() - pushTokenTask = nil activityStateTask?.cancel() activityStateTask = nil observedActivityId = nil } - // MARK: - Push tokens + // MARK: - Activity state /// Observe the user-dismissal signal on a live activity. ActivityKit flips /// state to `.dismissed` when the user swipes the LA away on the Lock @@ -447,31 +417,4 @@ public final class LiveActivityCoordinator: ObservableObject { } } - private func observePushTokenUpdates(for activity: Activity) { - pushTokenTask?.cancel() - pushTokenTask = Task { [weak self] in - for await tokenData in activity.pushTokenUpdates { - let hex = tokenData.map { String(format: "%02x", $0) }.joined() - await self?.host?.sendPushToken( - hex, - kind: .activityUpdate, - sessionId: nil - ) - } - } - } - - private func startPushToStartListenerIfPossible() { - guard #available(iOS 17.2, *) else { return } - pushToStartTask = Task { [weak self] in - for await tokenData in Activity.pushToStartTokenUpdates { - let hex = tokenData.map { String(format: "%02x", $0) }.joined() - await self?.host?.sendPushToken( - hex, - kind: .activityStart, - sessionId: nil - ) - } - } - } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b17e587f9..b31d8ade4 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3,7 +3,6 @@ import Foundation import Network import SwiftUI import UIKit -import UserNotifications import WidgetKit import os import zlib @@ -1120,11 +1119,6 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St return syncNormalizedCommandScopeValue(activeProjectId) } -struct SyncSendTestPushResult: Equatable { - var ok: Bool - var message: String -} - /// Delivery events for the full-screen terminal. The active screen attaches a /// handler per session id and receives hydration snapshots, ordered live /// chunks, and process exit without polling `terminalBuffers`. @@ -7371,7 +7365,6 @@ final class SyncService: ObservableObject { lastPairingErrorCode = nil lastSyncAt = Date() saveRemoteCommandDescriptors(commandDescriptors) - uploadSavedNotificationPreferences() let matchingDiscovery = discoveredHosts.first { discovered in discovered.hostIdentity == remoteHostIdentity @@ -7685,10 +7678,6 @@ final class SyncService: ObservableObject { } case "command_result", "file_response", "terminal_snapshot", "terminal_history": resolve(requestId: requestId, result: .success(payload)) - case "in_app_notification": - if let dict = payload as? [String: Any] { - presentInAppNotification(dict) - } case "chat_subscribe": if supportsChatStreaming, let dict = payload as? [String: Any], @@ -8017,50 +8006,6 @@ final class SyncService: ObservableObject { } } - private func presentInAppNotification(_ payload: [String: Any]) { - guard let title = (payload["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !title.isEmpty, - let body = (payload["body"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !body.isEmpty else { - return - } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - content.categoryIdentifier = notificationCategoryIdentifier(for: payload["category"] as? String) - if let metadata = payload["metadata"] as? [String: Any] { - content.userInfo = metadata - } - if let deepLink = payload["deepLink"] as? String, !deepLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - var next = content.userInfo - next["deepLink"] = deepLink - content.userInfo = next - } - let collapseId = (payload["collapseId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let requestId: String - if let collapseId, !collapseId.isEmpty { - requestId = "ade.in-app.\(collapseId)" - } else { - requestId = "ade.in-app.\(UUID().uuidString)" - } - let request = UNNotificationRequest(identifier: requestId, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) - } - - private func notificationCategoryIdentifier(for category: String?) -> String { - switch category { - case "chat": - return NotificationCategories.Identifier.chatAwaitingInput - case "cto": - return NotificationCategories.Identifier.ctoSubagentFinished - case "pr": - return NotificationCategories.Identifier.prReviewRequested - default: - return NotificationCategories.Identifier.systemAlert - } - } - private func awaitSocketOpen(_ task: URLSessionWebSocketTask) async throws { try await withCheckedThrowingContinuation { continuation in let taskIdentifier = task.taskIdentifier @@ -8933,13 +8878,12 @@ final class SyncService: ObservableObject { } } -// MARK: - Push notifications, Live Activities, and remote commands (WS6) +// MARK: - Live Activities and remote commands (WS6) /// Kinds of remote commands the iOS client sends to the ADE brain. /// /// Each case maps to a verb on the desktop `syncRemoteCommandService` and the -/// notification-bus router. Keep this enum in sync with the action switch -/// inside `SyncService.sendRemoteCommand(_:payload:)`. +/// action switch inside `SyncService.sendRemoteCommand(_:payload:)`. enum RemoteCommandKind: String, Sendable { case approveSession case denySession @@ -8948,111 +8892,17 @@ enum RemoteCommandKind: String, Sendable { case restartSession case retryPrChecks case openPr - case setMutePush /// Ask the paired desktop to open an `ade://...` deep link locally. /// Used when iOS encounters a link it can't render itself (lane, repo /// branch, owner/repo PR) and the user wants to bounce it to their Mac. case openDeeplink } -extension SyncService: LiveActivityHost { - /// `LiveActivityHost` conformance — called by `LiveActivityCoordinator` when - /// the OS hands us a new push-to-start or per-activity update token. - func sendPushToken(_ token: String, kind: PushTokenKind, sessionId: String?) async { - await registerPushToken(token, kind: kind, sessionId: sessionId) - } -} +extension SyncService: LiveActivityHost {} extension SyncService { - /// Send the `register_push_token` sync message to the currently connected - /// host. No-ops when offline — APNs tokens are stable for the app install, - /// so we simply re-upload on the next successful reconnect. - func registerPushToken(_ hex: String, kind: PushTokenKind, sessionId: String?) async { - let trimmed = hex.trimmingCharacters(in: .whitespaces).lowercased() - guard !trimmed.isEmpty else { return } - - let wireKind: String - switch kind { - case .alert: wireKind = "alert" - case .activityStart: wireKind = "activity-start" - case .activityUpdate: wireKind = "activity-update" - } - - let bundleId = Bundle.main.bundleIdentifier ?? "com.ade.ios" - #if DEBUG - let env = "sandbox" - #else - let env = "production" - #endif - - var payload: [String: Any] = [ - "token": trimmed, - "kind": wireKind, - "env": env, - "bundleId": bundleId, - ] - if let sessionId, !sessionId.isEmpty { - payload["activityId"] = sessionId - } - - sendEnvelope(type: "register_push_token", requestId: nil, payload: payload) - } - - /// Upload the current notification preferences as a `notification_prefs` - /// message. Serializes the flat iOS struct into the nested shape the desktop - /// `NotificationPreferences` TypeScript type expects. - func uploadNotificationPrefs(_ prefs: NotificationPreferences) { - let nested = Self.encodeNotificationPrefsForDesktop(prefs) - sendEnvelope(type: "notification_prefs", requestId: nil, payload: ["prefs": nested]) - } - - private func uploadSavedNotificationPreferences() { - uploadNotificationPrefs(NotificationPreferences.load(from: ADESharedContainer.defaults)) - } - - /// Ask the host to deliver a test push to this device. The desktop decides - /// which token kind (alert vs activity) to target based on what it last saw - /// from us. - func sendTestPush() async -> SyncSendTestPushResult { - // Fail fast when the socket is offline. Without this guard, `sendEnvelope` - // silently drops the frame and `awaitResponse` would sit until timeout, - // making the test-push button look unresponsive. - guard canSendLiveRequests() else { - return SyncSendTestPushResult(ok: false, message: "The paired machine is offline.") - } - let requestId = makeRequestId() - do { - let raw = try await awaitResponse( - requestId: requestId, - disconnectOnTimeout: false, - timeoutMessage: "The paired machine did not respond to the test push request." - ) { - self.sendEnvelope(type: "send_test_push", requestId: requestId, payload: ["kind": "alert"]) - } - guard let dict = raw as? [String: Any] else { - return SyncSendTestPushResult(ok: false, message: "The paired machine returned an unreadable test push response.") - } - if dict["ok"] as? Bool == true { - let result = dict["result"] as? [String: Any] - let message = (result?["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let message, !message.isEmpty { - return SyncSendTestPushResult(ok: true, message: message) - } - return SyncSendTestPushResult(ok: true, message: "Test push sent.") - } - let error = dict["error"] as? [String: Any] - let message = (error?["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let message, !message.isEmpty { - return SyncSendTestPushResult(ok: false, message: message) - } - return SyncSendTestPushResult(ok: false, message: "The paired machine could not send a test push.") - } catch { - return SyncSendTestPushResult(ok: false, message: error.localizedDescription) - } - } /// Dispatch a remote command over the existing sync WebSocket. Used by: - /// • Notification action handlers in `AppDelegate` /// • Live Activity `LiveActivityIntent` perform handlers (WS7) /// • Control Widget intents (WS7) /// @@ -9071,7 +8921,6 @@ extension SyncService { case .restartSession: action = "chat.restart" case .retryPrChecks: action = "prs.rerunChecks" case .openPr: action = "prs.getDetail" - case .setMutePush: action = "notification_prefs" case .openDeeplink: action = "deeplinks.open" } @@ -9111,15 +8960,6 @@ extension SyncService { if let prNumber = payload["prNumber"] { args["prNumber"] = prNumber } - case .setMutePush: - // Route through the preferences updater — we overload the same envelope - // rather than add yet another message type. The desktop honours the - // `muteUntil` field it finds on the preferences payload. - if let until = payload["muteUntil"] as? String { - args["muteUntil"] = until - } else { - args["muteUntil"] = NSNull() - } case .openDeeplink: // Desktop `deeplinks.open` expects a `url` arg — the same `ade://...` // string the user tapped on iOS. We pass it through verbatim so the @@ -9141,8 +8981,7 @@ extension SyncService { } // For now we send via the opaque command envelope — the desktop's - // `syncRemoteCommandService` dispatches on `action`. Notification actions - // may still ignore the result, while interactive surfaces can render it. + // `syncRemoteCommandService` dispatches on `action`. do { let result = try await performCommandRequestSafe(action: action, args: args) if let result = result as? [String: Any] { @@ -9423,72 +9262,6 @@ extension SyncService { } } - // MARK: - NotificationPreferences shape translation - - /// Translate the flat iOS `NotificationPreferences` into the nested shape - /// the desktop `SyncNotificationPrefsPayload` expects. Keeping the mapping - /// local (rather than rewriting the iOS struct) avoids touching the - /// persistence format that `NotificationsCenterView` already reads/writes. - static func encodeNotificationPrefsForDesktop( - _ prefs: NotificationPreferences - ) -> [String: Any] { - let anyEnabled = prefs.enabledCategoryCount > 0 - var dict: [String: Any] = [ - "enabled": anyEnabled, - "chat": [ - "awaitingInput": prefs.chatAwaitingInput, - "chatFailed": prefs.chatFailed, - "turnCompleted": prefs.chatTurnCompleted, - ], - "cto": [ - "subagentStarted": prefs.ctoSubagentStarted, - "subagentFinished": prefs.ctoSubagentFinished, - ], - "prs": [ - "ciFailing": prefs.prCiFailing, - "reviewRequested": prefs.prReviewRequested, - "changesRequested": prefs.prChangesRequested, - "mergeReady": prefs.prMergeReady, - ], - "system": [ - "providerOutage": prefs.systemProviderOutage, - "authRateLimit": prefs.systemAuthRateLimit, - "hookFailure": prefs.systemHookFailure, - ], - ] - - if let start = prefs.quietHoursStart, let end = prefs.quietHoursEnd { - dict["quietHours"] = [ - "enabled": true, - "start": Self.formatTimeOfDay(start), - "end": Self.formatTimeOfDay(end), - "timezone": TimeZone.current.identifier, - ] - } - - let activeOverrides = prefs.perSessionOverrides.filter { _, override in - override.muted || override.awaitingInputOnly - } - if !activeOverrides.isEmpty { - dict["perSessionOverrides"] = activeOverrides.mapValues { override in - [ - "muted": override.muted, - "awaitingInputOnly": override.awaitingInputOnly, - ] - } - } - - return dict - } - - private static func formatTimeOfDay(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = .current - formatter.dateFormat = "HH:mm" - return formatter.string(from: date) - } - private static func parseIso8601(_ raw: String) -> Date? { guard !raw.isEmpty else { return nil } let iso = ISO8601DateFormatter() diff --git a/apps/ios/ADE/Shared/ADESharedContainer.swift b/apps/ios/ADE/Shared/ADESharedContainer.swift index acd9fd777..28257910f 100644 --- a/apps/ios/ADE/Shared/ADESharedContainer.swift +++ b/apps/ios/ADE/Shared/ADESharedContainer.swift @@ -1,7 +1,7 @@ import Foundation -/// Shared access point for the App Group used by the main app, the Widget -/// extension, and the Notification Service extension. +/// Shared access point for the App Group used by the main app and the Widget +/// extension. /// /// The App Group identifier is the single source of truth and is used to /// derive the shared `UserDefaults` suite and the on-disk container URL. diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index dba57309a..2b1e37a84 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -1,7 +1,6 @@ import Foundation -/// Lightweight Codable DTOs shared by the main app, widgets, and the -/// notification service extension. +/// Lightweight Codable DTOs shared by the main app and widgets. /// /// Intentionally decoupled from `RemoteModels.swift` — widgets must not import /// heavyweight renderer code, and the shapes here only carry what we actually diff --git a/apps/ios/ADE/Shared/ADESharedTheme.swift b/apps/ios/ADE/Shared/ADESharedTheme.swift index 00129e7f6..a2fc16880 100644 --- a/apps/ios/ADE/Shared/ADESharedTheme.swift +++ b/apps/ios/ADE/Shared/ADESharedTheme.swift @@ -1,7 +1,7 @@ import SwiftUI -/// Minimal theme subset needed by widgets and the notification service -/// extension. Extensions cannot import main-app sources directly, so the +/// Minimal theme subset needed by widgets. Extensions cannot import +/// main-app sources directly, so the /// provider brand map is duplicated here and must be kept in sync with /// `ADEDesignSystem.swift:brandClaude..brandGroq` and `providerBrand(for:)`. public enum ADESharedTheme { diff --git a/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift b/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift index b4c8ec2b9..be1b7bcbd 100644 --- a/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift +++ b/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift @@ -5,14 +5,13 @@ import Foundation /// /// Referenced by `ADELiveActivityViews.swift` (Live Activity buttons) and /// `ADEControlWidget.swift` (Control Center widgets). This file is included -/// in the main ADE target, the ADEWidgets extension, and the -/// ADENotificationService extension so the same symbols resolve in every -/// process that hosts interactive regions. +/// in the main ADE target and the ADEWidgets extension so the same symbols +/// resolve in every process that hosts interactive regions. /// /// All `perform()` bodies route through a `ADEIntentCommandBridge` that the /// main app registers at launch. We avoid importing `SyncService` here -/// because this file is compiled into the widget + notification-service -/// extensions too, which don't link `SyncService.swift`. +/// because this file is compiled into the widget extension too, which doesn't +/// link `SyncService.swift`. /// /// NOTE (naming): the file is still called `LiveActivityIntentsForward.swift` /// for pbxproj-stability reasons; it now carries the real impls. @@ -20,8 +19,8 @@ import Foundation // MARK: - Cross-target command bridge /// String-keyed mirror of `SyncService.RemoteCommandKind` — duplicated here -/// so the widget + NS extensions can reference it without importing the -/// full `SyncService` translation unit. +/// so the widget extension can reference it without importing the full +/// `SyncService` translation unit. public enum ADEIntentCommandKind: String, Sendable { case approveSession case denySession @@ -29,18 +28,14 @@ public enum ADEIntentCommandKind: String, Sendable { case replyToSession case retryPrChecks case openPr - case setMutePush /// Restart a failed session from the Live Activity "Failed" action row. - /// TODO: wire chat.restart remote command — the desktop-side handler - /// does not exist yet (only chat.approve/deny/reply/pause today). Until - /// then the bridge receives this but no remote command is dispatched. case restartSession } /// Main-app adapter installed by `SyncService` at launch. The widget / -/// notification-service processes never register an implementation, so -/// `perform()` becomes a no-op there (which is correct — interactive intents -/// from a Live Activity always execute in the main app process anyway). +/// extension process never registers an implementation, so `perform()` becomes +/// a no-op there (which is correct — interactive intents from a Live Activity +/// always execute in the main app process anyway). @MainActor public protocol ADEIntentCommandBridge: AnyObject { func dispatch(_ kind: ADEIntentCommandKind, payload: [String: Any]) async @@ -59,59 +54,6 @@ public enum ADEIntentCommandRegistry { } } -// MARK: - Mute preferences (shared container key) - -@available(iOS 17.0, *) -public enum ADEMutePreferences { - /// ISO-8601 date at which the mute should expire. `nil` means not muted. - /// Shared between the main app, widget extension, and notification - /// extension via the App Group `UserDefaults`. - public static let muteUntilKey = "ade.notifications.muteUntil" - /// Legacy boolean flag still read by `ADEControlWidget.swift` so the - /// Control Center toggle renders the correct "is muted" state without - /// having to parse a date. - public static let mutedBoolKey = "ade.notifications.muted" - - public static var muteUntil: Date? { - let defaults = ADESharedContainer.defaults - guard let iso = defaults.string(forKey: muteUntilKey), !iso.isEmpty else { - return nil - } - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - if let d = formatter.date(from: iso) { return d } - // Fall back to fractional-seconds variant. - let withFractional = ISO8601DateFormatter() - withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return withFractional.date(from: iso) - } - - public static var isMuted: Bool { - guard let until = muteUntil else { return false } - return until.timeIntervalSinceNow > 0 - } - - /// Persist a new mute window. `until == nil` clears the mute. - /// Returns the ISO-8601 representation that was written (or `nil` if the - /// mute was cleared), which is what we forward to the desktop host. - @discardableResult - public static func setMute(until: Date?) -> String? { - let defaults = ADESharedContainer.defaults - if let until = until, until.timeIntervalSinceNow > 0 { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - let iso = formatter.string(from: until) - defaults.set(iso, forKey: muteUntilKey) - defaults.set(true, forKey: mutedBoolKey) - return iso - } else { - defaults.removeObject(forKey: muteUntilKey) - defaults.set(false, forKey: mutedBoolKey) - return nil - } - } -} - // MARK: - Live Activity intents @available(iOS 17.0, *) @@ -316,45 +258,3 @@ public struct OpenADEIntent: AppIntent { return .result() } } - -/// Toggles the global "mute ADE pushes" flag stored in the shared App Group -/// container. When enabled, we mute for one hour — the Control Widget doesn't -/// offer a duration picker, so a fixed window keeps the UX predictable. -@available(iOS 18.0, *) -public struct ToggleMutePushIntent: SetValueIntent { - public static var title: LocalizedStringResource = "Mute ADE notifications" - public static var description = IntentDescription( - "Toggle whether ADE push notifications are silenced." - ) - - /// Driven by `ControlWidgetToggle`'s two-way binding. `true` means "mute - /// now for one hour"; `false` means "unmute". - @Parameter(title: "Muted") - public var value: Bool - - public init() {} - - public init(value: Bool) { - self.value = value - } - - @MainActor - public func perform() async throws -> some IntentResult { - let iso: String? - if value { - let oneHourFromNow = Date(timeIntervalSinceNow: 60 * 60) - iso = ADEMutePreferences.setMute(until: oneHourFromNow) - } else { - iso = ADEMutePreferences.setMute(until: nil) - } - - var payload: [String: Any] = [:] - if let iso = iso { - payload["muteUntil"] = iso - } else { - payload["muteUntil"] = NSNull() - } - await ADEIntentCommandRegistry.dispatch(.setMutePush, payload: payload) - return .result() - } -} diff --git a/apps/ios/ADE/Shared/WidgetAppIntents.swift b/apps/ios/ADE/Shared/WidgetAppIntents.swift index ef06018cc..23405e792 100644 --- a/apps/ios/ADE/Shared/WidgetAppIntents.swift +++ b/apps/ios/ADE/Shared/WidgetAppIntents.swift @@ -4,11 +4,10 @@ import Foundation /// Widget configuration intents. These let users pin a specific session to a /// small widget and surface suggestions in the Shortcuts app. /// -/// Compiled into the main ADE target, the ADEWidgets extension, and the -/// ADENotificationService extension for symbol parity across processes that -/// host interactive regions. The underlying data source (`ADESharedContainer -/// .readWorkspaceSnapshot()`) is populated by the main app and read by the -/// extensions via the App Group. +/// Compiled into the main ADE target and the ADEWidgets extension for symbol +/// parity across processes that host interactive regions. The underlying data +/// source (`ADESharedContainer.readWorkspaceSnapshot()`) is populated by the +/// main app and read by the extension via the App Group. // MARK: - SessionEntity diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index fa47ee8e1..66343ab55 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -65,7 +65,7 @@ public struct AttentionItem: Identifiable, Equatable { /// Source of truth for the in-app Attention Drawer. /// -/// Reducer-only — never opens its own WebSocket or APNs channel. It +/// Reducer-only: never opens its own WebSocket. It /// subscribes to the `SyncService` `@Published var activeSessions` + /// `@Published var localStateRevision` publishers and rebuilds its /// `items` array from the `WorkspaceSnapshot` written to the App Group by diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 5205d558e..6bc3698e9 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -43,16 +43,6 @@ struct ConnectionSettingsView: View { ) .padding(.horizontal, 16) - SettingsNotificationsSection( - onPreferencesChanged: { prefs in - syncService.uploadNotificationPrefs(prefs) - }, - onSendTestPush: { - await syncService.sendTestPush() - } - ) - .padding(.horizontal, 16) - SettingsAppearanceSection() .padding(.horizontal, 16) diff --git a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift deleted file mode 100644 index 04dccec73..000000000 --- a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift +++ /dev/null @@ -1,581 +0,0 @@ -import SwiftUI -import UserNotifications - -/// Notifications Center — the single entry point for all push-related toggles. -/// -/// Persists state as a JSON-encoded `NotificationPreferences` blob in the -/// App Group `UserDefaults` (`ade.notifications.prefs`). The parent settings -/// list wires the sync-service writer and the "send test push" action as -/// closures so this view stays preview-able without `SyncService` / network -/// dependencies. -struct NotificationsCenterView: View { - var onPreferencesChanged: (NotificationPreferences) -> Void - var onSendTestPush: () async -> SyncSendTestPushResult - - @State private var prefs: NotificationPreferences - @State private var authStatus: UNAuthorizationStatus = .notDetermined - @State private var hasDeviceToken: Bool = false - @State private var isRequestingAuthorization: Bool = false - @State private var isSendingTestPush: Bool = false - @State private var testPushResult: SyncSendTestPushResult? - - init( - initialPreferences: NotificationPreferences = NotificationPreferences(), - onPreferencesChanged: @escaping (NotificationPreferences) -> Void, - onSendTestPush: @escaping () async -> SyncSendTestPushResult - ) { - self.onPreferencesChanged = onPreferencesChanged - self.onSendTestPush = onSendTestPush - _prefs = State(initialValue: initialPreferences) - } - - var body: some View { - ScrollView { - VStack(spacing: 0) { - statusBanner - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 14) - - section(title: "Chat") { - settingsRow( - title: "Awaiting input", - subtitle: "time-sensitive · bypasses focus", - toggle: binding(\.chatAwaitingInput) - ) - rowSeparator() - settingsRow( - title: "Failed", - subtitle: "agent stopped on error", - toggle: binding(\.chatFailed) - ) - rowSeparator() - settingsRow( - title: "Turn completed", - subtitle: "agent finished its turn", - toggle: binding(\.chatTurnCompleted) - ) - } - - section(title: "CTO & sub-agents") { - settingsRow( - title: "Sub-agent started", - subtitle: nil, - toggle: binding(\.ctoSubagentStarted) - ) - rowSeparator() - settingsRow( - title: "Sub-agent finished", - subtitle: nil, - toggle: binding(\.ctoSubagentFinished) - ) - } - - section(title: "Pull requests") { - settingsRow( - title: "CI failing", - subtitle: "required check turned red", - toggle: binding(\.prCiFailing) - ) - rowSeparator() - settingsRow( - title: "Review requested", - subtitle: "someone asked you to review", - toggle: binding(\.prReviewRequested) - ) - rowSeparator() - settingsRow( - title: "Changes requested", - subtitle: "reviewer left blocking feedback", - toggle: binding(\.prChangesRequested) - ) - rowSeparator() - settingsRow( - title: "Merge ready", - subtitle: "approvals and checks are green", - toggle: binding(\.prMergeReady) - ) - } - - section(title: "System & health") { - settingsRow( - title: "Provider outage", - subtitle: "Claude, OpenAI, etc.", - toggle: binding(\.systemProviderOutage) - ) - rowSeparator() - settingsRow( - title: "Auth / rate limit", - subtitle: "session needs attention", - toggle: binding(\.systemAuthRateLimit) - ) - rowSeparator() - settingsRow( - title: "Hook failure", - subtitle: "quiet by default", - toggle: binding(\.systemHookFailure) - ) - } - - section(title: "Quiet hours") { - quietHoursRow - rowSeparator() - perSessionOverridesRow - } - - VStack(alignment: .leading, spacing: 8) { - sendTestPushButton - if let testPushResult { - Text(testPushResult.message) - .font(.caption) - .foregroundStyle(testPushResult.ok ? ADEColor.success : ADEColor.danger) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 2) - } else if !canSendTestPush { - Text("Enable notifications and register this device before sending a test push.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 2) - } - } - .padding(.horizontal, 16) - .padding(.top, 20) - .padding(.bottom, 24) - - Text("Preferences are stored in the shared container and mirrored to your paired machine.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.bottom, 40) - } - } - .background(ADEColor.pageBackground.ignoresSafeArea()) - .navigationTitle("Notifications") - .navigationBarTitleDisplayMode(.inline) - .task { - await refreshPreferencesAfterFirstPaint() - await refreshAuthorizationStatus() - } - } - - // MARK: - Banner - - @ViewBuilder - private var statusBanner: some View { - switch authStatus { - case .authorized, .ephemeral: - ADEBanner( - tint: ADEColor.success, - dotTint: ADEColor.success, - title: "Push notifications are enabled", - subtitle: hasDeviceToken ? "device registered · APNs prod" : "awaiting device registration", - trailing: hasDeviceToken ? nil : .init(label: "Register", action: registerDeviceForRemoteNotifications) - ) - case .provisional: - ADEBanner( - tint: ADESharedTheme.warningAmber, - dotTint: ADESharedTheme.warningAmber, - title: "Push is provisional", - subtitle: "tap to enable full banners", - trailing: .init(label: "Enable", action: openSystemSettings) - ) - case .denied: - ADEBanner( - tint: ADEColor.danger, - dotTint: ADEColor.danger, - title: "Push notifications are off", - subtitle: "re-enable in iOS Settings", - trailing: .init(label: "Open iOS Settings", action: openSystemSettings) - ) - case .notDetermined: - ADEBanner( - tint: ADESharedTheme.warningAmber, - dotTint: ADESharedTheme.warningAmber, - title: "Push notifications are not enabled yet", - subtitle: "allow notifications to receive agent updates", - trailing: .init( - label: isRequestingAuthorization ? "Requesting..." : "Enable", - action: requestAuthorizationIfNeeded - ), - trailingDisabled: isRequestingAuthorization - ) - @unknown default: - EmptyView() - } - } - - // MARK: - Sections - - @ViewBuilder - private func section( - title: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 0) { - Text(title.uppercased()) - .font(.system(size: 13, design: .monospaced)) - .kerning(0) - .foregroundStyle(Color(red: 0x8E / 255.0, green: 0x8E / 255.0, blue: 0x93 / 255.0)) - .padding(EdgeInsets(top: 20, leading: 20, bottom: 7, trailing: 20)) - .accessibilityAddTraits(.isHeader) - - groupContainer { - content() - } - } - } - - @ViewBuilder - private func groupContainer( - @ViewBuilder content: () -> Content - ) -> some View { - VStack(spacing: 0) { content() } - .padding(.horizontal, 0) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color(red: 28.0 / 255.0, green: 25.0 / 255.0, blue: 42.0 / 255.0).opacity(0.72)) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - ) - ) - .overlay( - // Soft top highlight — lifts the group off the page and reads as glass. - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.06), .clear], - startPoint: .top, - endPoint: .center - ) - ) - .allowsHitTesting(false) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [Color.white.opacity(0.12), Color.white.opacity(0.02)], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.75 - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: Color.black.opacity(0.35), radius: 16, x: 0, y: 6) - .padding(.horizontal, 16) - } - - // MARK: - Rows - - @ViewBuilder - private func settingsRow( - title: String, - subtitle: String?, - toggle: Binding - ) -> some View { - HStack(alignment: .center, spacing: 14) { - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.system(size: 16, weight: .regular)) - .kerning(-0.2) - .foregroundStyle(ADEColor.textPrimary) - if let subtitle { - Text(subtitle) - .font(.system(size: 12.5, design: .monospaced)) - .foregroundStyle(Color(red: 0x8E / 255.0, green: 0x8E / 255.0, blue: 0x93 / 255.0)) - .fixedSize(horizontal: false, vertical: true) - } - } - Spacer(minLength: 12) - Toggle("", isOn: toggle) - .labelsHidden() - .tint(ADEColor.success) - } - .padding(.horizontal, 16) - .padding(.vertical, 11) - .frame(minHeight: 44) - .contentShape(Rectangle()) - .accessibilityElement(children: .combine) - } - - @ViewBuilder - private func rowSeparator() -> some View { - Rectangle() - .fill(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.4)) - .frame(height: 0.33) - .padding(.leading, 16) - } - - private var quietHoursRow: some View { - NavigationLink { - QuietHoursEditorView( - start: Binding( - get: { prefs.quietHoursStart }, - set: { prefs.quietHoursStart = $0; commit() } - ), - end: Binding( - get: { prefs.quietHoursEnd }, - set: { prefs.quietHoursEnd = $0; commit() } - ) - ) - } label: { - HStack(alignment: .center, spacing: 14) { - VStack(alignment: .leading, spacing: 1) { - Text("Do not disturb") - .font(.system(size: 16, weight: .regular)) - .kerning(-0.2) - .foregroundStyle(ADEColor.textPrimary) - Text(quietHoursSubtitle) - .font(.system(size: 12.5, design: .monospaced)) - .foregroundStyle(Color(red: 0x8E / 255.0, green: 0x8E / 255.0, blue: 0x93 / 255.0)) - } - Spacer(minLength: 12) - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.purpleAccent) - } - .padding(.horizontal, 16) - .padding(.vertical, 11) - .frame(minHeight: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityHint("Configure a daily window during which push alerts are suppressed") - } - - private var perSessionOverridesRow: some View { - NavigationLink { - PerSessionOverrideView( - overrides: Binding( - get: { prefs.perSessionOverrides }, - set: { prefs.perSessionOverrides = $0; commit() } - ) - ) - } label: { - HStack(alignment: .center, spacing: 14) { - VStack(alignment: .leading, spacing: 1) { - Text("Per-agent overrides") - .font(.system(size: 16, weight: .regular)) - .kerning(-0.2) - .foregroundStyle(ADEColor.textPrimary) - Text(overridesSubtitle) - .font(.system(size: 12.5, design: .monospaced)) - .foregroundStyle(Color(red: 0x8E / 255.0, green: 0x8E / 255.0, blue: 0x93 / 255.0)) - } - Spacer(minLength: 12) - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.purpleAccent) - } - .padding(.horizontal, 16) - .padding(.vertical, 11) - .frame(minHeight: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityHint("Mute specific agents or restrict them to awaiting-input alerts only") - } - - // MARK: - Send test push - - private var sendTestPushButton: some View { - Button(action: sendTestPush) { - Text(isSendingTestPush ? "Sending test push..." : "Send test push") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(ADEColor.purpleAccent) - .frame(maxWidth: .infinity) - .padding(.vertical, 13) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ADEColor.purpleAccent.opacity(0.15)) - ) - } - .buttonStyle(.plain) - .disabled(!canSendTestPush || isSendingTestPush) - .opacity(canSendTestPush ? (isSendingTestPush ? 0.65 : 1) : 0.45) - .accessibilityHint( - canSendTestPush - ? "Ask the paired machine to send a test notification to this device" - : "Enable notifications and register this device first" - ) - } - - // MARK: - Helpers - - private func binding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { prefs[keyPath: keyPath] }, - set: { newValue in - prefs[keyPath: keyPath] = newValue - commit() - } - ) - } - - private func refreshPreferencesAfterFirstPaint() async { - await Task.yield() - let loaded = NotificationPreferences.load(from: ADESharedContainer.defaults) - guard loaded != prefs else { return } - prefs = loaded - } - - private func refreshAuthorizationStatus() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - await MainActor.run { - authStatus = settings.authorizationStatus - hasDeviceToken = UIApplication.shared.isRegisteredForRemoteNotifications - } - } - - private func requestAuthorizationIfNeeded() { - guard !isRequestingAuthorization else { return } - isRequestingAuthorization = true - Task { - _ = try? await UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .badge, .sound] - ) - await MainActor.run { - UIApplication.shared.registerForRemoteNotifications() - } - await refreshAuthorizationStatus() - await MainActor.run { - isRequestingAuthorization = false - } - } - } - - private func registerDeviceForRemoteNotifications() { - UIApplication.shared.registerForRemoteNotifications() - Task { await refreshAuthorizationStatus() } - } - - private func sendTestPush() { - guard canSendTestPush, !isSendingTestPush else { return } - isSendingTestPush = true - testPushResult = nil - Task { - let result = await onSendTestPush() - await MainActor.run { - testPushResult = result - isSendingTestPush = false - } - } - } - - private func openSystemSettings() { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - - private func commit() { - prefs.save(to: ADESharedContainer.defaults) - onPreferencesChanged(prefs) - } - - private var quietHoursSubtitle: String { - guard let start = prefs.quietHoursStart, let end = prefs.quietHoursEnd else { - return "off" - } - let startStr = start.formatted(date: .omitted, time: .shortened) - let endStr = end.formatted(date: .omitted, time: .shortened) - return "\(startStr) \u{2192} \(endStr)" - } - - private var overridesSubtitle: String { - let count = prefs.perSessionOverrides.values.filter { $0.muted || $0.awaitingInputOnly }.count - if count == 0 { return "none" } - return "\(count) active" - } - - private var canSendTestPush: Bool { - switch authStatus { - case .authorized, .provisional, .ephemeral: - return hasDeviceToken - default: - return false - } - } -} - -// MARK: - Banner primitive - -private struct ADEBanner: View { - struct Trailing { - let label: String - let action: () -> Void - } - - let tint: Color - let dotTint: Color - let title: String - let subtitle: String - let trailing: Trailing? - let trailingDisabled: Bool - - init( - tint: Color, - dotTint: Color, - title: String, - subtitle: String, - trailing: Trailing?, - trailingDisabled: Bool = false - ) { - self.tint = tint - self.dotTint = dotTint - self.title = title - self.subtitle = subtitle - self.trailing = trailing - self.trailingDisabled = trailingDisabled - } - - var body: some View { - HStack(alignment: .center, spacing: 10) { - Circle() - .fill(dotTint) - .frame(width: 8, height: 8) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(subtitle) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(Color(red: 0x8E / 255.0, green: 0x8E / 255.0, blue: 0x93 / 255.0)) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 10) - if let trailing { - Button(action: trailing.action) { - Text(trailing.label) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(tint) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(tint.opacity(0.18)) - ) - } - .buttonStyle(.plain) - .disabled(trailingDisabled) - .opacity(trailingDisabled ? 0.55 : 1) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(tint.opacity(0.12)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(tint.opacity(0.25), lineWidth: 0.5) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(title). \(subtitle).") - } -} diff --git a/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift b/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift deleted file mode 100644 index bc3164bcf..000000000 --- a/apps/ios/ADE/Views/Settings/PerSessionOverrideView.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -func notificationHasActiveOverride(_ override: SessionNotificationOverride) -> Bool { - override.muted || override.awaitingInputOnly -} - -func notificationStaleOverrideIds( - overrides: [String: SessionNotificationOverride], - agents: [AgentSnapshot] -) -> [String] { - let agentIds = Set(agents.map(\.sessionId)) - return overrides - .filter { sessionId, override in - !agentIds.contains(sessionId) && notificationHasActiveOverride(override) - } - .map(\.key) - .sorted() -} - -/// Lists every session we know about from the shared workspace snapshot and -/// lets the user mute a session or restrict it to awaiting-input alerts only. -/// -/// Snapshot reads are cheap (JSON from the App Group `UserDefaults`), so this -/// refreshes when shown rather than subscribing to a publisher. -struct PerSessionOverrideView: View { - @Binding var overrides: [String: SessionNotificationOverride] - - @State private var agents: [AgentSnapshot] = [] - - var body: some View { - let staleOverrideIds = notificationStaleOverrideIds(overrides: overrides, agents: agents) - - Form { - if agents.isEmpty && staleOverrideIds.isEmpty { - Section { - emptyState - } - } else { - Section { - ForEach(agents) { agent in - sessionRow(for: agent) - } - ForEach(staleOverrideIds, id: \.self) { sessionId in - staleOverrideRow(for: sessionId) - } - } footer: { - Text("Overrides only affect push notifications — the session itself keeps running.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - .navigationTitle("Per-session overrides") - .navigationBarTitleDisplayMode(.inline) - .task { reloadIfChanged() } - .refreshable { reloadIfChanged() } - } - - // MARK: - Rows - - @ViewBuilder - private func sessionRow(for agent: AgentSnapshot) -> some View { - let override = overrides[agent.sessionId] ?? SessionNotificationOverride() - - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Circle() - .fill(ADESharedTheme.brandColor(for: agent.provider)) - .frame(width: 10, height: 10) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(agent.title ?? "Untitled session") - .font(.subheadline.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Text(statusLine(for: agent)) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer() - } - - Toggle(isOn: mutedBinding(agent.sessionId, fallback: override)) { - Label("Mute this session", systemImage: "bell.slash") - .labelStyle(.titleAndIcon) - .font(.caption) - } - .tint(ADEColor.purpleAccent) - .accessibilityHint("Silence all push alerts from \(agent.title ?? "this session")") - - Toggle(isOn: awaitingOnlyBinding(agent.sessionId, fallback: override)) { - Label("Awaiting-input only", systemImage: "hand.raised") - .labelStyle(.titleAndIcon) - .font(.caption) - } - .tint(ADEColor.purpleAccent) - .disabled(override.muted) - .accessibilityHint("Only alert when \(agent.title ?? "this session") pauses for your input") - } - .padding(.vertical, 4) - } - - @ViewBuilder - private func staleOverrideRow(for sessionId: String) -> some View { - let override = overrides[sessionId] ?? SessionNotificationOverride() - - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Circle() - .fill(ADEColor.textMuted) - .frame(width: 10, height: 10) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(shortSessionId(sessionId)) - .font(.subheadline.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Text("Saved override") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer() - } - - Toggle(isOn: mutedBinding(sessionId, fallback: override)) { - Label("Mute this session", systemImage: "bell.slash") - .labelStyle(.titleAndIcon) - .font(.caption) - } - .tint(ADEColor.purpleAccent) - .accessibilityHint("Silence all push alerts from this saved session") - - Toggle(isOn: awaitingOnlyBinding(sessionId, fallback: override)) { - Label("Awaiting-input only", systemImage: "hand.raised") - .labelStyle(.titleAndIcon) - .font(.caption) - } - .tint(ADEColor.purpleAccent) - .disabled(override.muted) - .accessibilityHint("Only alert when this saved session pauses for your input") - } - .padding(.vertical, 4) - } - - private var emptyState: some View { - VStack(alignment: .center, spacing: 10) { - Image(systemName: "tray") - .font(.largeTitle) - .foregroundStyle(ADEColor.textMuted) - .accessibilityHidden(true) - Text("No active sessions") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Sessions appear here once your paired machine starts syncing them.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - .accessibilityElement(children: .combine) - } - - // MARK: - Helpers - - private func reloadIfChanged() { - let nextAgents = ADESharedContainer.readWorkspaceSnapshot()?.agents ?? [] - guard nextAgents != agents else { return } - agents = nextAgents - } - - private func mutedBinding(_ sessionId: String, fallback: SessionNotificationOverride) -> Binding { - Binding( - get: { (overrides[sessionId] ?? fallback).muted }, - set: { newValue in - var current = overrides[sessionId] ?? fallback - current.muted = newValue - if newValue { current.awaitingInputOnly = false } - writeOverride(current, for: sessionId) - } - ) - } - - private func awaitingOnlyBinding(_ sessionId: String, fallback: SessionNotificationOverride) -> Binding { - Binding( - get: { (overrides[sessionId] ?? fallback).awaitingInputOnly }, - set: { newValue in - var current = overrides[sessionId] ?? fallback - current.awaitingInputOnly = newValue - writeOverride(current, for: sessionId) - } - ) - } - - private func writeOverride(_ override: SessionNotificationOverride, for sessionId: String) { - if notificationHasActiveOverride(override) { - overrides[sessionId] = override - } else { - overrides.removeValue(forKey: sessionId) - } - } - - private func shortSessionId(_ sessionId: String) -> String { - guard sessionId.count > 12 else { return sessionId } - return "\(sessionId.prefix(8))…\(sessionId.suffix(4))" - } - - private func statusLine(for agent: AgentSnapshot) -> String { - if agent.awaitingInput { - return "Awaiting your reply" - } - switch agent.status { - case "running": return "Running" - case "failed": return "Failed" - case "completed": return "Completed" - default: return agent.status.capitalized - } - } -} diff --git a/apps/ios/ADE/Views/Settings/QuietHoursEditorView.swift b/apps/ios/ADE/Views/Settings/QuietHoursEditorView.swift deleted file mode 100644 index f0e4d49cb..000000000 --- a/apps/ios/ADE/Views/Settings/QuietHoursEditorView.swift +++ /dev/null @@ -1,101 +0,0 @@ -import SwiftUI - -/// Daily do-not-disturb window editor. -/// -/// Only the time-of-day components are meaningful — the caller persists the -/// raw `Date`s but every evaluator across the codebase calls -/// `Calendar.current.dateComponents([.hour, .minute], from:)` and ignores the -/// day portion. -struct QuietHoursEditorView: View { - @Binding var start: Date? - @Binding var end: Date? - - private var enabled: Binding { - Binding( - get: { start != nil && end != nil }, - set: { newValue in - if newValue { - if start == nil { start = QuietHoursEditorView.defaultStart } - if end == nil { end = QuietHoursEditorView.defaultEnd } - } else { - start = nil - end = nil - } - } - ) - } - - var body: some View { - Form { - Section { - Toggle(isOn: enabled) { - Text("Enable quiet hours") - .font(.body) - } - .tint(ADEColor.purpleAccent) - .accessibilityHint("Suppress push alerts during a daily window") - } - - if start != nil || end != nil { - Section { - DatePicker( - "Start", - selection: Binding( - get: { start ?? QuietHoursEditorView.defaultStart }, - set: { start = $0 } - ), - displayedComponents: .hourAndMinute - ) - .accessibilityHint("Time each day when quiet hours begin") - - DatePicker( - "End", - selection: Binding( - get: { end ?? QuietHoursEditorView.defaultEnd }, - set: { end = $0 } - ), - displayedComponents: .hourAndMinute - ) - .accessibilityHint("Time each day when quiet hours end") - } - - Section { - Button(role: .destructive) { - start = nil - end = nil - } label: { - HStack { - Image(systemName: "xmark.circle") - Text("Clear quiet hours") - } - } - .accessibilityHint("Remove the configured quiet-hours window") - } - } - } - .navigationTitle("Quiet hours") - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .bottom) { - Text("Critical alerts — like CI failing on a PR you own — still break through quiet hours.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - .padding(.bottom, 12) - } - } - - private static var defaultStart: Date { - var components = DateComponents() - components.hour = 22 - components.minute = 0 - return Calendar.current.date(from: components) ?? Date() - } - - private static var defaultEnd: Date { - var components = DateComponents() - components.hour = 7 - components.minute = 0 - return Calendar.current.date(from: components) ?? Date() - } -} diff --git a/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift b/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift deleted file mode 100644 index a52b9e884..000000000 --- a/apps/ios/ADE/Views/Settings/SettingsNotificationsSection.swift +++ /dev/null @@ -1,130 +0,0 @@ -import SwiftUI - -/// Single-row settings entry that opens the full Notifications Center. -/// -/// Mirrors the visual style of `SettingsPairActionRow` but renders inside a -/// `NavigationLink` so the parent settings screen can push -/// `NotificationsCenterView` onto its existing `NavigationStack`. -struct SettingsNotificationsSection: View { - var onPreferencesChanged: (NotificationPreferences) -> Void - var onSendTestPush: () async -> SyncSendTestPushResult - - @State private var prefs = NotificationPreferences() - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - SettingsSectionHeader( - label: "NOTIFICATIONS", - hint: "Alerts from chat, CTO, PRs, and system" - ) - - NavigationLink { - NotificationsCenterView( - initialPreferences: prefs, - onPreferencesChanged: { updated in - prefs = updated - onPreferencesChanged(updated) - }, - onSendTestPush: onSendTestPush - ) - } label: { - rowContent - } - .buttonStyle(ADEScaleButtonStyle()) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint("Open the Notifications Center to configure alerts") - } - .task { - await refreshPreferencesAfterFirstPaint() - } - } - - private func refreshPreferencesAfterFirstPaint() async { - await Task.yield() - let loaded = NotificationPreferences.load(from: ADESharedContainer.defaults) - guard loaded != prefs else { return } - prefs = loaded - } - - private var rowContent: some View { - HStack(spacing: 14) { - Image(systemName: "bell.badge") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(ADEColor.purpleAccent) - .frame(width: 38, height: 38) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - ADEColor.purpleAccent.opacity(0.30), - ADEColor.purpleAccent.opacity(0.10), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(ADEColor.purpleAccent.opacity(0.35), lineWidth: 0.6) - ) - - VStack(alignment: .leading, spacing: 2) { - Text("Notifications") - .font(.body.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - Text(subtitle) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - - Spacer(minLength: 8) - - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.purpleAccent.opacity(0.55)) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill( - LinearGradient( - colors: [ - ADEColor.purpleAccent.opacity(0.06), - Color.clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - ) - .glassEffect(in: .rect(cornerRadius: 16)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [ - ADEColor.purpleAccent.opacity(0.32), - ADEColor.border.opacity(0.10), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 0.75 - ) - ) - } - - private var subtitle: String { - let enabled = prefs.enabledCategoryCount - let total = NotificationPreferences.totalCategoryCount - return "\(enabled) of \(total) categories enabled" - } - - private var accessibilityLabel: String { - "Notifications. \(subtitle)." - } -} diff --git a/apps/ios/ADENotificationService/ADENotificationService.entitlements b/apps/ios/ADENotificationService/ADENotificationService.entitlements deleted file mode 100644 index 7aa57eab8..000000000 --- a/apps/ios/ADENotificationService/ADENotificationService.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.ade.ios - - - diff --git a/apps/ios/ADENotificationService/Info.plist b/apps/ios/ADENotificationService/Info.plist deleted file mode 100644 index cceefd7f7..000000000 --- a/apps/ios/ADENotificationService/Info.plist +++ /dev/null @@ -1,31 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - ADE Notifications - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationService - - - diff --git a/apps/ios/ADENotificationService/NotificationService.swift b/apps/ios/ADENotificationService/NotificationService.swift deleted file mode 100644 index b134e0704..000000000 --- a/apps/ios/ADENotificationService/NotificationService.swift +++ /dev/null @@ -1,72 +0,0 @@ -import UserNotifications - -/// APNs `UNNotificationServiceExtension` used by ADE to decorate inbound -/// remote pushes before they are presented to the user. -/// -/// Responsibilities: -/// - Prefix the title with the provider brand (e.g. "Claude · …") when the -/// payload includes a `providerSlug` hint. -/// - Set `threadIdentifier` so the system groups pushes per session / PR. -/// - Raise `interruptionLevel` / `relevanceScore` for time-sensitive -/// categories (awaiting input). -/// -/// Never logs payload text — APNs content can contain model / user-authored -/// strings. -final class NotificationService: UNNotificationServiceExtension { - var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? - - override func didReceive(_ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - self.contentHandler = contentHandler - bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent - guard let content = bestAttemptContent else { - contentHandler(request.content) - return - } - - let userInfo = content.userInfo - - // 1) Brand-prefix the title if providerSlug is provided. - if let slug = userInfo["providerSlug"] as? String, !slug.isEmpty { - let cap = slug.prefix(1).uppercased() + slug.dropFirst() - if !content.title.lowercased().hasPrefix(cap.lowercased()) { - content.title = "\(cap) · \(content.title)" - } - } - - // 2) Thread identifier for grouping by session / PR. - if let sessionId = userInfo["sessionId"] as? String, !sessionId.isEmpty { - content.threadIdentifier = "session-\(sessionId)" - } else if let prNumber = userInfo["prNumber"] as? Int { - content.threadIdentifier = "pr-\(prNumber)" - } else if let prNumber = userInfo["prNumber"] as? NSNumber { - content.threadIdentifier = "pr-\(prNumber.intValue)" - } else if let prNumber = userInfo["prNumber"] as? String, !prNumber.isEmpty { - content.threadIdentifier = "pr-\(prNumber)" - } - - // 3) Interruption level / relevance for time-sensitive categories. - let categoryId = content.categoryIdentifier - if #available(iOS 15.0, *) { - if categoryId == "CHAT_AWAITING_INPUT" { - content.interruptionLevel = .timeSensitive - content.relevanceScore = 1.0 - } else if categoryId.hasPrefix("PR_") || categoryId == "CHAT_FAILED" { - content.interruptionLevel = .active - content.relevanceScore = 0.7 - } else { - content.interruptionLevel = .active - content.relevanceScore = 0.5 - } - } - - contentHandler(content) - } - - override func serviceExtensionTimeWillExpire() { - if let handler = contentHandler, let content = bestAttemptContent { - handler(content) - } - } -} diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 339925e79..a70acb81d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -305,70 +305,6 @@ final class ADETests: XCTestCase { ) } - func testNotificationPreferencesSavePrunesInactivePerSessionOverrides() throws { - let suiteName = "ADETests.NotificationPreferences.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - var prefs = NotificationPreferences() - prefs.perSessionOverrides = [ - "inactive": SessionNotificationOverride(), - "muted": SessionNotificationOverride(muted: true), - "awaiting": SessionNotificationOverride(awaitingInputOnly: true), - ] - - prefs.save(to: defaults) - - let loaded = NotificationPreferences.load(from: defaults) - XCTAssertNil(loaded.perSessionOverrides["inactive"]) - XCTAssertEqual(loaded.perSessionOverrides["muted"], SessionNotificationOverride(muted: true)) - XCTAssertEqual(loaded.perSessionOverrides["awaiting"], SessionNotificationOverride(awaitingInputOnly: true)) - } - - @MainActor - func testSyncNotificationPrefsPayloadOmitsInactivePerSessionOverrides() { - var prefs = NotificationPreferences() - prefs.perSessionOverrides = [ - "inactive": SessionNotificationOverride(), - "active": SessionNotificationOverride(awaitingInputOnly: true), - ] - - let payload = SyncService.encodeNotificationPrefsForDesktop(prefs) - let overrides = payload["perSessionOverrides"] as? [String: [String: Bool]] - - XCTAssertNil(overrides?["inactive"]) - XCTAssertEqual(overrides?["active"]?["muted"], false) - XCTAssertEqual(overrides?["active"]?["awaitingInputOnly"], true) - } - - func testNotificationStaleOverrideIdsKeepSavedOverridesVisible() { - let agent = AgentSnapshot( - sessionId: "active-session", - provider: "codex", - laneName: "Primary", - title: "Active", - status: "idle", - awaitingInput: false, - lastActivityAt: Date(), - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - - let staleIds = notificationStaleOverrideIds( - overrides: [ - "inactive-stale": SessionNotificationOverride(), - "active-session": SessionNotificationOverride(muted: true), - "saved-stale": SessionNotificationOverride(awaitingInputOnly: true), - ], - agents: [agent] - ) - - XCTAssertEqual(staleIds, ["saved-stale"]) - } - func testShellCliPermissionModeDoesNotInheritRuntimeMode() { XCTAssertNil(workCliPermissionMode(provider: "shell", runtimeMode: "plan")) XCTAssertEqual(workCliPermissionMode(provider: "codex", runtimeMode: "plan"), "plan") @@ -662,9 +598,6 @@ final class ADETests: XCTestCase { "project_clone_request", "project_list_my_github_repos_request", "heartbeat", - "register_push_token", - "notification_prefs", - "send_test_push", ] for type in runtimeScopedTypes { @@ -11330,7 +11263,15 @@ final class ADETests: XCTestCase { ) let toolEntries = snapshot.timeline.filter { $0.id.hasPrefix("tool-") } XCTAssertEqual(toolEntries.count, 1) - XCTAssertEqual(toolEntries.first?.id, "tool-call-dup") + XCTAssertEqual(toolEntries.first?.id, "tool-group:tool-call-dup") + guard case .toolGroup(let group)? = toolEntries.first?.payload else { + return XCTFail("Expected duplicate tool calls to collapse into one tool group.") + } + XCTAssertEqual(group.members.count, 1) + guard case .tool(let groupedCard)? = group.members.first else { + return XCTFail("Expected the collapsed group to retain the deduped tool card.") + } + XCTAssertEqual(groupedCard.id, "call-dup") } func testWorkChatToolLifecycleUsesLogicalItemIdForStableCards() { diff --git a/apps/ios/ADEWidgets/ADEControlWidget.swift b/apps/ios/ADEWidgets/ADEControlWidget.swift index 40d4efbdc..2b3d8868b 100644 --- a/apps/ios/ADEWidgets/ADEControlWidget.swift +++ b/apps/ios/ADEWidgets/ADEControlWidget.swift @@ -4,11 +4,6 @@ import WidgetKit /// Control Center widgets for iOS 18+. Each control is its own /// `ControlWidget` and registered by `ADEWidgetBundle`. -/// -/// Intent types (`OpenADEIntent`, `ToggleMutePushIntent`) live in -/// `LiveActivityIntentsForward.swift`; the mute intent persists its window via -/// `ADEMutePreferences.setMute(until:)` and forwards the ISO string to the -/// desktop host through the intent command bridge. @available(iOS 18.0, *) struct ADEControlWidget: ControlWidget { @@ -25,65 +20,10 @@ struct ADEControlWidget: ControlWidget { } } -@available(iOS 18.0, *) -struct ADEMuteControlWidget: ControlWidget { - static let kind = "com.ade.ios.control.muteNotifications" - - var body: some ControlWidgetConfiguration { - StaticControlConfiguration(kind: Self.kind) { - ControlWidgetToggle( - "Mute ADE notifications", - isOn: ADEMuteControlState.isMuted, - action: ToggleMutePushIntent() - ) { isOn in - Label( - isOn ? mutedUntilLabel() : "Mute", - systemImage: isOn ? "bell.slash.fill" : "bell.fill" - ) - } - } - .displayName("Mute ADE") - .description("Silence ADE pushes for an hour.") - } - - private func mutedUntilLabel() -> String { - if let until = ADEMutePreferences.muteUntil, until.timeIntervalSinceNow > 0 { - let formatted = until.formatted(date: .omitted, time: .shortened) - return "Muted until \(formatted)" - } - return "Muted" - } -} - -/// Reads the unified mute state from `ADEMutePreferences` so the Control -/// Center toggle renders the correct "is muted" pill without having to parse -/// the ISO date in-line. -@available(iOS 18.0, *) -enum ADEMuteControlState { - static var isMuted: Bool { ADEMutePreferences.isMuted } -} - // MARK: - Previews #if DEBUG -/// Control widgets don't support `#Preview(as:)` the way home/lock widgets do — -/// the system renders them inside the Controls gallery. These inline views let -/// the canvas show the OFF / ON label content in isolation. -@available(iOS 18.0, *) -#Preview("Mute label · OFF") { - Label("Mute", systemImage: "bell.fill") - .labelStyle(.titleAndIcon) - .padding() -} - -@available(iOS 18.0, *) -#Preview("Mute label · ON") { - Label("Muted until 9:00 AM", systemImage: "bell.slash.fill") - .labelStyle(.titleAndIcon) - .padding() -} - @available(iOS 18.0, *) #Preview("Open ADE label") { Label("Open", systemImage: "sparkles") diff --git a/apps/ios/ADEWidgets/ADEWidgetBundle.swift b/apps/ios/ADEWidgets/ADEWidgetBundle.swift index b05f9625c..54a68892b 100644 --- a/apps/ios/ADEWidgets/ADEWidgetBundle.swift +++ b/apps/ios/ADEWidgets/ADEWidgetBundle.swift @@ -18,7 +18,6 @@ struct ADEWidgetBundle: WidgetBundle { ADELockScreenWidget() if #available(iOS 18.0, *) { ADEControlWidget() - ADEMuteControlWidget() } } } diff --git a/apps/ios/ExportOptions.plist b/apps/ios/ExportOptions.plist index e8f796c02..8d351c738 100644 --- a/apps/ios/ExportOptions.plist +++ b/apps/ios/ExportOptions.plist @@ -16,8 +16,6 @@ ADE App Store com.ade.ios.widgets ADE Widgets App Store - com.ade.ios.notificationservice - ADE Notification Service App Store destination export diff --git a/apps/web/src/app/pages/PrivacyPage.tsx b/apps/web/src/app/pages/PrivacyPage.tsx index 49dc98d74..898498e2b 100644 --- a/apps/web/src/app/pages/PrivacyPage.tsx +++ b/apps/web/src/app/pages/PrivacyPage.tsx @@ -21,11 +21,10 @@ const sections: Section[] = [ { title: "What the iOS app collects", body: [ - "The iOS app (bundle ID com.ade.ios) handles only what is needed to pair with an ADE machine and receive notifications:", + "The iOS app (bundle ID com.ade.ios) handles only what is needed to pair with and control an ADE machine:", { list: [ "A pairing identifier and the machine address you scan or enter, stored on the device.", - "An Apple Push Notification (APNs) device token, sent only to the ADE machine you have paired with.", "Camera access, used solely on-device for QR code pairing. Frames are not stored or transmitted.", "Local network discovery (Bonjour, _ade-sync._tcp) to find your own ADE machine on the same network.", ], @@ -51,7 +50,6 @@ const sections: Section[] = [ "ADE relies on a small set of infrastructure services. None of them receive your project content unless you opt in to a feature that uses them.", { list: [ - "Apple — Apple Push Notification service for delivery to the iOS app.", "GitHub — desktop releases are distributed through GitHub Releases.", "Vercel — this website is hosted on Vercel; standard request logs apply.", "Mintlify — documentation is served at /docs through Mintlify.", @@ -64,7 +62,7 @@ const sections: Section[] = [ { title: "Retention", body: [ - "Pairing identifiers and push tokens persist on the iOS device until you uninstall the app or unpair from the machine. Computer data persists on your local disk and is yours to keep, move, or delete. Data sent to AI providers is governed by each provider's retention policy.", + "Pairing identifiers persist on the iOS device until you uninstall the app or unpair from the machine. Computer data persists on your local disk and is yours to keep, move, or delete. Data sent to AI providers is governed by each provider's retention policy.", ], }, { diff --git a/architecture.mdx b/architecture.mdx index 8dd710748..c98fbc3f5 100644 --- a/architecture.mdx +++ b/architecture.mdx @@ -53,7 +53,7 @@ The `ade` CLI gives agents and humans a typed way to reach ADE actions from a sh ## iOS companion -The iOS app pairs with the local runtime. It mirrors useful state, receives push notifications, and can send commands back to the Mac. Agents still run on the Mac or configured remote runtime. +The iOS app pairs with the local runtime. It mirrors useful state and can send commands back to the Mac. Agents still run on the Mac or configured remote runtime. ## Optional remote runtimes diff --git a/changelog/v1.0.19.mdx b/changelog/v1.0.19.mdx index 5c7ab900c..de39cf3df 100644 --- a/changelog/v1.0.19.mdx +++ b/changelog/v1.0.19.mdx @@ -3,15 +3,7 @@ title: "v1.0.19" description: "Release notes for ADE v1.0.19 — April 21, 2026" --- -New AI backends, iOS push notifications, smarter PR and rebase tooling, and a diagnostics dashboard. - ---- - -## Mobile push notifications (APNS) - -ADE can now send push notifications to your iPhone when agents complete tasks or need your input. Notifications fire for run complete, run failed, and intervention required events — so you no longer have to keep the desktop app visible to stay aware of long-running work. - -Setup takes about 30 seconds: open **Settings → Notifications → Mobile Push**, scan the QR code with the ADE iOS app, and choose which events trigger a push. Multiple devices can be registered. +New AI backends, smarter PR and rebase tooling, and a diagnostics dashboard. --- @@ -77,4 +69,3 @@ A new **Diagnostics** section in Settings shows a live view of ADE's runtime hea ## Other changes - **Device registry improvements** — multi-device tracking is more reliable; stale device entries are pruned automatically after 30 days of inactivity -- **Settings consolidation** — notification settings gained a Mobile Push sub-section; the settings card overview was updated to reflect new sections diff --git a/changelog/v1.1.5.mdx b/changelog/v1.1.5.mdx index 636f7b790..aebf90c2f 100644 --- a/changelog/v1.1.5.mdx +++ b/changelog/v1.1.5.mdx @@ -20,7 +20,6 @@ v1.1.5 builds the two-way pty bridge so an iOS device can drive a desktop shell, - **OpenCode listener-pid recovery.** `openCodeServerManager` recovers servers from orphaned ports by listener-pid; when `listenerPid === proc.pid`, the listener-PID kill is the sole signal — only fall through to `stopChildProcess` when they differ. - **PTY title fallback.** Hardened to a deterministic CLI placeholder so a brand-new shell never renders an empty title. - **`ExitPlanMode` behaviour.** Flipped to `behavior:"allow"` so the SDK's native handler runs, removing a layer of indirection that was eating the resume signal. -- **`getOptionalSyncService` resolver.** `registerIpc` routes `lanesList` and `apnsSendTestPush` through a single resolver that respects an injected `getSyncService` null result instead of falling through to `ctx`. Keeps tests honest about which projects have sync wired. - **PTY resume target backfill on launch.** When relaunching a tracked CLI session whose `resumeMetadata` is missing `targetId`, `ptyService` now runs `tryBackfillResumeTarget("resume-launch")` before spawning so the new pty starts on the correct resume command instead of cold-starting. Replaces the prior warn-only branch. - **Per-project route restore.** `AppShell` persists each project's last-visited route to `localStorage` and restores it on project switch, instead of always slamming `/work`. Allowed roots only — falls back to `/work` for anything unrecognized. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9a2f236bc..af4004718 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -201,7 +201,7 @@ Native SwiftUI app acting as a controller. It pairs with an ADE machine over Web - CRDT: pure-SQL CRR emulation layer (trigger-based change tracking) since iOS blocks `sqlite3_load_extension()`/`sqlite3_auto_extension()`. Changesets are wire-compatible with desktop cr-sqlite. - Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, `LiveActivityCoordinator.swift`. - Shipped tabs: Lanes, Files, Work, PRs, CTO, Settings. -- Shipped: APNs push pipeline (runtime-side `apnsService` + `notificationEventBus` → iOS `AppDelegate` + `NotificationCategories` + Notification Service Extension), workspace Live Activity (Lock Screen + Dynamic Island), Home Screen / Lock Screen / Control Center widgets. +- Shipped: workspace Live Activity (Lock Screen + Dynamic Island), Home Screen / Lock Screen / Control Center widgets. - Planned: Automations, Graph, History tabs; iPad layout; Spotlight. - Target: iOS 26+, iPhone + iPad. @@ -544,7 +544,6 @@ Most services described here live under `apps/desktop/src/main/services/ | `shared/` | `utils.ts`, `imageDimensions.ts`, `queueRebase.ts`, `packLegacyUtils.ts`, `transcriptInsights.ts` | Cross-domain utilities, including shared record guards and PNG/JPEG dimension parsing used by App Control and iOS Simulator capture paths. | | `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. `globalState.upsertRecentProject` accepts `preserveRecentOrder` so reactivating an already-known project (by app focus, deep link, etc.) refreshes its `lastOpenedAt` in place instead of jumping it to the front of the recents list. `model_picker_favorites` and `model_picker_recents` are per-project CRR tables shared by desktop, TUI, and iOS; they are primary-key-only so CRR can convert them, with the recents cap enforced in `modelPickerStore.ts`. `AdeDb.sync.discardUnpublishedChangesForTables(tableNames)` lets a service clear local CRR state for specific tables without leaking those clears to sync peers — it records the cleared tables and `through_db_version` in the local-only `local_crr_change_suppressions` table, and `exportChangesSince` filters local-site rows for those tables at or below that version on the way out. The local-only excluded set (still kept out of replication) includes that suppression table itself, the snapshot caches, `pr_auto_link_ignores`, `pull_request_ai_summaries`, and `runtime_processes`. `crsql_changes` DELETE statements run through a helper that swallows the read-only-table error the cr-sqlite extension raises when a CRR-managed table is wiped, with a `db.crr_changes_cleanup_skipped` warn log instead of failing the migration. | | `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | **Thin delegation to the ADE runtime's sync service.** The authoritative sync service now lives in `apps/ade-cli/src/services/sync/`; the desktop main-process instances default to a non-host viewer role for legacy state and tests. The old in-process host is disabled unless `ADE_ENABLE_DESKTOP_SYNC_HOST=1` (diagnostics only). Wire formats — WebSocket envelope, remote command routing, device registry, pairing secrets — are the same across both implementations. Viewer joins clear the local `devices` + `sync_cluster_state` rows and then call `db.sync.discardUnpublishedChangesForTables(["devices", "sync_cluster_state"])` so the resulting DELETE rows do not leak back to other peers; the peer client follows up with `syncPeerService.acknowledgeLocalDbVersion()` to advance the outbound cursor past the suppressed range. | -| `notifications/` | `apnsService.ts`, `apnsBridgeService.ts`, `notificationMapper.ts`, `notificationEventBus.ts` | APNs HTTP/2 client (ES256 JWT, key persisted via Electron `safeStorage` on the desktop or `EncryptedFileCredentialStore` under `.ade/secrets/` in a headless runtime), pure domain-event → `MappedNotification` mapping (13 categories / 4 families), event bus routing to APNs alert pushes + Live Activity update pushes + in-app WS delivery, filtered by per-device `NotificationPreferences`. `apnsBridgeService.ts` is the `notifications_apns` ADE action domain (`getStatus`, `saveConfig`, `uploadKey`, `clearKey`, `sendTestPush`) so the same Settings flow works whether the active project is local-bound or SSH-bound. | | `tests/` | `testService.ts` | Test-suite execution + run history. | | `updates/` | `autoUpdateService.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle \| checking \| downloading \| ready \| installing \| error`), uses `compareUpdateVersions` (SemVer-aware) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. Packaged builds schedule startup/periodic checks; source/dev launches construct the service without auto-check timers so missing `app-update.yml` never surfaces as a renderer error. `quitAndInstall()` is async: it re-runs `checkForUpdates({ allowReady: true })` to confirm the staged build is still latest, and only then flips to `installing` and calls `updater.quitAndInstall(false, true)`. | | `usage/` | `usageTrackingService.ts`, `budgetCapService.ts`, `ledgers/localUsageLedgers.ts` | Token/cost accounting, budget enforcement. `usageTrackingService.ts` owns polling, aggregation, pacing, GitHub stats, and cache orchestration; local provider ledger scanners live under `usage/ledgers/`. Budget caps can match a rule scope while `usd-per-run` evaluates usage records keyed to the active run id. Threshold state is shared at module level across all `createUsageTrackingService` instances so multiple project contexts don't fire duplicate threshold events; `main.ts` adds a final IPC-level dedup gate with a 10-minute TTL per `provider:threshold:resetCycle` key. | @@ -892,7 +891,6 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s shared with the desktop Work tab through `apps/desktop/src/shared/cliLaunch.ts`. - Pairing is a **user-set 6-digit PIN** stored at `.ade/secrets/sync-pin.json` on the host. The phone sends the PIN once; the host returns a durable per-device secret. QR payload is v2 (host identity + port + address candidates, no pairing code). -- APNs pipeline: iOS registers device tokens (alert + push-to-start + per-activity update) via `SyncService.registerPushToken`. The host's `notificationEventBus` routes domain events (chat, PR, CTO, system) to `apnsService` for alert pushes and Live Activity update pushes, filtered by per-device `NotificationPreferences` stored in the iOS App Group `UserDefaults`. - Widgets: `ADEWorkspaceWidget` (Home Screen), `ADELockScreenWidget`, `ADEControlWidget` (Control Center, iOS 18+) read from a shared `WorkspaceSnapshot` in the App Group container. `LiveActivityCoordinator` manages the single workspace Live Activity. - Tabs: Lanes, Files, Work, PRs, CTO, Settings. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index cfc0a2e52..c8c93df45 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -182,8 +182,8 @@ Renderer — settings: - `apps/desktop/src/renderer/components/app/SettingsPage.tsx` — tab container. The current top-level sections are General, Appearance, - AI Connections, Background Jobs, Mobile Push, Integrations, Lane - Templates, and Lane Behavior. Legacy `workspace`, `project`, and + AI Connections, Background Jobs, Integrations, Lane Templates, and + Lane Behavior. Legacy `workspace`, `project`, and `context` deep links land in General; `providers` lands in AI Connections; `automations` lands in Background Jobs. Tutorial replay and tour entry points live under the Help menu in the top bar, not @@ -267,7 +267,7 @@ Renderer — settings: only `claude` and `codex` are tracked in `TRACKED_PROVIDERS`. Budget caps round-trip through `ade.usage.getBudgetConfig` / `saveBudgetConfig`. Threshold crossings (25 / 50 / 75 / 100 %) emit - `UsageThresholdEvent`s the notification bus turns into APNs alerts. + `UsageThresholdEvent`s for local usage handling. - `apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx` — Settings > Stats. Reads `window.ade.usage.getAdeStats({ preset })` for today / 7d / 30d / all-time stats and calls @@ -434,7 +434,6 @@ changing rather than which service backs it: | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, agent-turn completion sound + volume + quiet-when-focused, chat font size (`chatFontSizePx`), chat transcript density (`chatTranscriptDensity` — `compact` / `comfortable` / `spacious`), chat chrome tint (`chatChromeTint` — `colored` default vs `neutral` for monochrome chrome; the legacy `chatLaneAccentEmphasis` preset slug is still read so older user-pref blobs migrate cleanly), chat shell geometry (`chatShellGeometry` — `soft` / `default` / `sharp` corners), the user-message minimap toggle (`chatUserMinimapEnabled` — drives the inline `ChatUserMinimap`), and the Work launch-prompt clipboard preferences (`launchPromptClipboardEnabled` for copying submitted prompts, `launchPromptClipboardNoticeEnabled` for the composer reminder). Persisted to `localStorage` under `ade.userPreferences.v1`. | | Workspace | `WorkspaceSettingsSection.tsx`, `ProjectSection.tsx` | Project identity, paths, skill files. (`SyncDevicesSection.tsx` — multi-device sync, host transfer, peer status, pairing PIN, Tailscale discovery — is mounted from the top bar's Sync popover, not as a Settings tab.) | | AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics, and AI feature flags. The same status surface is exposed through ADE actions for `ade code` model setup. | -| Mobile Push | `MobilePushPanel.tsx` | APNs registration, paired-device push tokens, per-category preferences | | Integrations | `IntegrationsSettingsSection.tsx`, `GitHubSection.tsx`, `LinearSection.tsx` | GitHub, Linear, and computer-use backend readiness. The GitHub section reads `status.connected` (the backend's single "GitHub is usable" gate) to decide between CONNECTED / LIMITED ACCESS / NOT CONNECTED, surfaces a dedicated repo-probe error when a fine-grained token authenticates as a user but cannot access the active repo, and the REFRESH button calls `getStatus({ forceRefresh: true })` so users who fix permissions on github.com see the change immediately. See [`pull-requests/README.md`](../pull-requests/README.md#github-connectivity-model) for the full status-shape and `connected` derivation. | | Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and lane lifecycle policy | | Stats | `AdeUsageSection.tsx` | Local runtime token / cost summaries and GitHub-backed PR, commit, and code movement totals. Deep links from `?tab=usage` and `?tab=stats` land here. | diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index c5b0358eb..ddfc85ac2 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -310,44 +310,17 @@ iOS service files (`apps/ios/ADE/Services/`): PR mobile snapshot fetch, live chat-event push listener, lane reparent payload building with the optional stack base-branch override, project home/catalog state, active-project scoping, - unregistered-worktree discovery, and APNs push-token registration - to the runtime, plus local project-list hiding for "Remove from list" - so cached DB rows and runtime catalog rows for the same root disappear - together. + unregistered-worktree discovery, and local project-list hiding for + "Remove from list" so cached DB rows and runtime catalog rows for + the same root disappear together. - `KeychainService.swift` — iOS Keychain Services for paired device secrets (per-machine token shelf included). - `LiveActivityCoordinator.swift` — owns the single workspace - `Activity` lifecycle and forwards - push-to-start / per-activity update tokens to the runtime. - -Notification services (`apps/desktop/src/main/services/notifications/`): - -- `apnsService.ts` — HTTP/2 APNs client, ES256 JWT signing, - `ApnsKeyStore` (`.p8` persisted via Electron `safeStorage` in the - desktop process or an `EncryptedFileCredentialStore` rooted at - `.ade/secrets/` when the runtime runs headless on a remote machine), - `Http2ApnsTransport` (injectable via `ApnsTransport` for tests). -- `apnsBridgeService.ts` — exposes the `notifications_apns` ADE action - domain (`getStatus`, `saveConfig`, `uploadKey`, `clearKey`, - `sendTestPush`) so a desktop window bound to a remote runtime - configures APNs against the remote runtime instead of the local - Electron process. ade-cli `bootstrap.ts` constructs the service + - key store and re-applies any persisted config on startup so push - works without a desktop attached. -- `notificationMapper.ts` — pure domain-event → `MappedNotification` - mapping across 13 categories in 4 families (chat, cto, pr, system). -- `notificationEventBus.ts` — `publishChatEvent`, `publishPrEvent`, - `publishSystemEvent`, `sendTestPush`. Routes - to APNs (alert + Live Activity update pushes) and/or in-app WS - delivery, filtered by per-device `NotificationPreferences`. - -iOS notification / widget files (under `apps/ios/`): - -- `ADE/App/AppDelegate.swift`, `ADE/App/NotificationCategories.swift`, - `ADE/App/DeepLinkRouter.swift`, `ADE/Models/NotificationPreferences.swift`. -- `ADENotificationService/NotificationService.swift` — - `UNNotificationServiceExtension` (brand prefix, `threadIdentifier`, - `interruptionLevel` / `relevanceScore`). + `Activity` lifecycle. + +iOS widget files (under `apps/ios/`): + +- `ADE/App/DeepLinkRouter.swift`. - `ADEWidgets/ADELiveActivity.swift`, `ADEWorkspaceWidget.swift`, `ADELockScreenWidget.swift`, `ADEControlWidget.swift` (Control Center widgets, iOS 18+). diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index d837d10bc..969708738 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -51,8 +51,6 @@ apps/ios/ ├── ADE/ │ ├── App/ │ │ ├── ADEApp.swift # SwiftUI app entry -│ │ ├── AppDelegate.swift # APNs registration, notification-category -│ │ │ # setup, response/action routing, deep-link dispatch │ │ ├── ContentView.swift # 5-tab TabView with a custom │ │ │ # `ADERootBottomTabBar` overlay │ │ │ # (Work/Lanes/PRs/Files/CTO + Work @@ -74,21 +72,16 @@ apps/ios/ │ │ │ # post .adeSendToMacRequested instead so the │ │ │ # SendToMacCard sheet can bounce the URL to │ │ │ # a paired host via the deeplinks.open sync -│ │ │ # command. Also dispatches notification -│ │ │ # userInfo (sessionId / prId / prNumber → prId -│ │ │ # via WorkspaceSnapshot lookup). -│ │ └── NotificationCategories.swift # UNNotificationCategory / UNNotificationAction set +│ │ │ # command. │ ├── Models/ -│ │ ├── RemoteModels.swift # Codable structs mirroring shared types -│ │ └── NotificationPreferences.swift # 13-toggle prefs + quiet hours + per-session overrides +│ │ └── RemoteModels.swift # Codable structs mirroring shared types │ ├── Resources/ │ │ ├── DatabaseBootstrap.sql # generated from desktop kvDb.ts │ │ └── VoiceGlossary.json # shared dictation cleanup glossary │ ├── Services/ │ │ ├── Database.swift # SQLite + pure-SQL CRR + offline caches │ │ ├── KeychainService.swift # paired device secret storage -│ │ ├── LiveActivityCoordinator.swift # workspace Live Activity lifecycle + -│ │ │ # push-token collection +│ │ ├── LiveActivityCoordinator.swift # workspace Live Activity lifecycle │ │ ├── Dictation/ # SpeechDictationService, │ │ │ # DictationController, deterministic │ │ │ # cleanup, VoiceGlossary loader @@ -98,7 +91,7 @@ apps/ios/ │ │ # CLI launcher (startCliSession), chat push, │ │ # machine project browse/open/create/clone, │ │ # lane reparent stack-base override payloads, -│ │ # push-token registration, worktree discovery +│ │ # worktree discovery │ ├── Shared/ │ │ ├── ADESharedContainer.swift # App Group UserDefaults + WorkspaceSnapshot helpers │ │ ├── ADESharedModels.swift # AgentSnapshot, PrSnapshot — shared with widgets @@ -106,7 +99,7 @@ apps/ios/ │ │ ├── DictationActivityShared.swift # Dictation Live Activity attributes, │ │ │ # waveform, Done/Cancel intents │ │ ├── LiveActivityIntentsForward.swift # ADEIntentCommandKind, ADEIntentCommandRegistry -│ │ └── WidgetAppIntents.swift # OpenADEIntent, ToggleMutePushIntent (iOS 18+) +│ │ └── WidgetAppIntents.swift # widget configuration intents │ ├── Views/ │ │ ├── Components/ # ADEDesignSystem (incl. ADEConnectionDot, │ │ │ # ADEUIKitAppearance.configureTabBar(), @@ -147,18 +140,13 @@ apps/ios/ │ │ │ # per-tab views, PrWorkflowCards, │ │ │ # PrStackSheet, CreatePrWizardView, │ │ │ # PrTargetBranchPickerDropdown -│ │ ├── Settings/ # ConnectionSettingsView, NotificationsCenterView, -│ │ │ # QuietHoursEditorView, PerSessionOverrideView, -│ │ │ # SettingsPairingSection, SettingsConnectionHeader, -│ │ │ # SettingsPinSheet, SettingsNotificationsSection, +│ │ ├── Settings/ # ConnectionSettingsView, SettingsPairingSection, +│ │ │ # SettingsConnectionHeader, SettingsPinSheet, │ │ │ # SettingsVoiceInputSection │ │ └── LanesTabView.swift │ └── Assets.xcassets/ # App icon, brand mark, provider logos │ # (Anthropic, Claude, Codex, Cursor, │ # Droid, OpenAI, OpenCode) -├── ADENotificationService/ -│ └── NotificationService.swift # UNNotificationServiceExtension: brand-prefix -│ # title, set threadIdentifier, raise interruption level ├── ADEWidgets/ │ ├── ADEWidgetBundle.swift # WidgetBundle registering all three widget surfaces │ ├── ADELiveActivity.swift # ADESessionAttributes (ActivityKit), ADELiveActivity widget @@ -291,7 +279,7 @@ Navigation: - `TabView` at the root with five tabs (Lanes, Files, Work, PRs, Settings). - `NavigationStack` per tab for push/pop. -- Deep links from push notifications jump to specific screens. +- Deep links jump to specific screens. ## Database: native SQLite + pure-SQL CRR @@ -505,132 +493,8 @@ per-IP rate limiting (5 failures → 10-minute cooldown). - Registers `BGAppRefreshTask` for periodic state sync when the app is backgrounded. - iOS grants ~30 seconds per fetch window. -- Priority order: sync cr-sqlite changesets, update notification badges. - -### Push Notifications (APNs) - -ADE delivers push notifications from the runtime to the iOS app -over Apple Push Notification service. The full stack is implemented: - -**Runtime side** (`apps/desktop/src/main/services/notifications/`): - -- `apnsService.ts` — HTTP/2 APNs client using `node:http2` + JWT - signed with `node:crypto` (ES256). No native binary dependency. - The `.p8` private key is encrypted at rest via Electron - `safeStorage.encryptString` and stored at - `/apns.key.enc`; decrypted in-memory only during signing. - Key configured via `ApnsConfigureOptions` (keyP8Pem, keyId, teamId, - bundleId, env). JWTs are refreshed every 50 minutes to stay within - Apple's 60-minute limit. `ApnsKeyStore` manages encrypted - persistence; `signApnsJwt` is the pure signing function. - `APNS_INVALID_TOKEN_REASONS` lists token-dead reasons - (`BadDeviceToken`, `Unregistered`, `DeviceTokenNotForTopic`). - `Http2ApnsTransport` is the default transport; tests inject a mock - via the `ApnsTransport` interface seam. - -- `notificationMapper.ts` — side-effect-free mapping from ADE domain - events to `MappedNotification` values. Thirteen `NotificationCategory` - values across four families: - - | Category | Family | Push type | Priority | - |---|---|---|---| - | `CHAT_AWAITING_INPUT` | chat | alert | 10 (time-sensitive) | - | `CHAT_FAILED` | chat | alert | 10 | - | `CHAT_COMPLETED` | chat | alert | 5 | - | `CTO_SUBAGENT_STARTED` | cto | alert | 5 | - | `CTO_SUBAGENT_FINISHED` | cto | alert | 5 | - | `PR_CI_FAILING` | pr | alert | 10 | - | `PR_REVIEW_REQUESTED` | pr | alert | 10 | - | `PR_CHANGES_REQUESTED` | pr | alert | 10 | - | `PR_MERGE_READY` | pr | alert | 5 | - | `SYSTEM_PROVIDER_OUTAGE` | system | alert | 10 | - | `SYSTEM_AUTH_RATE_LIMIT` | system | alert | 10 | - | `SYSTEM_HOOK_FAILURE` | system | alert | 5 | - - Each notification carries a `deepLink` (`ade://session/` or - `ade://pr/`), a `collapseId` for de-duplication, and a - `metadata` bag that lets the iOS side set `threadIdentifier`. - -- `notificationEventBus.ts` — routes domain events to APNs and/or - in-app WebSocket delivery. Call surface: `publishChatEvent`, - `publishPrEvent`, `publishSystemEvent`, - `sendTestPush`. The bus asks `listPushTargets()` and - `getPrefsForDevice()` at send time so preference toggles take effect - immediately. If the device is currently connected over WebSocket, - the bus also delivers an in-app notification via - `sendInAppNotification` even when APNs is disabled. `apns-expiration` - is set to `+1h` for priority-10 pushes and `+10m` for priority-5 - pushes; stale banners are not queued after the window. - Also sends Live Activity `liveactivity` pushes to per-activity - update tokens when the notification maps to an attention value (chat - awaiting input / failed, PR CI failing / review requested / merge - ready). `sendTestPush` has an explicit fallback: when APNs is not - configured but the target device is currently connected, the bus - delivers an in-app `system` notification and returns - `{ ok: true, reason: "in_app_only" }`, and the `ade serve` runtime's - `syncHostService` does the same when no notification bus is wired at - all, so "Send test push" on the phone always produces a visible - confirmation when the WebSocket is alive. - -**iOS client side**: - -- `AppDelegate.swift` — owns APNs registration - (`registerForRemoteNotifications`), notification-category setup - (`NotificationCategories.register()`), and response routing: - `Approve`/`Deny`/`Reply` actions forward to - `SyncService.sendRemoteCommand(approveSession/denySession/replyToSession)`; - `Restart` calls `restartSession`; `RetryChecks` calls - `retryPrChecks`; tapping the banner body dispatches to - `DeepLinkRouter`. - Requests `.alert`, `.badge`, `.sound`, `.providesAppNotificationSettings`, - and `.timeSensitive` (iOS 15+) at first launch. - Token registration calls - `SyncService.shared?.registerPushToken(hex, kind: .alert, ...)` which - transmits the token to the runtime via the sync command surface. - -- `NotificationCategories.swift` — declares ten - `UNNotificationCategory` / `UNNotificationAction` values matching - the desktop `NotificationCategory` identifiers 1:1. - `CHAT_AWAITING_INPUT` gets Approve + Deny + Reply (text input); - `CHAT_FAILED` gets Open agent + Restart; `PR_CI_FAILING` gets - Open PR + Retry checks; `PR_MERGE_READY` gets View PR; CTO - and system categories get generic Open actions. - -- `ADENotificationService/NotificationService.swift` — Notification - Service Extension that decorates inbound APNs payloads before - display: prefixes the title with the provider brand slug (e.g. - `Claude · Awaiting approval`), sets `threadIdentifier` from - `sessionId` or `prNumber` so the OS groups banners per session/PR, - and raises `interruptionLevel` / `relevanceScore` based on category. - -- `DeepLinkRouter.swift` — `ade://` URL handler and notification- - payload dispatcher. Parses `ade://session/` and - `ade://pr/` URLs, plus notification `userInfo` bags carrying - `sessionId`, `prId`, or `prNumber`, and posts - `.adeDeepLinkRequested` to `NotificationCenter` so individual tab - views can flip their selection. PR deep links specifically resolve - to a stable `prId`: when the inbound identifier is the GitHub PR - number (from a widget / Live Activity URL or a legacy `prNumber` - payload), `resolvePrId` looks it up against the App Group - `WorkspaceSnapshot` and stashes the matching id on - `SyncService.requestedPrNavigation` (a `PrNavigationRequest`) so - `PrsRootScreen` opens the same row the desktop would. When the - payload already carries a `prId`, that is used verbatim. - -**Notification preferences** (`apps/ios/ADE/Models/NotificationPreferences.swift`): - -- `NotificationPreferences` — 13 per-category toggles (chat 3, - CTO 3, PR 4, system 3), a quiet-hours window (start/end time-of-day - `Date`), and `perSessionOverrides` keyed by `sessionId` - (`muted`, `awaitingInputOnly`). Persisted as JSON in the App Group - `UserDefaults` at key `ade.notifications.prefs`. -- Synced to the runtime so the runtime can gate APNs sends by - device preferences without requiring an extra round-trip. -- `NotificationsCenterView.swift` — unified notifications settings - screen with category toggles, quiet-hours picker - (`QuietHoursEditorView`), per-session overrides - (`PerSessionOverrideView`), authorization status banner, and "Send - test push" action. +- Priority order: sync cr-sqlite changesets and update shared workspace + snapshots. ### Live Activities @@ -668,10 +532,8 @@ as the system-header suffix (`ADE · `). Because focus-lane change forces the coordinator to end the existing activity and request a new one, so the header always matches the lane the user is watching. Push-to-start (iOS 17.2+) and per-activity update tokens -are collected via `pushTokenUpdates` and forwarded to the runtime through -`LiveActivityHost.sendPushToken`. The runtime sends `liveactivity` APNs -pushes to the update tokens via `notificationEventBus` whenever an -attention-eligible event fires. +are not used; the app updates the activity from local sync state while +it is running. The `ADELiveActivity` widget registers the `ActivityConfiguration` for the lock-screen / banner presentation and the Dynamic Island @@ -703,16 +565,12 @@ Three widget / control surfaces are registered by `ADEWidgetBundle`: - `ADELockScreenWidget` — accessory rectangular/circular sizes for the iOS Lock Screen; reads from the same shared snapshot. -- `ADEControlWidget` (iOS 18+) — Control Center "Open ADE" button - and "Mute ADE" toggle. The mute toggle persists a window via - `ADEMutePreferences.setMute(until:)` and forwards the ISO timestamp - to the runtime via the intent command bridge - (`ADEIntentCommandRegistry` / `ADESyncIntentBridge`). +- `ADEControlWidget` (iOS 18+) — Control Center "Open ADE" button. Shared DTOs live in `apps/ios/ADE/Shared/ADESharedModels.swift`: `AgentSnapshot` and `PrSnapshot` — lightweight Codable structs -readable by the widget extension and the notification service -extension without importing the main app's heavier renderer code. +readable by the widget extension without importing the main app's +heavier renderer code. ### Haptic Feedback @@ -810,7 +668,7 @@ Opening or selecting the project again clears those hidden keys. | **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | -| **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | +| **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. | ### Planned @@ -957,7 +815,6 @@ reflected in the phone's UI on the next descriptor read. | Settings tab (pairing / appearance / diagnostics) | Implemented | | CTO / Automations / Graph / History tabs | Planned | | Full Settings parity | Planned | -| Push notifications (APNs) | Implemented (categories, actions, Notification Service Extension, per-device preferences) | | Widgets (Home Screen / Lock Screen / Control) | Implemented | | Live Activities (workspace roster + attention) | Implemented | | iPad adaptive layout | Planned | @@ -1119,14 +976,6 @@ reflected in the phone's UI on the next descriptor read. awaiting-input or a PR that goes red again resurfaces automatically. Do not turn this into a permanent allowlist; recurrence visibility is the whole point. -- **`NotificationPreferences.save(to:)` writes the pruned struct.** - Per-session overrides with both switches off are equivalent to no - override; the iOS save path calls - `pruningInactivePerSessionOverrides` before encoding so toggling - agents on and then back off does not bloat the App Group - `UserDefaults` payload. The same pruning happens on the desktop - side in `normalizeNotificationPreferences` to keep both ends in - agreement after a round-trip. - **The runtime's iOS sync wants `ADE_PROJECT_ROOT` for preferred project.** `ade serve` reads `ADE_PROJECT_ROOT` and pre-registers the project through `ProjectRegistry.add` so the sync diff --git a/docs/perf/ios-mobile-action-inventory.md b/docs/perf/ios-mobile-action-inventory.md index 46b44bb33..26867b4db 100644 --- a/docs/perf/ios-mobile-action-inventory.md +++ b/docs/perf/ios-mobile-action-inventory.md @@ -27,8 +27,6 @@ this pass. | --- | --- | --- | | Headless runtime project selection | fixed | `ade serve` registers the `ADE_PROJECT_ROOT` project so the phone syncs `/Users/admin/Projects/perf pass`, not the most recent desktop project. | | Project picker idle flicker | fixed | Computer Use caught the project picker showing duplicate cached `perf pass` rows and idle reconnect churn. The app now deduplicates cached projects by normalized root while keeping the active project first, ignores timestamp-only discovery refreshes for published host lists, and stops scanning Bonjour `.local` hosts across the whole fallback port window. CUA verified the same screen now shows one 4-lane `perf pass` row, and recent simulator logs contain no `ne_tracker_check_is_hostname_blocked` spam. | -| Notification preference mirror | fixed | Codex Computer Use cycled category toggles, quiet-hours enable/edit/clear, and per-session override mute/awaiting-only controls. iOS now prunes inactive per-session override rows before saving/sending, desktop normalization drops inactive legacy rows, and reconnect uploads saved notification preferences so offline toggle changes clear stale host metadata. | -| Send test push feedback | fixed | Computer Use tapped `Send test push` on the real Notifications screen. The previous hidden `notification_bus_unavailable` host failure is now surfaced, and the headless throwaway runtime delivers a foreground in-app ADE notification over the sync WebSocket when APNs is unavailable. CUA verified both the banner and the success text: `Test notification delivered in app. APNs is not wired in this runtime.` | | Settings sheet flicker and appearance | fixed | Codex Computer Use reproduced the live Settings sheet while paired to the throwaway runtime. Settings now reads a throttled Equatable presentation snapshot instead of observing every `SyncService` publish, and fresh `lastSyncAt` churn renders as stable `just now`. CUA cycled Light/Dark/System appearance; Light and Dark now apply to the modal itself. Repeated simulator captures after the fix were pixel-identical (`magick compare -metric AE` returned `0`). | | Settings discovery row churn | fixed | CUA reproduced the disconnected Settings discovery sheet showing duplicate service rows for the same LAN machine (`lappy` / `MacBook-Pro-567.local` at `192.168.1.249`) and a raw-count label. Discovery display now coalesces duplicate live services by primary displayed route, keeps distinct LAN machines separate even when secondary/Tailscale metadata overlaps, and the Settings button uses the same coalesced count as the sheet. CUA verified `2 nearby machines found` and two unique rows (`Mac.lan`, `MacBook-Pro-567.local`); repeated sheet captures compared at `0` content-pixel delta below the status bar. | | Desktop apply of mobile CRDT changes | fixed | Desktop normalizes legacy text/numeric single-column primary keys before writing `crsql_changes`. | @@ -205,10 +203,6 @@ this pass. - Codex Computer Use verification after flicker fix: Simulator project picker shows one `perf pass` row (`4 lanes`) and the machine banner remains idle at `No machine attached`. - Simulator log verification after flicker fix: `xcrun simctl spawn 2CD8BD1C-C5F5-4B9D-B446-803488E4F559 log show --last 2m --style compact --predicate 'process == "ADE" AND eventMessage CONTAINS "ne_tracker_check_is_hostname_blocked"'` — no matching ADE events. - Latest full iOS pass after project-picker flicker fix: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `278 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T15-44-50-318Z_pid61834_83a9527f.log` -- Latest Notifications reconnect/test-push build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T16-38-28-081Z_pid61834_55d1f492.log` -- Codex Computer Use verification after Notifications fixes: Simulator paired to the throwaway runtime, local app-group prefs showed `{ perSessionOverrides: {}, keys: [] }`, host device metadata had no `notificationPreferences.perSessionOverrides`, `Send test push` displayed an ADE notification banner, and the screen showed `Test notification delivered in app. APNs is not wired in this runtime.` -- Latest Notifications focused iOS tests: `mcp__xcodebuildmcp__.test_sim({ progress: false, extraArgs: ["-quiet", "-only-testing:ADETests/ADETests/testNotificationPreferencesSavePrunesInactivePerSessionOverrides", "-only-testing:ADETests/ADETests/testSyncNotificationPrefsPayloadOmitsInactivePerSessionOverrides", "-only-testing:ADETests/ADETests/testNotificationStaleOverrideIdsKeepSavedOverridesVisible", "-only-testing:ADETests/ADETests/testSyncConnectPortCandidatesDoNotScanBonjourHostnameFallbackWindow", "-only-testing:ADETests/ADETests/testSyncDiscoveredHostIgnoresTimestampOnlyRefreshForPublishedList", "-only-testing:ADETests/ADETests/testSyncServiceProjectHomeDeduplicatesCachedRowsByRootAndKeepsActive", "-only-testing:ADETests/ADETests/testSyncServiceAdoptsRemoteProjectIdWhenStaleCachedDuplicateStillExists"] })` — `7 passed`, `0 failed`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T16-42-14-625Z_pid61834_e76b5f10.log` -- Latest full iOS pass after Notifications fixes: `mcp__xcodebuildmcp__.test_sim({ progress: true, extraArgs: ["-quiet"] })` — `281 passed`, `0 failed`, `0 skipped`; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/test_sim_2026-05-18T16-43-12-001Z_pid61834_3eb019da.log` - Latest Settings flicker/appearance build: `mcp__xcodebuildmcp__.build_run_sim({})` — succeeded; log `/Users/admin/Library/Developer/XcodeBuildMCP/workspaces/huge-ade-mobile-pass-831383b3-bfafd19e206f/logs/build_run_sim_2026-05-18T16-56-34-652Z_pid61834_96e27adc.log` - Codex Computer Use verification after Settings flicker/appearance fix: Simulator Settings showed `Connected`, `Mac.lan`, `Tailscale 100.75.20.63 · :8787`, `Last sync: just now`; CUA cycled Light, Dark, and System. Screenshots `/tmp/ade-settings-post-1.png` and `/tmp/ade-settings-post-2.png` compared with `magick compare -metric AE` returned `0`. - Codex Computer Use verification for manual Tailscale pairing: With the Simulator relaunched into `Not connected`, CUA typed host `100.75.20.63`, PIN `388722`, tapped `Connect`, and Settings returned to `Connected`. Runtime status showed one authenticated iPhone peer with `remoteAddress: "100.75.20.63"`. diff --git a/index.mdx b/index.mdx index db9629270..04dbb8a14 100644 --- a/index.mdx +++ b/index.mdx @@ -39,6 +39,6 @@ ADE helps you run multiple coding agents without losing track of branches, files Capture screenshots, recordings, traces, and logs as evidence tied to chats, PRs, Linear issues, or lanes. - Pair your phone to follow work, receive push notifications, and act on updates while away from the Mac. + Pair your phone to follow work and act on updates while away from the Mac. diff --git a/key-concepts.mdx b/key-concepts.mdx index 66296942a..db21d3253 100644 --- a/key-concepts.mdx +++ b/key-concepts.mdx @@ -56,7 +56,7 @@ History is the searchable project record. It includes chats, terminal sessions, ## iOS companion -The iOS app pairs with the desktop runtime. It can mirror useful project state, receive push notifications, and let you follow work from your phone. The phone does not run agents; your Mac remains the execution host. +The iOS app pairs with the desktop runtime. It can mirror useful project state and let you follow work from your phone. The phone does not run agents; your Mac remains the execution host. ## Local project state diff --git a/plans/tui-parity-roadmap.md b/plans/tui-parity-roadmap.md index 63248e8f7..350f43870 100644 --- a/plans/tui-parity-roadmap.md +++ b/plans/tui-parity-roadmap.md @@ -2497,10 +2497,10 @@ Land the three P0 performance fixes first (coalescer → shared aggregation → ] }, { - "feature": "Integrations config (GitHub / Linear / Mobile Push / ADE CLI)", + "feature": "Integrations config (GitHub / Linear / ADE CLI)", "status": "partial", - "details": "commands.ts wires operational /linear * (55-66) and /pr * (50-54), not settings config. No enablement, prPollingIntervalSeconds, or mobile-push pairing.", - "gap": "Integration config is desktop-only; Mobile Push pairing absent.", + "details": "commands.ts wires operational /linear * (55-66) and /pr * (50-54), not settings config. No enablement or prPollingIntervalSeconds.", + "gap": "Integration config is desktop-only.", "tuiFiles": [ "apps/ade-cli/src/tuiClient/commands.ts", "apps/ade-cli/src/tuiClient/app.tsx" From bdcb188ebb256c8e17fe9f33440c535eb4d4a723 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:10:04 -0400 Subject: [PATCH 2/7] ship: iteration 1 - restore iOS intent bridge --- apps/ios/ADE/App/ADEApp.swift | 28 +++++++++++++++++++ .../ADE/Views/Work/WorkNewChatScreen.swift | 7 +++++ 2 files changed, 35 insertions(+) diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index d81cc7088..5a2e4651a 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -20,6 +20,9 @@ struct ADEApp: App { .environmentObject(syncService) .environmentObject(dictationController) .task { + await MainActor.run { + ADEIntentCommandRegistry.register(ADESyncIntentBridge.shared) + } guard !didBootstrapSync else { return } didBootstrapSync = true lastActivationSyncAt = Date() @@ -62,3 +65,28 @@ struct ADEApp: App { } } } + +// MARK: - Live Activity / Control Widget command bridge + +/// Maps the cross-target intent commands to main-app remote commands. +/// Kept in the main ADE target so widgets never link `SyncService`. +@MainActor +private final class ADESyncIntentBridge: ADEIntentCommandBridge { + static let shared = ADESyncIntentBridge() + + private init() {} + + func dispatch(_ kind: ADEIntentCommandKind, payload: [String: Any]) async { + let mapped: RemoteCommandKind + switch kind { + case .approveSession: mapped = .approveSession + case .denySession: mapped = .denySession + case .pauseSession: mapped = .pauseSession + case .replyToSession: mapped = .replyToSession + case .restartSession: mapped = .restartSession + case .retryPrChecks: mapped = .retryPrChecks + case .openPr: mapped = .openPr + } + await SyncService.shared?.sendRemoteCommand(mapped, payload: payload) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index c976fa178..859bc9aa8 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -152,6 +152,13 @@ struct WorkNewChatScreen: View { selectedLaneId == workAutoCreateLaneSentinelId } + private var autoCreateToolsLane: LaneSummary? { + if let preferredLaneId, let lane = lanes.first(where: { $0.id == preferredLaneId }) { + return lane + } + return lanes.first + } + /// Fast mode only applies to in-app chat sessions on fast-tier models — the /// CLI launcher has no fast-mode parameter — so the lightning toggle (and the /// value we send) is gated on both. The picker's option can only *add* support From 4eeb7e068977750b38c43f3c830458be1f746ae0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:56:58 -0400 Subject: [PATCH 3/7] ship: iteration 2 - address review feedback --- .../components/app/settingsSections.ts | 39 ------------------- apps/ios/ADE/App/ADEApp.swift | 9 +++-- .../Shared/LiveActivityIntentsForward.swift | 1 + .../ADELiveActivityPrimitives.swift | 2 +- 4 files changed, 8 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/settingsSections.ts b/apps/desktop/src/renderer/components/app/settingsSections.ts index e0037ba5a..0e853c6be 100644 --- a/apps/desktop/src/renderer/components/app/settingsSections.ts +++ b/apps/desktop/src/renderer/components/app/settingsSections.ts @@ -23,47 +23,8 @@ export type SectionId = SettingsSection["id"]; export const DEFAULT_SETTINGS_SECTION: SectionId = "general"; -const TAB_ALIASES: Record = { - project: "workspace", - context: "workspace", - providers: "ai", - sync: "workspace", - devices: "workspace", - "multi-device": "workspace", - github: "integrations", - linear: "integrations", - proof: "integrations", - keybindings: "general", - onboarding: "general", - help: "general", - tours: "general", - usage: "ade-usage", - stats: "ade-usage", -}; - export function getVisibleSettingsSections(showLocalOnlySections: boolean): SettingsSection[] { return SETTINGS_SECTIONS.filter( (section) => showLocalOnlySections || !("localOnly" in section && section.localOnly), ); } - -export function resolveSettingsSectionFromTab( - tabParam: string | null, - showLocalOnlySections: boolean, -): SectionId | null { - if (!tabParam) return null; - - const visibleSections = getVisibleSettingsSections(showLocalOnlySections); - const visibleIds = new Set(visibleSections.map((section) => section.id)); - - if (visibleIds.has(tabParam)) { - return tabParam as SectionId; - } - - const alias = TAB_ALIASES[tabParam]; - if (alias && visibleIds.has(alias)) { - return alias; - } - - return null; -} diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 5a2e4651a..fd09e269d 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -14,15 +14,17 @@ struct ADEApp: App { /// Driven by `.adeSendToMacRequested` notifications from `DeepLinkRouter`. @State private var sendToMacTarget: SendToMacTarget? + @MainActor + init() { + ADEIntentCommandRegistry.register(ADESyncIntentBridge.shared) + } + var body: some Scene { WindowGroup { ContentView() .environmentObject(syncService) .environmentObject(dictationController) .task { - await MainActor.run { - ADEIntentCommandRegistry.register(ADESyncIntentBridge.shared) - } guard !didBootstrapSync else { return } didBootstrapSync = true lastActivationSyncAt = Date() @@ -86,6 +88,7 @@ private final class ADESyncIntentBridge: ADEIntentCommandBridge { case .restartSession: mapped = .restartSession case .retryPrChecks: mapped = .retryPrChecks case .openPr: mapped = .openPr + case .openDeeplink: mapped = .openDeeplink } await SyncService.shared?.sendRemoteCommand(mapped, payload: payload) } diff --git a/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift b/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift index be1b7bcbd..2a598cb8d 100644 --- a/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift +++ b/apps/ios/ADE/Shared/LiveActivityIntentsForward.swift @@ -28,6 +28,7 @@ public enum ADEIntentCommandKind: String, Sendable { case replyToSession case retryPrChecks case openPr + case openDeeplink /// Restart a failed session from the Live Activity "Failed" action row. case restartSession } diff --git a/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift b/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift index 32e1e8337..04c9acc9e 100644 --- a/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift +++ b/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift @@ -509,7 +509,7 @@ public struct OpenADEDeepLinkIntent: AppIntent { @MainActor public func perform() async throws -> some IntentResult { await ADEIntentCommandRegistry.dispatch( - .openPr, + .openDeeplink, payload: ["url": urlString] ) return .result() From b4568478eba683e8e752ecc5e19136f5285898af Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:15:13 -0400 Subject: [PATCH 4/7] ship: iteration 3 - route widget PR actions --- .../ADELiveActivityPrimitives.swift | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift b/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift index 04c9acc9e..a205da245 100644 --- a/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift +++ b/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift @@ -472,11 +472,11 @@ public struct AttentionActionRow: View { return OpenADEDeepLinkIntent(urlString: "ade://session/\(sessionId)") } - private func openPrIntent(_ prNumber: Int?) -> OpenADEDeepLinkIntent { + private func openPrIntent(_ prNumber: Int?) -> OpenADEPrIntent { guard let prNumber, prNumber > 0 else { - return OpenADEDeepLinkIntent(urlString: "ade://workspace") + return OpenADEPrIntent(prNumber: 0) } - return OpenADEDeepLinkIntent(urlString: "ade://pr/\(prNumber)") + return OpenADEPrIntent(prNumber: prNumber) } private func prLabel(_ verb: String, _ number: Int?) -> String { @@ -487,6 +487,32 @@ public struct AttentionActionRow: View { } } +@available(iOS 17.0, *) +public struct OpenADEPrIntent: AppIntent { + public static var title: LocalizedStringResource = "Open PR" + public static var description = IntentDescription("Open the linked ADE pull request.") + public static var openAppWhenRun: Bool = true + + @Parameter(title: "PR Number") + public var prNumber: Int + + public init() {} + + public init(prNumber: Int) { + self.prNumber = prNumber + } + + @MainActor + public func perform() async throws -> some IntentResult { + guard prNumber > 0 else { return .result() } + await ADEIntentCommandRegistry.dispatch( + .openPr, + payload: ["prNumber": prNumber] + ) + return .result() + } +} + /// iOS-17 compatible fallback for `OpenURLIntent` (which is iOS 18+). Sets /// `openAppWhenRun = true` and forwards the URL via the existing /// `ADEIntentCommandRegistry` bridge so the main app's deep-link router can @@ -508,6 +534,7 @@ public struct OpenADEDeepLinkIntent: AppIntent { @MainActor public func perform() async throws -> some IntentResult { + guard urlString != "ade://workspace" else { return .result() } await ADEIntentCommandRegistry.dispatch( .openDeeplink, payload: ["url": urlString] From 01f829a2c5f6984236acbc637e53d3a8e5cf08f0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:29:57 -0400 Subject: [PATCH 5/7] ship: iteration 4 - pass widget PR ids --- .../ADELiveActivityPrimitives.swift | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift b/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift index a205da245..fb23523c5 100644 --- a/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift +++ b/apps/ios/ADEWidgets/ADELiveActivityPrimitives.swift @@ -428,7 +428,7 @@ public struct AttentionActionRow: View { label: prLabel("Open", attention.prNumber), systemImage: compact ? nil : "arrow.triangle.branch", variant: .primary(tint: ADESharedTheme.brandCursor), - intent: openPrIntent(attention.prNumber) + intent: openPrIntent(prNumber: attention.prNumber, prId: attention.prId) ) ActionPill( label: "Rerun CI", @@ -444,20 +444,20 @@ public struct AttentionActionRow: View { label: prLabel("Review", attention.prNumber), systemImage: compact ? nil : "eye", variant: .primary(tint: ADESharedTheme.brandCursor), - intent: openPrIntent(attention.prNumber) + intent: openPrIntent(prNumber: attention.prNumber, prId: attention.prId) ) case .mergeReady: ActionPill( label: prLabel("Merge", attention.prNumber), systemImage: compact ? nil : "checkmark.seal", variant: .primary(tint: ADESharedTheme.statusSuccess), - intent: openPrIntent(attention.prNumber) + intent: openPrIntent(prNumber: attention.prNumber, prId: attention.prId) ) ActionPill( label: "View", systemImage: compact ? nil : "arrow.right", variant: .secondary, - intent: openPrIntent(attention.prNumber) + intent: openPrIntent(prNumber: attention.prNumber, prId: attention.prId) ) } } @@ -472,11 +472,11 @@ public struct AttentionActionRow: View { return OpenADEDeepLinkIntent(urlString: "ade://session/\(sessionId)") } - private func openPrIntent(_ prNumber: Int?) -> OpenADEPrIntent { + private func openPrIntent(prNumber: Int?, prId: String?) -> OpenADEPrIntent { guard let prNumber, prNumber > 0 else { - return OpenADEPrIntent(prNumber: 0) + return OpenADEPrIntent(prNumber: 0, prId: "") } - return OpenADEPrIntent(prNumber: prNumber) + return OpenADEPrIntent(prNumber: prNumber, prId: prId ?? "") } private func prLabel(_ verb: String, _ number: Int?) -> String { @@ -496,18 +496,23 @@ public struct OpenADEPrIntent: AppIntent { @Parameter(title: "PR Number") public var prNumber: Int + @Parameter(title: "PR ID", default: "") + public var prId: String + public init() {} - public init(prNumber: Int) { + public init(prNumber: Int, prId: String = "") { self.prNumber = prNumber + self.prId = prId } @MainActor public func perform() async throws -> some IntentResult { - guard prNumber > 0 else { return .result() } + let trimmedPrId = prId.trimmingCharacters(in: .whitespacesAndNewlines) + guard prNumber > 0, !trimmedPrId.isEmpty else { return .result() } await ADEIntentCommandRegistry.dispatch( .openPr, - payload: ["prNumber": prNumber] + payload: ["prNumber": prNumber, "prId": trimmedPrId] ) return .result() } From 817da405d5ec912bb38412bbe3d5f39fe97e86e5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:40:32 -0400 Subject: [PATCH 6/7] ship: iteration 5 - forward widget PR ids --- apps/ios/ADE/Services/SyncService.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b31d8ade4..a1e1bb223 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -8957,6 +8957,11 @@ extension SyncService { args["prNumber"] = pr } case .openPr: + if let prId = (payload["prId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !prId.isEmpty { + args["prId"] = prId + } if let prNumber = payload["prNumber"] { args["prNumber"] = prNumber } From 22c5d47939d549a025f08b797e287f25e7d77974 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:53:42 -0400 Subject: [PATCH 7/7] ship: iteration 6 - navigate PR intents locally --- apps/ios/ADE/App/ADEApp.swift | 32 ++++++++++++++++++++++++++++++++ apps/ios/ADETests/ADETests.swift | 19 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index fd09e269d..e6a9a56dc 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -79,6 +79,10 @@ private final class ADESyncIntentBridge: ADEIntentCommandBridge { private init() {} func dispatch(_ kind: ADEIntentCommandKind, payload: [String: Any]) async { + if kind == .openPr { + requestPrNavigationFromIntentPayload(payload) + } + let mapped: RemoteCommandKind switch kind { case .approveSession: mapped = .approveSession @@ -93,3 +97,31 @@ private final class ADESyncIntentBridge: ADEIntentCommandBridge { await SyncService.shared?.sendRemoteCommand(mapped, payload: payload) } } + +@MainActor +func requestPrNavigationFromIntentPayload(_ payload: [String: Any]) { + let prId = (payload["prId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let prNumber = intentPayloadPrNumber(payload["prNumber"]) + + if let prId, !prId.isEmpty { + SyncService.shared?.requestedPrNavigation = PrNavigationRequest(prId: prId, prNumber: prNumber) + } else if let prNumber { + SyncService.shared?.requestedPrNavigation = PrNavigationRequest(prNumber: prNumber) + } +} + +private func intentPayloadPrNumber(_ rawValue: Any?) -> Int? { + if let value = rawValue as? Int, value > 0 { + return value + } + if let value = rawValue as? NSNumber, value.intValue > 0 { + return value.intValue + } + if let string = rawValue as? String, + let value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)), + value > 0 { + return value + } + return nil +} diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index a70acb81d..5816c49db 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -205,6 +205,25 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.requestedPrNavigation?.prId, "github-pr-number:9876") } + @MainActor + func testPrIntentPayloadRequestsLocalPrNavigation() throws { + let previousShared = SyncService.shared + defer { SyncService.shared = previousShared } + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + SyncService.shared = service + + requestPrNavigationFromIntentPayload([ + "prId": "pr_123", + "prNumber": "42", + ]) + + XCTAssertEqual(service.requestedPrNavigation?.prId, "pr_123") + XCTAssertEqual(service.requestedPrNavigation?.prNumber, 42) + } + @MainActor func testDeepLinkRouterSendsHttpsAdePrLinksToMac() throws { let expected = "https://ade-app.dev/open?type=pr&repo=arul/ADE&number=42"