Merge branch 'dev' into canary
This commit is contained in:
@@ -49,8 +49,20 @@ final class LiveActivityManager {
|
|||||||
/// is backgrounded and its sync socket is suspended (UX_IOS §5.3).
|
/// is backgrounded and its sync socket is suspended (UX_IOS §5.3).
|
||||||
var onPushToken: ((_ token: String, _ activityID: String) -> Void)?
|
var onPushToken: ((_ token: String, _ activityID: String) -> Void)?
|
||||||
var onActivityEnded: ((_ activityID: String) -> Void)?
|
var onActivityEnded: ((_ activityID: String) -> Void)?
|
||||||
|
/// Set by `RemoteStore` to ship this device's **push-to-start** token to the paired Macs. Unlike
|
||||||
|
/// `onPushToken` (a per-activity update token that exists only once an Activity does), this token
|
||||||
|
/// is device-scoped and exists before any Activity — it's what lets a Mac *create* the glance
|
||||||
|
/// over APNs when work starts while the app is closed, so it appears without the user opening the
|
||||||
|
/// app first (iOS 17.2+, UX_IOS §5.3).
|
||||||
|
var onPushToStartToken: ((_ token: String) -> Void)?
|
||||||
/// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end.
|
/// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end.
|
||||||
private var tokenObservation: Task<Void, Never>?
|
private var tokenObservation: Task<Void, Never>?
|
||||||
|
/// Streams this device's push-to-start token (it can rotate). Device-scoped, so — unlike
|
||||||
|
/// `tokenObservation` — it lives for the whole process and is never cancelled on `end()`.
|
||||||
|
private var pushToStartObservation: Task<Void, Never>?
|
||||||
|
/// Observes Activities that appear without us creating them — i.e. ones a Mac push-started while
|
||||||
|
/// the app was closed — so we adopt them and forward their update token. Also process-lived.
|
||||||
|
private var activityAdoptionObservation: Task<Void, Never>?
|
||||||
|
|
||||||
/// Reconcile the Activity with the current session set.
|
/// Reconcile the Activity with the current session set.
|
||||||
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
||||||
@@ -177,6 +189,47 @@ final class LiveActivityManager {
|
|||||||
enqueue(state)
|
enqueue(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the process-lived observers that make push-to-start work: the device's push-to-start
|
||||||
|
/// token (forwarded to the Macs so they can create the glance over APNs) and adoption of any
|
||||||
|
/// Activity a Mac push-started while the app was closed. Idempotent — safe to call on every
|
||||||
|
/// bridge setup; the guards keep a single observer each.
|
||||||
|
func beginPushToStartObservation() {
|
||||||
|
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 {
|
||||||
|
pushToStartObservation = Task { [weak self] in
|
||||||
|
for await data in Activity<NucleicSessionAttributes>.pushToStartTokenUpdates {
|
||||||
|
let hex = data.map { String(format: "%02x", $0) }.joined()
|
||||||
|
self?.onPushToStartToken?(hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if activityAdoptionObservation == nil {
|
||||||
|
activityAdoptionObservation = Task { [weak self] in
|
||||||
|
for await activity in Activity<NucleicSessionAttributes>.activityUpdates {
|
||||||
|
self?.adopt(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopt an Activity we didn't create locally — almost always one a Mac push-started while the
|
||||||
|
/// app was closed. Taking ownership wires up its update-token stream (`observePushToken`) so the
|
||||||
|
/// Macs can keep the glance fresh the normal way, and collapses any strays to one. If we already
|
||||||
|
/// track an Activity, leave it: the local `Activity.request` path and `endStrays` already keep a
|
||||||
|
/// single glance, and re-adopting would just churn the token observation.
|
||||||
|
private func adopt(_ activity: Activity<NucleicSessionAttributes>) {
|
||||||
|
guard self.activity == nil else { return }
|
||||||
|
self.activity = activity
|
||||||
|
observePushToken(activity)
|
||||||
|
endStrays(keeping: activity.id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Forward the activity's APNS update token (and its rotations) to `RemoteStore`.
|
/// Forward the activity's APNS update token (and its rotations) to `RemoteStore`.
|
||||||
private func observePushToken(_ activity: Activity<NucleicSessionAttributes>) {
|
private func observePushToken(_ activity: Activity<NucleicSessionAttributes>) {
|
||||||
tokenObservation?.cancel()
|
tokenObservation?.cancel()
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ final class HostConnection {
|
|||||||
private var retryTask: Task<Void, Never>?
|
private var retryTask: Task<Void, Never>?
|
||||||
private var revalidateTask: Task<Void, Never>?
|
private var revalidateTask: Task<Void, Never>?
|
||||||
private var lanConnectTimeout: Task<Void, Never>?
|
private var lanConnectTimeout: Task<Void, Never>?
|
||||||
|
/// In-flight LAN-reachability probe (the tailnet/relay → LAN return leg). Nil unless a probe is
|
||||||
|
/// running; at most one at a time so repeated path-change events don't stack.
|
||||||
|
private var lanUpgradeProbe: Task<Void, Never>?
|
||||||
private var reconnectAttempts = 0
|
private var reconnectAttempts = 0
|
||||||
private var seenSeq: Set<UInt64> = []
|
private var seenSeq: Set<UInt64> = []
|
||||||
|
|
||||||
@@ -686,6 +689,72 @@ final class HostConnection {
|
|||||||
if !connectivity.isLive, pathMonitor.isSatisfied {
|
if !connectivity.isLive, pathMonitor.isSatisfied {
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
reconnect(to: host)
|
reconnect(to: host)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Live over a fallback transport (tailnet/relay) and a LAN path just reappeared (rejoined the
|
||||||
|
// Mac's Wi-Fi) → the return leg of the switch above. The tunnel survives the interface change
|
||||||
|
// on its own, so nothing else would ever re-plan back down to the direct path — probe LAN and
|
||||||
|
// swap to it if the Mac actually answers here.
|
||||||
|
maybeUpgradeToLAN()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe whether the pinned Mac is reachable over LAN right now and, if so, switch back to the
|
||||||
|
/// direct connection — the tailnet/relay → LAN return leg. Only meaningful while live over a
|
||||||
|
/// non-LAN transport with a LAN-capable path available; a bare TCP connect to the pinned LAN
|
||||||
|
/// endpoint that reaches `.ready` is the "the Mac is on this network" signal. The probe runs
|
||||||
|
/// entirely off the live connection, so joining a *foreign* Wi-Fi (no Mac here) costs one short,
|
||||||
|
/// silent connect attempt and leaves the working tunnel untouched.
|
||||||
|
private func maybeUpgradeToLAN() {
|
||||||
|
guard let host = pinnedHost, connectivity.isLive, activeTransport != .lan,
|
||||||
|
pathMonitor.canUseLAN, lanUpgradeProbe == nil,
|
||||||
|
let endpoint = discovery.endpoint(
|
||||||
|
forFingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
||||||
|
else { return }
|
||||||
|
lanUpgradeProbe = Task { [weak self] in
|
||||||
|
let reachable = await Self.probeReachable(endpoint)
|
||||||
|
guard let self else { return }
|
||||||
|
self.lanUpgradeProbe = nil
|
||||||
|
// Re-check the world didn't move under us during the probe (still live, still not on LAN,
|
||||||
|
// LAN still available) before tearing a working connection down.
|
||||||
|
guard !Task.isCancelled, reachable, self.connectivity.isLive,
|
||||||
|
self.activeTransport != .lan, self.pathMonitor.canUseLAN,
|
||||||
|
let host = self.pinnedHost
|
||||||
|
else { return }
|
||||||
|
self.reconnectAttempts = 0
|
||||||
|
self.reconnect(to: host) // rebuilds the candidate chain LAN-first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bare TCP reachability check for `maybeUpgradeToLAN`: does `endpoint` accept a connection within
|
||||||
|
/// `timeoutSeconds`? Tears the probe socket down regardless — it only tests reachability, never
|
||||||
|
/// carries traffic. A short timeout so a foreign Wi-Fi (Mac absent) doesn't stall the check.
|
||||||
|
private static func probeReachable(_ endpoint: NWEndpoint, timeoutSeconds: Double = 3) async -> Bool {
|
||||||
|
let params = NWParameters.tcp
|
||||||
|
params.includePeerToPeer = true
|
||||||
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
|
let queue = DispatchQueue(label: "nucleic.remote.lanprobe")
|
||||||
|
let once = ProbeOnce()
|
||||||
|
return await withTaskCancellationHandler {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if once.take() { cont.resume(returning: true) }
|
||||||
|
connection.cancel()
|
||||||
|
case .failed, .cancelled:
|
||||||
|
if once.take() { cont.resume(returning: false) }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||||
|
if once.take() { cont.resume(returning: false) }
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
}
|
||||||
|
} onCancel: {
|
||||||
|
connection.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,6 +773,10 @@ final class HostConnection {
|
|||||||
reconnect(to: host)
|
reconnect(to: host)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Already live — but if we're on a fallback transport and a LAN path exists now, try to move
|
||||||
|
// back to the direct connection (foregrounding on the Mac's Wi-Fi after being away on cellular,
|
||||||
|
// where no path-change event fired while suspended).
|
||||||
|
maybeUpgradeToLAN()
|
||||||
revalidateTask?.cancel()
|
revalidateTask?.cancel()
|
||||||
revalidateTask = Task { [weak self] in
|
revalidateTask = Task { [weak self] in
|
||||||
// A generous bound on a pong round-trip (LAN <10ms, relay <200ms) — only paid in full
|
// A generous bound on a pong round-trip (LAN <10ms, relay <200ms) — only paid in full
|
||||||
@@ -735,6 +808,7 @@ final class HostConnection {
|
|||||||
retryTask?.cancel(); retryTask = nil
|
retryTask?.cancel(); retryTask = nil
|
||||||
revalidateTask?.cancel(); revalidateTask = nil
|
revalidateTask?.cancel(); revalidateTask = nil
|
||||||
connectTask?.cancel(); connectTask = nil
|
connectTask?.cancel(); connectTask = nil
|
||||||
|
lanUpgradeProbe?.cancel(); lanUpgradeProbe = nil
|
||||||
connectPlan = nil
|
connectPlan = nil
|
||||||
teardownClient()
|
teardownClient()
|
||||||
}
|
}
|
||||||
@@ -746,3 +820,17 @@ final class HostConnection {
|
|||||||
client = nil
|
client = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A thread-safe "fire once" latch: the `NWConnection` reachability probe resolves its continuation
|
||||||
|
/// from a state callback *or* a timeout, on the same queue but from different closures — `take()`
|
||||||
|
/// returns true to exactly one caller so the continuation is resumed exactly once.
|
||||||
|
private final class ProbeOnce: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var done = false
|
||||||
|
func take() -> Bool {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
if done { return false }
|
||||||
|
done = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -558,13 +620,20 @@ final class RemoteStore: ObservableObject {
|
|||||||
var cb = HostConnection.Callbacks()
|
var cb = HostConnection.Callbacks()
|
||||||
cb.didUpdate = { [weak self] in
|
cb.didUpdate = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
// Complete a session opened before its owning Mac was live — a Live Activity /
|
||||||
|
// notification cold-launch deep link opens the detail while the app is still dialing, so
|
||||||
|
// `open()` couldn't bind it to a connection that didn't exist yet. Runs before the merge
|
||||||
|
// so the aggregate picks up the freshly-bound host's connectivity/scope this pass.
|
||||||
|
self.bindOpenSessionIfNeeded()
|
||||||
// Any host's change re-merges the aggregate the flat state binds to (mesh P3).
|
// Any host's change re-merges the aggregate the flat state binds to (mesh P3).
|
||||||
self.rebuildAggregate()
|
self.rebuildAggregate()
|
||||||
self.refreshAggregate()
|
self.refreshAggregate()
|
||||||
self.flushPendingNotificationDecision()
|
self.flushPendingNotificationDecision()
|
||||||
// A host that just connected (or reconnected) needs the current Live Activity token
|
// A host that just connected (or reconnected) needs the current Live Activity token
|
||||||
// so it can push while the phone is away.
|
// so it can push while the phone is away — and the push-to-start token so it can create
|
||||||
|
// the glance cold when work starts before the app is opened.
|
||||||
self.syncLiveActivityRegistration()
|
self.syncLiveActivityRegistration()
|
||||||
|
self.syncPushToStartRegistration()
|
||||||
}
|
}
|
||||||
cb.openSnapshot = { [weak self] snap in
|
cb.openSnapshot = { [weak self] snap in
|
||||||
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
|
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
|
||||||
@@ -922,6 +991,26 @@ final class RemoteStore: ObservableObject {
|
|||||||
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
|
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bind the open session to its owning Mac once that Mac is live — the deferred half of `open()`
|
||||||
|
/// for a session opened before any connection existed (a Live Activity / notification cold-launch
|
||||||
|
/// deep link). Because `open()` ran while the app was still dialing, `connection(owningSession:)`
|
||||||
|
/// found nothing: the host binding stayed nil, no `subscribe` went out, and the transcript
|
||||||
|
/// callbacks — all gated on `openSessionHostID` — dropped everything the host later sent, leaving
|
||||||
|
/// the detail stuck on "Disconnected" with an empty transcript and a dead composer. Once a host
|
||||||
|
/// connects and lists its sessions, `didUpdate` calls this: it finds the owner and finishes the
|
||||||
|
/// subscription so the transcript loads and connectivity/scope follow the bound host. Idempotent
|
||||||
|
/// and cheap — a no-op on the common path where `open()` already bound a live connection.
|
||||||
|
private func bindOpenSessionIfNeeded() {
|
||||||
|
guard !demoMode, let sessionID = openSessionID,
|
||||||
|
let conn = connection(owningSession: sessionID), conn.connectivity.isLive else { return }
|
||||||
|
// Already bound to this live host with its subscription in place — nothing to redo.
|
||||||
|
guard openSessionHostID != conn.hostID || conn.openSessionID != sessionID else { return }
|
||||||
|
openSessionHostID = conn.hostID
|
||||||
|
conn.openSessionID = sessionID
|
||||||
|
conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||||
|
conn.fetchFullTranscript(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
|
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
|
||||||
/// doesn't advertise the capability — the view falls back to the diffstat summary.
|
/// doesn't advertise the capability — the view falls back to the diffstat summary.
|
||||||
func fetchDiff(_ sessionID: SessionID) {
|
func fetchDiff(_ sessionID: SessionID) {
|
||||||
@@ -1184,7 +1273,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
||||||
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
||||||
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
||||||
.registerLiveActivity, .endLiveActivity,
|
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken,
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair:
|
.requestPairingCode, .cancelPairingCode, .respondMacPair:
|
||||||
break
|
break
|
||||||
@@ -1201,14 +1290,35 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
|
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
|
||||||
/// re-sent to everyone when the token rotates).
|
/// re-sent to everyone when the token rotates).
|
||||||
private var liveActivitySentTo: Set<String> = []
|
private var liveActivitySentTo: Set<String> = []
|
||||||
|
/// This device's push-to-start token (iOS 17.2+), shipped to every capable Mac so it can create
|
||||||
|
/// the Live Activity over APNs when work starts before the app is opened. Device-scoped, so —
|
||||||
|
/// unlike `liveActivityReg` — it persists across activities and isn't cleared when one ends.
|
||||||
|
private var pushToStartToken: String?
|
||||||
|
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
|
||||||
|
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)
|
||||||
self.liveActivitySentTo.removeAll() // a fresh token must reach every host again
|
self.liveActivitySentTo.removeAll() // a fresh token must reach every host again
|
||||||
self.syncLiveActivityRegistration()
|
self.syncLiveActivityRegistration()
|
||||||
}
|
}
|
||||||
|
LiveActivityManager.shared.onPushToStartToken = { [weak self] token in
|
||||||
|
guard let self, self.pushToStartToken != token else { return }
|
||||||
|
self.pushToStartToken = token
|
||||||
|
self.pushToStartSentTo.removeAll() // a fresh token must reach every host again
|
||||||
|
self.syncPushToStartRegistration()
|
||||||
|
}
|
||||||
|
// Start observing the push-to-start token (and adopting any push-started activity) now — it's
|
||||||
|
// device-scoped and must be captured even before any Activity exists.
|
||||||
|
LiveActivityManager.shared.beginPushToStartObservation()
|
||||||
LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
|
LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.liveActivityReg = nil
|
self.liveActivityReg = nil
|
||||||
@@ -1234,6 +1344,23 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send the current push-to-start token to every live, capable host that hasn't got it yet — the
|
||||||
|
/// mirror of `syncLiveActivityRegistration` for the device-scoped token that lets a host create
|
||||||
|
/// the glance over APNs when work starts before the app is opened (iOS 17.2+).
|
||||||
|
private func syncPushToStartRegistration() {
|
||||||
|
guard let token = pushToStartToken else { return }
|
||||||
|
// A host that dropped should re-register when it returns.
|
||||||
|
pushToStartSentTo = pushToStartSentTo.filter { connections[$0]?.connectivity.isLive == true }
|
||||||
|
for (id, conn) in connections {
|
||||||
|
// Gate on the dedicated push-to-start bit, not `canPushLiveActivity`: an older host can
|
||||||
|
// advertise the latter yet throw on the unknown `registerPushToStartToken` tag.
|
||||||
|
guard conn.connectivity.isLive, conn.capabilities.canPushToStartLiveActivity,
|
||||||
|
!pushToStartSentTo.contains(id) else { continue }
|
||||||
|
conn.send(.registerPushToStartToken(token))
|
||||||
|
pushToStartSentTo.insert(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Demo simulator (offline, interactive)
|
// MARK: - Demo simulator (offline, interactive)
|
||||||
//
|
//
|
||||||
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
|
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
|
||||||
@@ -1291,7 +1418,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
.addressUpdate, .meshRoster,
|
.addressUpdate, .meshRoster,
|
||||||
// Live Activity push registration is a real-connection concern (there's no host to
|
// Live Activity push registration is a real-connection concern (there's no host to
|
||||||
// push in demo), so it's inert here.
|
// push in demo), so it's inert here.
|
||||||
.registerLiveActivity, .endLiveActivity,
|
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken,
|
||||||
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
||||||
// and demo has no peer Macs, so they're inert here.
|
// and demo has no peer Macs, so they're inert here.
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel,
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel,
|
||||||
|
|||||||
@@ -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