Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift
T
abkslmandNucleic 9a0a46bc8b Network Reconnection Delay
Nucleic-Session: 4BE3E40C-E939-444C-8AED-93C0AFE13C07
Co-authored-by: Nucleic <[email protected]>
2026-07-06 12:46:02 -07:00

113 lines
5.4 KiB
Swift

import SwiftUI
@main
struct NucleicRemoteApp: App {
@StateObject private var store = RemoteStore()
@Environment(\.scenePhase) private var scenePhase
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(store)
// A Live Activity tap arrives as a `nucleic://` deep link: open the waiting
// session, or surface the sessions list when work is only running.
.onOpenURL { store.handleDeepLink($0) }
.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.
if ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] != "1" {
PushRegistrar.shared.requestAuthorizationAndRegister()
}
// 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; keep the store's
// activity flag current so it only notifies for background transitions.
.onChange(of: scenePhase) { _, phase in
store.setScenePhaseActive(phase == .active)
switch phase {
case .active:
// Re-plan transports immediately on foreground — the network may have
// changed while suspended, so don't wait for a dead socket to notice.
store.onForeground()
Task { await HeartbeatReporter.reportIfDue() }
case .background:
// Hold the process (and its live sockets) briefly so a quick app-switch
// doesn't drop the connection and force a cold reconnect on return.
store.onBackground()
default:
break
}
}
}
}
}
struct RootView: View {
@EnvironmentObject var store: RemoteStore
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
// Appearance, so both devices honor the same choices.
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
var body: some View {
VStack(spacing: 0) {
// The same non-release warning strip the Mac window carries (dev/canary/beta/rc).
BuildBanner()
// A demo-mode strip so sample data is never mistaken for a live Mac connection.
DemoBanner()
Group {
if store.isPaired {
// Width-adaptive shell: the iPhone's TabView on compact width, the Mac's
// sidebar+detail split on a regular-width iPad (see AdaptiveRootView).
AdaptiveRootView()
} else {
PairingIntroView()
}
}
}
.overlay(alignment: .bottom) { ErrorBubble() }
.tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
// The palette resolves against the stored color-vision mode at render time; rebuild
// the hierarchy when it changes so every cached view picks up the remapped colors.
.id(colorVisionRaw)
}
}
/// A transient red bubble for host-reported errors — the mobile echo of the Mac's last-error
/// overlay at the detail pane's bottom. Auto-dismissed by the store after a few seconds; a tap
/// dismisses it immediately.
private struct ErrorBubble: View {
@EnvironmentObject var store: RemoteStore
var body: some View {
if let error = store.lastError {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
Text(error.message).lineLimit(3)
Spacer(minLength: 0)
Image(systemName: "xmark").opacity(0.7)
}
.font(.footnote.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Palette.danger, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.bottom, 58) // clear the tab bar
.onTapGesture { store.dismissError() }
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut(duration: 0.2), value: store.lastError)
}
}
}