From 934ab4e2bdb84ae25f0c87a2566d4bc195126d86 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Tue, 18 Aug 2026 10:23:04 +0530 Subject: [PATCH] feat(ios): surface native companion notifications --- docs/ios-companion.md | 6 ++-- ios/App/CompanionApp.swift | 4 ++- ios/App/Notifications.swift | 56 +++++++++++++++++++++++++++++++++++++ ios/App/Session.swift | 38 +++++++++++++++++++++++++ ios/App/SettingsView.swift | 13 +++++++++ ios/README.md | 7 +++-- ios/TESTING.md | 4 ++- 7 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 ios/App/Notifications.swift diff --git a/docs/ios-companion.md b/docs/ios-companion.md index b552d57ee2..441dea2f22 100644 --- a/docs/ios-companion.md +++ b/docs/ios-companion.md @@ -217,8 +217,10 @@ distribution scope: 2. **Desktop conversation parity:** task create/switch/rename/delete, SQLite search with exact-message landing, transcript export/share, reactions, and edit/version controls. Archived or hidden chat management remains desktop-only. -3. **Notifications:** APNs credentials, a relay or another wake-up design, - notification actions, and background reconciliation. +3. **Notifications:** native permission, live/replayed alerts, time-sensitive + approvals, badges, and background reconciliation are in the app. Closed-app + delivery still requires project-owned APNs credentials and a hosted relay; + Tailscale cannot wake a terminated iOS process. 4. **Distribution:** signing, bundle ownership, privacy declarations, TestFlight, and App Store review material. Swift tests and an unsigned simulator build already run in the repository CI. diff --git a/ios/App/CompanionApp.swift b/ios/App/CompanionApp.swift index 9e8bdd53bf..cad3588f2a 100644 --- a/ios/App/CompanionApp.swift +++ b/ios/App/CompanionApp.swift @@ -18,7 +18,9 @@ struct CompanionApp: App { .onAppear { session.connect() } .onChange(of: scenePhase) { _, phase in switch phase { - case .active: session.connect() + case .active: + session.connect() + Task { await session.refreshNotificationAuthorization() } case .background: session.disconnect() case .inactive: break @unknown default: break diff --git a/ios/App/Notifications.swift b/ios/App/Notifications.swift new file mode 100644 index 0000000000..4a6e74b6f9 --- /dev/null +++ b/ios/App/Notifications.swift @@ -0,0 +1,56 @@ +import Foundation +import UserNotifications +import CompanionCore + +/// The on-device notification surface. Delivery comes from live or replayed +/// companion frames; a future APNs relay can feed the same categories and +/// userInfo without changing the rest of the app. +final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationCoordinator() + private let center = UNUserNotificationCenter.current() + + private override init() { + super.init() + center.delegate = self + } + + func authorizationStatus() async -> UNAuthorizationStatus { + await center.notificationSettings().authorizationStatus + } + + func requestAuthorization() async -> Bool { + (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) == true + } + + func deliver(_ notification: NotificationFrame, sequence: Int?) { + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.body + content.sound = .default + content.categoryIdentifier = notification.isBlocking ? "OPENMAUS_APPROVAL" : "OPENMAUS_UPDATE" + content.threadIdentifier = notification.threadId + content.userInfo = [ + "threadId": notification.threadId, + "botId": notification.botId, + "kind": notification.kind, + ] + if notification.isBlocking { content.interruptionLevel = .timeSensitive } + + // A replay after a short disconnect must reconcile a missed alert, + // but a repeated frame must not draw it twice. + let identifier = "openmaus.\(notification.threadId).\(sequence.map(String.init) ?? notification.title)" + center.add(UNNotificationRequest(identifier: identifier, content: content, trigger: nil)) + } + + func setBadge(_ count: Int) { + center.setBadgeCount(max(0, count)) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound, .badge]) + } +} diff --git a/ios/App/Session.swift b/ios/App/Session.swift index 915415b28c..2cb8b2843d 100644 --- a/ios/App/Session.swift +++ b/ios/App/Session.swift @@ -11,6 +11,8 @@ import Foundation import OSLog import SwiftUI import CompanionCore +import UserNotifications +import UIKit /// Stream lifecycle, in Console.app and the Xcode console. A companion that /// is silently not connected looks exactly like one with nothing to say, so @@ -35,6 +37,7 @@ final class Session: ObservableObject { @Published var actionError: String? /// One exact message the next opened chat should reveal. @Published private(set) var focusedMessageId: String? + @Published private(set) var notificationAuthorization: UNAuthorizationStatus = .notDetermined private var client: CompanionClient? private var streamTask: Task? @@ -56,7 +59,9 @@ final class Session: ObservableObject { // MARK: - Pairing init() { + _ = NotificationCoordinator.shared restore() + Task { await refreshNotificationAuthorization() } } /// Rebuild the last connection at launch. @@ -128,6 +133,7 @@ final class Session: ObservableObject { connection = nil client = nil state = CompanionState() + NotificationCoordinator.shared.setBadge(0) status = .unpaired } @@ -237,6 +243,10 @@ final class Session: ObservableObject { continue } state.apply(frame) + if case let .notify(notification) = frame.frame { + NotificationCoordinator.shared.deliver(notification, sequence: frame.seq) + } + NotificationCoordinator.shared.setBadge(state.unreadCount) state.advance(to: frame.seq) } // the stream ended without an error — the harness went away @@ -270,6 +280,7 @@ final class Session: ObservableObject { let fleet = try await client.fleet(messages: 50) log.info("hydrated \(fleet.bots.count, privacy: .public) bots, \(fleet.groups.count, privacy: .public) rooms") state.hydrate(fleet) + NotificationCoordinator.shared.setBadge(state.unreadCount) } // MARK: - Actions @@ -464,6 +475,33 @@ final class Session: ObservableObject { } } + func refreshNotificationAuthorization() async { + notificationAuthorization = await NotificationCoordinator.shared.authorizationStatus() + } + + func enableNotifications() async { + if notificationAuthorization == .denied { + if let url = URL(string: UIApplication.openSettingsURLString) { + await UIApplication.shared.open(url) + } + return + } + _ = await NotificationCoordinator.shared.requestAuthorization() + await refreshNotificationAuthorization() + NotificationCoordinator.shared.setBadge(state.unreadCount) + } + + var notificationStatusText: String { + switch notificationAuthorization { + case .authorized: return "On" + case .provisional: return "Quietly on" + case .ephemeral: return "Temporarily on" + case .denied: return "Off in Settings" + case .notDetermined: return "Not enabled" + @unknown default: return "Unknown" + } + } + private func perform(quietly: Bool = false, _ body: (CompanionClient) async throws -> Void) async { guard let client else { return } do { diff --git a/ios/App/SettingsView.swift b/ios/App/SettingsView.swift index 0926d8025e..accb3836c6 100644 --- a/ios/App/SettingsView.swift +++ b/ios/App/SettingsView.swift @@ -20,6 +20,18 @@ struct SettingsView: View { LabeledContent("Connection", value: statusText) } + Section { + LabeledContent("Status", value: session.notificationStatusText) + Button(session.notificationAuthorization == .denied ? "Open iPhone Settings" : "Enable notifications") { + Task { await session.enableNotifications() } + } + .disabled(session.notificationAuthorization == .authorized) + } header: { + Text("Notifications") + } footer: { + Text("Approvals and finished work appear while OpenMausMobile is connected, including frames replayed after a short background pause. Closed-app push needs the separate APNs relay release.") + } + Section { Button("Unpair this phone", role: .destructive) { confirmingSignOut = true } } footer: { @@ -34,6 +46,7 @@ struct SettingsView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) + .task { await session.refreshNotificationAuthorization() } .confirmationDialog( "Unpair this phone?", isPresented: $confirmingSignOut, diff --git a/ios/README.md b/ios/README.md index 2430baad53..af46460800 100644 --- a/ios/README.md +++ b/ios/README.md @@ -162,8 +162,11 @@ mean losing the ability to lock it out. ## Not in this version -The app is foreground-only. There is no APNs delivery while it is closed, no -voice/call mode, and no hosted relay. Task management, SQLite transcript search, +The live connection is foreground-only. Notification frames produce native +banners, sounds, time-sensitive approval alerts, and an app badge while connected; +the resume cursor replays alerts missed during a short background pause. There is +no APNs delivery after the app is terminated, no voice/call mode, and no hosted relay. +Task management, SQLite transcript search, transcript sharing, reactions, and edit/version controls use narrow companion routes and the computer remains the source of truth. Tailscale is supported through manual MagicDNS entry; it is not a dependency and OpenMausBot does not diff --git a/ios/TESTING.md b/ios/TESTING.md index 43fcc1d459..547153daae 100644 --- a/ios/TESTING.md +++ b/ios/TESTING.md @@ -246,7 +246,9 @@ port — only the route to it is different. Not built yet, so not bugs: -- **Nothing arrives while the app is closed.** No push until APNs. +- **Nothing arrives after the app is terminated.** Live and replayed notification + frames now become native alerts and badges, but closed-app push still needs an + APNs relay with project-owned Apple credentials. - **No voice or routine management.** Tasks, SQLite transcript search/export, reactions, and edit/version switching are available from the conversation UI.