nvrsion: Add functionality to handle Launch Activity Logic in Sync, PushRelayClient, SyncHost, and related iOS/Cloud files.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -195,6 +195,12 @@ final class LiveActivityManager {
|
|||||||
/// bridge setup; the guards keep a single observer each.
|
/// bridge setup; the guards keep a single observer each.
|
||||||
func beginPushToStartObservation() {
|
func beginPushToStartObservation() {
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||||
|
// Grab an activity that already exists when we start observing — e.g. one a Mac push-started
|
||||||
|
// while the app was closed, which won't re-emit through `activityUpdates` for a late observer.
|
||||||
|
// This is what lets a background "adopt" wake harvest and forward its update token.
|
||||||
|
if activity == nil, let existing = Activity<NucleicSessionAttributes>.activities.first {
|
||||||
|
adopt(existing)
|
||||||
|
}
|
||||||
if pushToStartObservation == nil {
|
if pushToStartObservation == nil {
|
||||||
pushToStartObservation = Task { [weak self] in
|
pushToStartObservation = Task { [weak self] in
|
||||||
for await data in Activity<NucleicSessionAttributes>.pushToStartTokenUpdates {
|
for await data in Activity<NucleicSessionAttributes>.pushToStartTokenUpdates {
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
var canControl: Bool { grantedScope >= .control }
|
var canControl: Bool { grantedScope >= .control }
|
||||||
|
|
||||||
|
/// The process-wide store. The SwiftUI `App` binds its `@StateObject` to this instance so the
|
||||||
|
/// UI and any headless entry point share one store — in particular the background push handler
|
||||||
|
/// (`PushAppDelegate`), which on a silent launch has no mounted scene (`onAppear` never fires)
|
||||||
|
/// and so must reach the store directly to bring it online and adopt a push-started Live Activity.
|
||||||
|
static let shared = RemoteStore()
|
||||||
|
|
||||||
private let identity = IdentityStore.loadOrCreateIdentity()
|
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||||
let discovery = LANDiscovery()
|
let discovery = LANDiscovery()
|
||||||
/// Proactive network-path awareness (shared across every host connection): flips transports the
|
/// Proactive network-path awareness (shared across every host connection): flips transports the
|
||||||
@@ -314,11 +320,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
func onAppear() {
|
func onAppear() {
|
||||||
setupLiveActivityBridge()
|
setupLiveActivityBridge()
|
||||||
if demoMode { seedDemo(); return }
|
if demoMode { seedDemo(); return }
|
||||||
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
|
startNetworkingIfNeeded()
|
||||||
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
|
|
||||||
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
|
|
||||||
pathMonitor.start()
|
|
||||||
discovery.start()
|
|
||||||
if isPaired {
|
if isPaired {
|
||||||
// Show the saved chat history immediately, before any host connects.
|
// Show the saved chat history immediately, before any host connects.
|
||||||
if sessions.isEmpty { sessions = cachedSummaries }
|
if sessions.isEmpty { sessions = cachedSummaries }
|
||||||
@@ -326,6 +328,22 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `startNetworkingIfNeeded` has already wired up path monitoring + LAN discovery.
|
||||||
|
private var networkingStarted = false
|
||||||
|
|
||||||
|
/// Start the shared network-path monitor and LAN discovery once. Factored out of `onAppear` so
|
||||||
|
/// the background push handler — which runs when `onAppear` never fired (silent launch, no scene)
|
||||||
|
/// — can bring the same machinery up before it dials.
|
||||||
|
private func startNetworkingIfNeeded() {
|
||||||
|
guard !networkingStarted else { return }
|
||||||
|
networkingStarted = true
|
||||||
|
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
|
||||||
|
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
|
||||||
|
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
|
||||||
|
pathMonitor.start()
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
|
||||||
/// A network-path change: tell each host connection to re-plan its transport now.
|
/// A network-path change: tell each host connection to re-plan its transport now.
|
||||||
private func handlePathChange() {
|
private func handlePathChange() {
|
||||||
guard !demoMode else { return }
|
guard !demoMode else { return }
|
||||||
@@ -377,6 +395,50 @@ final class RemoteStore: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle the host's silent "adopt" wake (UX_IOS §5.3): a session started while the app was
|
||||||
|
/// closed, the host push-*started* the Live Activity, and now the phone must come online long
|
||||||
|
/// enough to harvest that activity's per-activity update token and register it — so live updates
|
||||||
|
/// (and a clean end) resume without the user ever opening the app. Called from `PushAppDelegate`
|
||||||
|
/// on a `content-available` background push, which may relaunch the app with no scene, so this
|
||||||
|
/// does the setup `onAppear` normally would. Holds a background-task assertion until the token is
|
||||||
|
/// registered with a host or the window closes, then calls `completion`. Best-effort: iOS may
|
||||||
|
/// deny background runtime (budget, force-quit), in which case the glance keeps its start-time
|
||||||
|
/// state until the app next runs.
|
||||||
|
func handleLiveActivityAdoptWake(completion: @escaping () -> Void) {
|
||||||
|
guard !demoMode, isPaired else { completion(); return }
|
||||||
|
setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already
|
||||||
|
startNetworkingIfNeeded()
|
||||||
|
#if canImport(UIKit)
|
||||||
|
var bgTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
var finished = false
|
||||||
|
let finish = {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
if bgTask != .invalid {
|
||||||
|
UIApplication.shared.endBackgroundTask(bgTask)
|
||||||
|
bgTask = .invalid
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
bgTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.liveactivity.adopt") {
|
||||||
|
finish() // the OS reclaimed the grant before we finished — report what we have
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
let finish = completion
|
||||||
|
#endif
|
||||||
|
pathMonitor.refresh()
|
||||||
|
reconnect()
|
||||||
|
// Poll until the harvested update token has reached a host (`liveActivitySentTo` fills in via
|
||||||
|
// the adopt → onPushToken → syncLiveActivityRegistration chain), or we run out of runtime.
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
for _ in 0..<50 { // ~25s at a 0.5s cadence, inside iOS's ~30s background grant
|
||||||
|
guard let self, self.liveActivitySentTo.isEmpty else { break }
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func seedDemo() {
|
private func seedDemo() {
|
||||||
connectivity = .connected(.lan)
|
connectivity = .connected(.lan)
|
||||||
hostName = "Andrew's Mac"
|
hostName = "Andrew's Mac"
|
||||||
@@ -1235,7 +1297,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
|
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
|
||||||
private var pushToStartSentTo: Set<String> = []
|
private var pushToStartSentTo: Set<String> = []
|
||||||
|
|
||||||
|
/// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt
|
||||||
|
/// wake, and installing the callbacks / observers once is enough.
|
||||||
|
private var liveActivityBridgeSetup = false
|
||||||
|
|
||||||
private func setupLiveActivityBridge() {
|
private func setupLiveActivityBridge() {
|
||||||
|
guard !liveActivityBridgeSetup else { return }
|
||||||
|
liveActivityBridgeSetup = true
|
||||||
LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
|
LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
|
||||||
guard let self, self.liveActivityReg?.token != token else { return }
|
guard let self, self.liveActivityReg?.token != token else { return }
|
||||||
self.liveActivityReg = (token, activityID)
|
self.liveActivityReg = (token, activityID)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct NucleicRemoteApp: App {
|
struct NucleicRemoteApp: App {
|
||||||
@StateObject private var store = RemoteStore()
|
// Bind to the shared instance so the background push handler (which runs with no mounted scene)
|
||||||
|
// and the UI drive the same store.
|
||||||
|
@StateObject private var store = RemoteStore.shared
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
|
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,28 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
// Best-effort: no token → the phone still works on LAN, just no remote wake.
|
// Best-effort: no token → the phone still works on LAN, just no remote wake.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Silent pushes. The relay sends a content-free `clear` (content-available) to recall the
|
/// Silent pushes (content-available). Two kinds route here:
|
||||||
/// "waiting for your approval" tickle once the approval is resolved on another device — we
|
/// - `clear`: recall the "waiting for your approval" tickle once the approval is resolved on
|
||||||
/// wake in the background just long enough to withdraw the stale notification. Other silent
|
/// another device — wake briefly to withdraw the stale notification.
|
||||||
/// pushes (e.g. the wake tickle itself, which is an alert) don't route here.
|
/// - `adopt`: a Mac push-started a Live Activity while the app was closed; come online long
|
||||||
|
/// enough to harvest and register that activity's update token so live updates + a clean end
|
||||||
|
/// resume without the user opening the app (UX_IOS §5.3). This one holds the completion handler
|
||||||
|
/// until the token is registered (or the background window closes), so the process isn't
|
||||||
|
/// suspended mid-connect.
|
||||||
|
/// Other pushes (e.g. the approval wake tickle, which is an alert) don't route here.
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||||
) {
|
) {
|
||||||
|
if userInfo["adopt"] != nil {
|
||||||
|
// The store manages its own background-task assertion for this; report .newData once it
|
||||||
|
// has brought the connection up and registered the token (or given up).
|
||||||
|
Task { @MainActor in
|
||||||
|
RemoteStore.shared.handleLiveActivityAdoptWake { completionHandler(.newData) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if userInfo["clear"] != nil {
|
if userInfo["clear"] != nil {
|
||||||
// Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a
|
// Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a
|
||||||
// quick UNUserNotificationCenter call, so we can report .noData right away.
|
// quick UNUserNotificationCenter call, so we can report .noData right away.
|
||||||
|
|||||||
Reference in New Issue
Block a user