diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index 8af6cf0..290f12e 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -8,12 +8,38 @@ /* Begin PBXBuildFile section */ BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; }; + BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + CP00000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = PJ00000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = TG00000000000000000002; + remoteInfo = NucleicRemoteWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + BP00000000000000000005 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = ""; }; FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FR00000000000000000002 /* NucleicRemoteWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NucleicRemoteWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -24,6 +50,13 @@ ); target = TG00000000000000000001 /* NucleicRemote */; }; + EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = TG00000000000000000002 /* NucleicRemoteWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -35,6 +68,19 @@ path = NucleicRemote; sourceTree = ""; }; + SG00000000000000000002 /* NucleicRemoteWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */, + ); + path = NucleicRemoteWidgets; + sourceTree = ""; + }; + SG00000000000000000003 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +92,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BP00000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -53,6 +106,8 @@ isa = PBXGroup; children = ( SG00000000000000000001 /* NucleicRemote */, + SG00000000000000000002 /* NucleicRemoteWidgets */, + SG00000000000000000003 /* Shared */, GR00000000000000000002 /* Products */, E7D43FF02FF0806800BF2407 /* app-logo-file.icon */, ); @@ -62,6 +117,7 @@ isa = PBXGroup; children = ( FR00000000000000000001 /* NucleicRemote.app */, + FR00000000000000000002 /* NucleicRemoteWidgets.appex */, ); name = Products; sourceTree = ""; @@ -76,13 +132,16 @@ BP00000000000000000002 /* Sources */, BP00000000000000000003 /* Frameworks */, BP00000000000000000004 /* Resources */, + BP00000000000000000005 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + TD00000000000000000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( SG00000000000000000001 /* NucleicRemote */, + SG00000000000000000003 /* Shared */, ); name = NucleicRemote; packageProductDependencies = ( @@ -92,8 +151,37 @@ productReference = FR00000000000000000001 /* NucleicRemote.app */; productType = "com.apple.product-type.application"; }; + TG00000000000000000002 /* NucleicRemoteWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */; + buildPhases = ( + BP00000000000000000006 /* Sources */, + BP00000000000000000007 /* Frameworks */, + BP00000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + SG00000000000000000002 /* NucleicRemoteWidgets */, + SG00000000000000000003 /* Shared */, + ); + name = NucleicRemoteWidgets; + productName = NucleicRemoteWidgets; + productReference = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ +/* Begin PBXTargetDependency section */ + TD00000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = TG00000000000000000002 /* NucleicRemoteWidgets */; + targetProxy = CP00000000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXProject section */ PJ00000000000000000001 /* Project object */ = { isa = PBXProject; @@ -105,6 +193,9 @@ TG00000000000000000001 = { CreatedOnToolsVersion = 27.0; }; + TG00000000000000000002 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */; @@ -125,6 +216,7 @@ projectRoot = ""; targets = ( TG00000000000000000001 /* NucleicRemote */, + TG00000000000000000002 /* NucleicRemoteWidgets */, ); }; /* End PBXProject section */ @@ -138,6 +230,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BP00000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -148,6 +247,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BP00000000000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -256,6 +362,66 @@ }; name = Release; }; + BC00000000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L7UDTQ6F5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + BC00000000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L7UDTQ6F5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -277,6 +443,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BC00000000000000000005 /* Debug */, + BC00000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 119a3e9..2fa3083 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -8,5 +8,7 @@ NucleicChannel $(NUCLEIC_CHANNEL) + NSSupportsLiveActivities + diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift new file mode 100644 index 0000000..55d18b7 --- /dev/null +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -0,0 +1,62 @@ +import ActivityKit +import Foundation +import NucleicProtocol + +/// Owns the one aggregate session Live Activity (UX_IOS §5.3): started when work exists, +/// updated as sessions change, ended when everything is idle or the device unpairs. State +/// flows in from `RemoteStore` on every session-list change; the widget extension renders it +/// (`SessionLiveActivity`). +@MainActor +final class LiveActivityManager { + static let shared = LiveActivityManager() + private init() {} + + private var activity: Activity? + + /// Reconcile the Activity with the current session set. + func sync(hostName: String, sessions: [WireSessionSummary]) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let live = sessions.filter { !$0.archived } + let running = live.filter { $0.status == .running || $0.status == .provisioning } + let needsYou = live.filter { $0.status.needsYou($0.disposition) } + + guard !running.isEmpty || !needsYou.isEmpty else { + end() + return + } + + // The most urgent session headlines: the top waiter, else the freshest runner. + let top = needsYou.max { $0.updatedAt < $1.updatedAt } + ?? running.max { $0.updatedAt < $1.updatedAt } + let state = NucleicSessionAttributes.ContentState( + runningCount: running.count, + needsYouCount: needsYou.count, + topTitle: top?.title, + topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false) + + if let activity { + Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } + } else { + // Recover an Activity that survived an app relaunch before starting a new one. + if let existing = Activity.activities.first { + activity = existing + Task { await existing.update(ActivityContent(state: state, staleDate: nil)) } + return + } + activity = try? Activity.request( + attributes: NucleicSessionAttributes(hostName: hostName), + content: ActivityContent(state: state, staleDate: nil)) + } + } + + /// End the Activity (all idle, or unpaired). + func end() { + guard let activity else { return } + self.activity = nil + Task { + await activity.end( + ActivityContent(state: activity.content.state, staleDate: nil), + dismissalPolicy: .immediate) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings new file mode 100644 index 0000000..a690f5a --- /dev/null +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -0,0 +1,5 @@ +/* The relay's content-free APNS wake (cloud/nucleic-edge apns.ts `approvalPayload`): + the push deliberately carries no approval detail (the relay can't read it — E2EE), so + this localized alert is all the lock screen shows. The app pulls the real approval + over the encrypted channel on open. */ +"approval.pending" = "A session is waiting for your approval"; diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 0496521..094ed7e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -69,6 +69,43 @@ final class RemoteStore: ObservableObject { /// doesn't carry the host's flag, so the phone tracks its own view locally). @Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened() + /// A navigation request from outside the view hierarchy (notification tap → this session). + /// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears. + @Published var pendingRoute: SessionID? + + /// Whether the app is foreground-active (scene phase), mirrored here so the store can + /// decide which transitions deserve a notification. + private(set) var isActive = true + + func setScenePhaseActive(_ active: Bool) { isActive = active } + + /// Route to a session from a notification tap (deep link). + func route(to sessionID: SessionID) { pendingRoute = sessionID } + + /// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live + /// channel; if the socket dropped while backgrounded, reconnect and send once ready — + /// but only briefly (a stale queued approval must never fire minutes later; see + /// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway). + func respondFromNotification(_ id: ApprovalID, allow: Bool) { + let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil) + if connectivity.isLive { + send(.approvalRespond(id, decision)) + } else { + pendingNotificationDecision = (id, decision, Date()) + reconnect() + } + } + + /// At most one decision waits for reconnect, and it expires after 30s. + private var pendingNotificationDecision: (ApprovalID, Decision, Date)? + + private func flushPendingNotificationDecision() { + guard let (id, decision, at) = pendingNotificationDecision else { return } + pendingNotificationDecision = nil + guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app + send(.approvalRespond(id, decision)) + } + private static let lastOpenedKey = "nucleic.lastOpenedAt" private static func loadLastOpened() -> [SessionID: Date] { guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] } @@ -205,6 +242,7 @@ final class RemoteStore: ObservableObject { ]), WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []), ]) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) } /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host. @@ -275,6 +313,8 @@ final class RemoteStore: ObservableObject { IdentityStore.clearPairedHost() connectivity = .unpaired sessions = [] + LiveActivityManager.shared.end() + NotificationRouter.shared.updateBadge(0) } // MARK: - Intents (UX_IOS §9) @@ -450,11 +490,29 @@ final class RemoteStore: ObservableObject { send(.listSessions) send(.listDashboard) if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) } + flushPendingNotificationDecision() case .sessionList(let list): sessions = list + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) case .sessionUpdated(let summary): - if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary } - else { sessions.append(summary) } + let previous: WireSessionSummary? + if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { + previous = sessions[i] + sessions[i] = summary + } else { + previous = nil + sessions.append(summary) + } + // Notify on the transition into "waiting on you" / "finished" — only when the + // app isn't foreground-active (in-app, the list's washes and badges carry it). + let becameWaiting = summary.status == .awaitingInput + && previous?.status != .awaitingInput + if becameWaiting, !isActive, !summary.archived { + NotificationRouter.shared.postSessionUpdate(summary) + } + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) case .dashboard(let snapshot): dashboard = snapshot case .snapshot(let snapshot): @@ -472,8 +530,13 @@ final class RemoteStore: ObservableObject { if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) { openApprovals.append(req) } + // Always post; the router suppresses the banner when the user is already + // looking at this session, and resolution (any device) withdraws it. + let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval" + NotificationRouter.shared.postApproval(req, sessionTitle: title) case .approvalResolved(let resolved): openApprovals.removeAll { $0.id == resolved.id } + NotificationRouter.shared.withdrawApproval(resolved.id) case .sessionDiff(let diff): guard diff.sessionID == openSessionID else { break } openDiff = diff diff --git a/NucleicRemote/NucleicRemote/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift new file mode 100644 index 0000000..f79d70f --- /dev/null +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -0,0 +1,145 @@ +import Foundation +import UserNotifications +import UIKit +import NucleicProtocol + +/// The phone's notification pipeline (UX_IOS §5.1): local notifications for approvals and +/// needs-input transitions while the app is backgrounded, actionable Allow/Deny for low-risk +/// approvals, deep-link routing on tap, and the app-icon badge (= NEEDS YOU count). +/// +/// Two arrival paths converge here: +/// - **Local** (LAN): the app is backgrounded but its socket is briefly alive; `RemoteStore` +/// posts a rich local notification (content composed on-device — nothing rides APNs). +/// - **Remote** (relay, M5): a content-free `approval.pending` tickle wakes the phone; the +/// alert text comes from Localizable.strings and the app pulls the real approval over the +/// encrypted channel on open. +@MainActor +final class NotificationRouter: NSObject { + static let shared = NotificationRouter() + + /// The store notifications act on; set once at app start. + weak var store: RemoteStore? + + // Category / action identifiers (also referenced from the notification service side). + static let approvalCategory = "NUCLEIC_APPROVAL" + static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE" + static let inputCategory = "NUCLEIC_INPUT" + static let allowAction = "NUCLEIC_ALLOW" + static let denyAction = "NUCLEIC_DENY" + + /// Install the delegate + categories. Call once at launch (before any notification can + /// arrive, so actions are always registered). + func install(store: RemoteStore) { + self.store = store + let center = UNUserNotificationCenter.current() + center.delegate = self + + // Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1); + // Allow requires device auth so a pocket-tap can't grant. High-risk approvals use + // the action-less category — they must open the app (Face ID gate on the card). + let allow = UNNotificationAction( + identifier: Self.allowAction, title: "Allow", + options: [.authenticationRequired]) + let deny = UNNotificationAction( + identifier: Self.denyAction, title: "Deny", + options: [.destructive]) + let actionable = UNNotificationCategory( + identifier: Self.approvalActionableCategory, + actions: [deny, allow], intentIdentifiers: []) + let plain = UNNotificationCategory( + identifier: Self.approvalCategory, actions: [], intentIdentifiers: []) + let input = UNNotificationCategory( + identifier: Self.inputCategory, actions: [], intentIdentifiers: []) + center.setNotificationCategories([actionable, plain, input]) + } + + // MARK: - Posting (local path, composed on-device) + + /// Post a local notification for a pending approval. Rich because it never leaves the + /// device; the remote tickle stays content-free. + func postApproval(_ approval: ApprovalRequest, sessionTitle: String) { + let content = UNMutableNotificationContent() + content.title = sessionTitle + content.body = "\(approval.toolName) wants: \(approval.title)" + content.sound = .default + content.categoryIdentifier = approval.risk.isHigh + ? Self.approvalCategory : Self.approvalActionableCategory + content.userInfo = [ + "sessionID": approval.sessionID.rawValue, + "approvalID": approval.id.rawValue, + ] + // One notification per approval; a re-post for the same id replaces, and resolution + // (any device) withdraws it. + let request = UNNotificationRequest( + identifier: "approval-\(approval.id.rawValue)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + /// Post a "session needs you / finished" transition notification. + func postSessionUpdate(_ summary: WireSessionSummary) { + let content = UNMutableNotificationContent() + content.title = summary.title + content.body = summary.status == .awaitingInput && summary.disposition == .completed + ? "Finished its work." : "Waiting for your next prompt." + content.sound = .default + content.categoryIdentifier = Self.inputCategory + content.userInfo = ["sessionID": summary.sessionID.rawValue] + let request = UNNotificationRequest( + identifier: "input-\(summary.sessionID.rawValue)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + /// Withdraw an approval's notification once it's resolved (first-responder-wins — the + /// Mac may have answered). + func withdrawApproval(_ id: ApprovalID) { + let identifier = "approval-\(id.rawValue)" + let center = UNUserNotificationCenter.current() + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + } + + /// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8). + func updateBadge(_ count: Int) { + UNUserNotificationCenter.current().setBadgeCount(count) + } +} + +extension NotificationRouter: UNUserNotificationCenterDelegate { + /// Foreground arrivals: show the banner unless the user is already looking at that + /// session (the approval card is louder than a banner). + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + let sessionID = notification.request.content.userInfo["sessionID"] as? String + let suppress = await MainActor.run { + sessionID != nil && store?.openSessionID?.rawValue == sessionID + } + return suppress ? [] : [.banner, .sound, .badge] + } + + /// Taps and actions. A tap routes to the session; Allow/Deny resolve the approval over + /// a live channel (reconnecting first if the socket dropped). + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let info = response.notification.request.content.userInfo + let sessionID = (info["sessionID"] as? String).map(SessionID.init(rawValue:)) + let approvalID = (info["approvalID"] as? String).map(ApprovalID.init(rawValue:)) + let action = response.actionIdentifier + + await MainActor.run { [weak self] in + guard let store = self?.store else { return } + switch action { + case Self.allowAction: + if let approvalID { store.respondFromNotification(approvalID, allow: true) } + case Self.denyAction: + if let approvalID { store.respondFromNotification(approvalID, allow: false) } + default: + // Plain tap (or a long-press open): route to the session. + if let sessionID { store.route(to: sessionID) } + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index fbed750..647d985 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -12,6 +12,9 @@ struct NucleicRemoteApp: App { .environmentObject(store) .onAppear { store.onAppear() + // Notification categories + delegate must be in place before any + // notification can arrive, so actions/taps always route. + NotificationRouter.shared.install(store: store) // Surface the notifications prompt + register for APNS. The token rides along // in the sync Hello; the relay uses it to wake the phone for approvals (§3). // Skipped in demo mode so offline UI previews aren't blocked by the system dialog. @@ -21,8 +24,10 @@ struct NucleicRemoteApp: App { // Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4). Task { await HeartbeatReporter.reportIfDue() } } - // Re-check on every foreground so a new active day is counted. + // Re-check on every foreground so a new active day is counted; keep the store's + // activity flag current so it only notifies for background transitions. .onChange(of: scenePhase) { _, phase in + store.setScenePhaseActive(phase == .active) if phase == .active { Task { await HeartbeatReporter.reportIfDue() } } } } @@ -64,6 +69,11 @@ struct RootView: View { } } .overlay(alignment: .bottom) { ErrorBubble() } + // A notification tap routes to its session: jump to the Sessions tab, where + // `SessionsView` consumes `pendingRoute` and pushes the detail. + .onChange(of: store.pendingRoute) { _, route in + if route != nil { tab = 1 } + } .tint(Palette.accent) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 8f7dd67..277639e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -74,7 +74,10 @@ struct SessionsView: View { !raw.isEmpty, path.isEmpty { path.append(SessionID(rawValue: raw)) } + consumeRoute() } + // A notification tap while this tab is already up. + .onChange(of: store.pendingRoute) { consumeRoute() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -87,6 +90,13 @@ struct SessionsView: View { .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } + + /// Push the session a notification tap asked for, then clear the request. + private func consumeRoute() { + guard let route = store.pendingRoute else { return } + store.pendingRoute = nil + path.append(route) + } } struct SessionRow: View { diff --git a/NucleicRemote/NucleicRemoteWidgets/Info.plist b/NucleicRemote/NucleicRemoteWidgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/NucleicRemote/NucleicRemoteWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift b/NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift new file mode 100644 index 0000000..dee913a --- /dev/null +++ b/NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct NucleicRemoteWidgets: WidgetBundle { + var body: some Widget { + SessionLiveActivity() + } +} diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift new file mode 100644 index 0000000..1abe857 --- /dev/null +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -0,0 +1,126 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3): +/// glance = running count + needs-you count + the most urgent session; tap opens the app +/// (straight to the approval, via the notification-style route). Self-contained colors — the +/// extension doesn't link the app's Theme, so the two palette constants it needs live here +/// (the same teal/amber/blue as `Palette`). +struct SessionLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: NucleicSessionAttributes.self) { context in + // Lock screen banner. + LockScreenView(state: context.state, hostName: context.attributes.hostName) + .activityBackgroundTint(Color.black.opacity(0.55)) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled") + .font(.headline) + .foregroundStyle(ActivityPalette.active) + } + DynamicIslandExpandedRegion(.trailing) { + if context.state.needsYouCount > 0 { + Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(ActivityPalette.attention) + } else { + Label("0", systemImage: "checkmark.circle") + .font(.headline) + .foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + if let title = context.state.topTitle { + HStack(spacing: 6) { + Image(systemName: context.state.topNeedsYou + ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") + .foregroundStyle(context.state.topNeedsYou + ? ActivityPalette.attention : ActivityPalette.active) + Text(title).font(.subheadline.weight(.medium)).lineLimit(1) + Spacer() + Text(statusLine(context.state)) + .font(.caption).foregroundStyle(.secondary) + } + } else { + Text(statusLine(context.state)) + .font(.caption).foregroundStyle(.secondary) + } + } + } compactLeading: { + Image(systemName: "circle.lefthalf.filled") + .foregroundStyle(ActivityPalette.active) + } compactTrailing: { + if context.state.needsYouCount > 0 { + Text("\(context.state.needsYouCount)") + .font(.caption2.bold()) + .foregroundStyle(ActivityPalette.attention) + } else { + Text("\(context.state.runningCount)") + .font(.caption2.bold()) + .foregroundStyle(ActivityPalette.active) + } + } minimal: { + Image(systemName: context.state.needsYouCount > 0 + ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") + .foregroundStyle(context.state.needsYouCount > 0 + ? ActivityPalette.attention : ActivityPalette.active) + } + } + } + + private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String { + var parts: [String] = [] + if state.runningCount > 0 { parts.append("\(state.runningCount) running") } + if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") } + return parts.isEmpty ? "Idle" : parts.joined(separator: " · ") + } +} + +private struct LockScreenView: View { + let state: NucleicSessionAttributes.ContentState + let hostName: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: state.needsYouCount > 0 + ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") + .font(.title2) + .foregroundStyle(state.needsYouCount > 0 + ? ActivityPalette.attention : ActivityPalette.active) + VStack(alignment: .leading, spacing: 2) { + if let title = state.topTitle { + Text(title).font(.headline).lineLimit(1) + } + HStack(spacing: 4) { + if state.runningCount > 0 { + Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active) + } + if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") } + if state.needsYouCount > 0 { + Text("\(state.needsYouCount) waiting for you") + .foregroundStyle(ActivityPalette.attention) + } + if state.runningCount == 0 && state.needsYouCount == 0 { + Text("Idle").foregroundStyle(.secondary) + } + } + .font(.subheadline) + } + Spacer() + Text(hostName) + .font(.caption2).foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(14) + .foregroundStyle(.white) + } +} + +/// The two categorical colors the Activity needs — same constants as the app's `Palette`. +private enum ActivityPalette { + static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue + static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber +} diff --git a/NucleicRemote/Shared/SessionActivityAttributes.swift b/NucleicRemote/Shared/SessionActivityAttributes.swift new file mode 100644 index 0000000..467eed3 --- /dev/null +++ b/NucleicRemote/Shared/SessionActivityAttributes.swift @@ -0,0 +1,22 @@ +import ActivityKit +import Foundation + +/// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity +/// respects iOS's budget better than one per session): "N running, M waiting" plus the most +/// urgent session's title. Shared between the app (which starts/updates it) and the widget +/// extension (which renders it). +struct NucleicSessionAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + /// Sessions actively working a turn. + var runningCount: Int + /// Sessions blocked on the user (approval or next prompt) — the loud number. + var needsYouCount: Int + /// The most urgent session's title: the top waiting one, else the busiest running. + var topTitle: String? + /// Whether `topTitle` is waiting on the user (amber) vs just running (blue). + var topNeedsYou: Bool + } + + /// The paired Mac's name (static for the Activity's lifetime). + var hostName: String +}