Nucleic-Session: 4BE3E40C-E939-444C-8AED-93C0AFE13C07 Co-authored-by: Nucleic <[email protected]>
113 lines
5.4 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|