| import SwiftUI |
| import UIKit |
| import OpenClawProtocol |
|
|
| struct RootCanvas: View { |
| @Environment(NodeAppModel.self) private var appModel |
| @Environment(GatewayConnectionController.self) private var gatewayController |
| @Environment(VoiceWakeManager.self) private var voiceWake |
| @Environment(\.colorScheme) private var systemColorScheme |
| @Environment(\.scenePhase) private var scenePhase |
| @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false |
| @AppStorage("screen.preventSleep") private var preventSleep: Bool = true |
| @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false |
| @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 |
| @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false |
| @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false |
| @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" |
| @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false |
| @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" |
| @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false |
| @State private var presentedSheet: PresentedSheet? |
| @State private var voiceWakeToastText: String? |
| @State private var toastDismissTask: Task<Void, Never>? |
| @State private var showOnboarding: Bool = false |
| @State private var onboardingAllowSkip: Bool = true |
| @State private var didEvaluateOnboarding: Bool = false |
| @State private var didAutoOpenSettings: Bool = false |
|
|
| private enum PresentedSheet: Identifiable { |
| case settings |
| case chat |
| case quickSetup |
|
|
| var id: Int { |
| switch self { |
| case .settings: 0 |
| case .chat: 1 |
| case .quickSetup: 2 |
| } |
| } |
| } |
|
|
| enum StartupPresentationRoute: Equatable { |
| case none |
| case onboarding |
| case settings |
| } |
|
|
| static func startupPresentationRoute( |
| gatewayConnected: Bool, |
| hasConnectedOnce: Bool, |
| onboardingComplete: Bool, |
| hasExistingGatewayConfig: Bool, |
| shouldPresentOnLaunch: Bool) -> StartupPresentationRoute |
| { |
| if gatewayConnected { |
| return .none |
| } |
| |
| if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { |
| return .onboarding |
| } |
| |
| if !hasExistingGatewayConfig { |
| return .settings |
| } |
| return .none |
| } |
|
|
| static func shouldPresentQuickSetup( |
| quickSetupDismissed: Bool, |
| showOnboarding: Bool, |
| hasPresentedSheet: Bool, |
| gatewayConnected: Bool, |
| hasExistingGatewayConfig: Bool, |
| discoveredGatewayCount: Int) -> Bool |
| { |
| guard !quickSetupDismissed else { return false } |
| guard !showOnboarding else { return false } |
| guard !hasPresentedSheet else { return false } |
| guard !gatewayConnected else { return false } |
| |
| guard !hasExistingGatewayConfig else { return false } |
| return discoveredGatewayCount > 0 |
| } |
|
|
| var body: some View { |
| ZStack { |
| CanvasContent( |
| systemColorScheme: self.systemColorScheme, |
| gatewayStatus: self.gatewayStatus, |
| voiceWakeEnabled: self.voiceWakeEnabled, |
| voiceWakeToastText: self.voiceWakeToastText, |
| cameraHUDText: self.appModel.cameraHUDText, |
| cameraHUDKind: self.appModel.cameraHUDKind, |
| openChat: { |
| self.presentedSheet = .chat |
| }, |
| openSettings: { |
| self.presentedSheet = .settings |
| }) |
| .preferredColorScheme(.dark) |
|
|
| if self.appModel.cameraFlashNonce != 0 { |
| CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) |
| } |
| } |
| .gatewayTrustPromptAlert() |
| .deepLinkAgentPromptAlert() |
| .sheet(item: self.$presentedSheet) { sheet in |
| switch sheet { |
| case .settings: |
| SettingsTab() |
| .environment(self.appModel) |
| .environment(self.appModel.voiceWake) |
| .environment(self.gatewayController) |
| case .chat: |
| ChatSheet( |
| |
| gateway: self.appModel.operatorSession, |
| sessionKey: self.appModel.chatSessionKey, |
| agentName: self.appModel.activeAgentName, |
| userAccent: self.appModel.seamColor) |
| case .quickSetup: |
| GatewayQuickSetupSheet() |
| .environment(self.appModel) |
| .environment(self.gatewayController) |
| } |
| } |
| .fullScreenCover(isPresented: self.$showOnboarding) { |
| OnboardingWizardView( |
| allowSkip: self.onboardingAllowSkip, |
| onClose: { |
| self.showOnboarding = false |
| }) |
| .environment(self.appModel) |
| .environment(self.appModel.voiceWake) |
| .environment(self.gatewayController) |
| } |
| .onAppear { self.updateIdleTimer() } |
| .onAppear { self.updateHomeCanvasState() } |
| .onAppear { self.evaluateOnboardingPresentation(force: false) } |
| .onAppear { self.maybeAutoOpenSettings() } |
| .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } |
| .onChange(of: self.scenePhase) { _, newValue in |
| self.updateIdleTimer() |
| self.updateHomeCanvasState() |
| guard newValue == .active else { return } |
| Task { |
| await self.appModel.refreshGatewayOverviewIfConnected() |
| await MainActor.run { |
| self.updateHomeCanvasState() |
| } |
| } |
| } |
| .onAppear { self.maybeShowQuickSetup() } |
| .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } |
| .onAppear { self.updateCanvasDebugStatus() } |
| .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } |
| .onChange(of: self.appModel.gatewayStatusText) { _, _ in |
| self.updateCanvasDebugStatus() |
| self.updateHomeCanvasState() |
| } |
| .onChange(of: self.appModel.gatewayServerName) { _, _ in |
| self.updateCanvasDebugStatus() |
| self.updateHomeCanvasState() |
| } |
| .onChange(of: self.appModel.gatewayServerName) { _, newValue in |
| if newValue != nil { |
| self.showOnboarding = false |
| } |
| } |
| .onChange(of: self.onboardingRequestID) { _, _ in |
| self.evaluateOnboardingPresentation(force: true) |
| } |
| .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in |
| self.updateCanvasDebugStatus() |
| self.updateHomeCanvasState() |
| } |
| .onChange(of: self.appModel.homeCanvasRevision) { _, _ in |
| self.updateHomeCanvasState() |
| } |
| .onChange(of: self.appModel.gatewayServerName) { _, newValue in |
| if newValue != nil { |
| self.onboardingComplete = true |
| self.hasConnectedOnce = true |
| OnboardingStateStore.markCompleted(mode: nil) |
| } |
| self.maybeAutoOpenSettings() |
| } |
| .onChange(of: self.appModel.openChatRequestID) { _, _ in |
| self.presentedSheet = .chat |
| } |
| .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in |
| guard let newValue else { return } |
| let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) |
| guard !trimmed.isEmpty else { return } |
|
|
| self.toastDismissTask?.cancel() |
| withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { |
| self.voiceWakeToastText = trimmed |
| } |
|
|
| self.toastDismissTask = Task { |
| try? await Task.sleep(nanoseconds: 2_300_000_000) |
| await MainActor.run { |
| withAnimation(.easeOut(duration: 0.25)) { |
| self.voiceWakeToastText = nil |
| } |
| } |
| } |
| } |
| .onDisappear { |
| UIApplication.shared.isIdleTimerDisabled = false |
| self.toastDismissTask?.cancel() |
| self.toastDismissTask = nil |
| } |
| } |
|
|
| private var gatewayStatus: StatusPill.GatewayState { |
| GatewayStatusBuilder.build(appModel: self.appModel) |
| } |
|
|
| private func updateIdleTimer() { |
| UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep) |
| } |
|
|
| private func updateCanvasDebugStatus() { |
| self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) |
| guard self.canvasDebugStatusEnabled else { return } |
| let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) |
| let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress |
| self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) |
| } |
|
|
| private func updateHomeCanvasState() { |
| let payload = self.makeHomeCanvasPayload() |
| guard let data = try? JSONEncoder().encode(payload), |
| let json = String(data: data, encoding: .utf8) |
| else { |
| self.appModel.screen.updateHomeCanvasState(json: nil) |
| return |
| } |
| self.appModel.screen.updateHomeCanvasState(json: json) |
| } |
|
|
| private func makeHomeCanvasPayload() -> HomeCanvasPayload { |
| let gatewayName = self.normalized(self.appModel.gatewayServerName) |
| let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress) |
| let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway" |
| let activeAgentID = self.resolveActiveAgentID() |
| let agents = self.homeCanvasAgents(activeAgentID: activeAgentID) |
|
|
| switch self.gatewayStatus { |
| case .connected: |
| return HomeCanvasPayload( |
| gatewayState: "connected", |
| eyebrow: "Connected to \(gatewayLabel)", |
| title: "Your agents are ready", |
| subtitle: |
| "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", |
| gatewayLabel: gatewayLabel, |
| activeAgentName: self.appModel.activeAgentName, |
| activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC", |
| activeAgentCaption: "Selected on this phone", |
| agentCount: agents.count, |
| agents: Array(agents.prefix(6)), |
| footer: "The overview refreshes on reconnect and when the app returns to foreground.") |
| case .connecting: |
| return HomeCanvasPayload( |
| gatewayState: "connecting", |
| eyebrow: "Reconnecting", |
| title: "OpenClaw is syncing back up", |
| subtitle: |
| "The gateway session is coming back online. " |
| + "Agent shortcuts should settle automatically in a moment.", |
| gatewayLabel: gatewayLabel, |
| activeAgentName: self.appModel.activeAgentName, |
| activeAgentBadge: "OC", |
| activeAgentCaption: "Gateway session in progress", |
| agentCount: agents.count, |
| agents: Array(agents.prefix(4)), |
| footer: "If the gateway is reachable, reconnect should complete without intervention.") |
| case .error, .disconnected: |
| return HomeCanvasPayload( |
| gatewayState: self.gatewayStatus == .error ? "error" : "offline", |
| eyebrow: "Welcome to OpenClaw", |
| title: "Your phone stays quiet until it is needed", |
| subtitle: |
| "Pair this device to your gateway to wake it only for real work, " |
| + "keep a live agent overview handy, and avoid battery-draining background loops.", |
| gatewayLabel: gatewayLabel, |
| activeAgentName: "Main", |
| activeAgentBadge: "OC", |
| activeAgentCaption: "Connect to load your agents", |
| agentCount: agents.count, |
| agents: Array(agents.prefix(4)), |
| footer: |
| "When connected, the gateway can wake the phone with a silent push " |
| + "instead of holding an always-on session.") |
| } |
| } |
|
|
| private func resolveActiveAgentID() -> String { |
| let selected = self.normalized(self.appModel.selectedAgentId) ?? "" |
| if !selected.isEmpty { |
| return selected |
| } |
| return self.resolveDefaultAgentID() |
| } |
|
|
| private func resolveDefaultAgentID() -> String { |
| self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" |
| } |
|
|
| private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] { |
| let defaultAgentID = self.resolveDefaultAgentID() |
| let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in |
| let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID |
| let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID |
| return HomeCanvasAgentCard( |
| id: agent.id, |
| name: self.homeCanvasName(for: agent), |
| badge: self.homeCanvasBadge(for: agent), |
| caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"), |
| isActive: isActive) |
| } |
|
|
| return cards.sorted { lhs, rhs in |
| if lhs.isActive != rhs.isActive { |
| return lhs.isActive |
| } |
| return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending |
| } |
| } |
|
|
| private func homeCanvasName(for agent: AgentSummary) -> String { |
| self.normalized(agent.name) ?? agent.id |
| } |
|
|
| private func homeCanvasBadge(for agent: AgentSummary) -> String { |
| if let identity = agent.identity, |
| let emoji = identity["emoji"]?.value as? String, |
| let normalizedEmoji = self.normalized(emoji) |
| { |
| return normalizedEmoji |
| } |
| let words = self.homeCanvasName(for: agent) |
| .split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) |
| .prefix(2) |
| let initials = words.compactMap { $0.first }.map(String.init).joined() |
| if !initials.isEmpty { |
| return initials.uppercased() |
| } |
| return "OC" |
| } |
|
|
| private func normalized(_ value: String?) -> String? { |
| guard let value else { return nil } |
| let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| return trimmed.isEmpty ? nil : trimmed |
| } |
|
|
| private func evaluateOnboardingPresentation(force: Bool) { |
| if force { |
| self.onboardingAllowSkip = true |
| self.showOnboarding = true |
| return |
| } |
|
|
| guard !self.didEvaluateOnboarding else { return } |
| self.didEvaluateOnboarding = true |
| let route = Self.startupPresentationRoute( |
| gatewayConnected: self.appModel.gatewayServerName != nil, |
| hasConnectedOnce: self.hasConnectedOnce, |
| onboardingComplete: self.onboardingComplete, |
| hasExistingGatewayConfig: self.hasExistingGatewayConfig(), |
| shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel)) |
| switch route { |
| case .none: |
| break |
| case .onboarding: |
| self.onboardingAllowSkip = true |
| self.showOnboarding = true |
| case .settings: |
| self.didAutoOpenSettings = true |
| self.presentedSheet = .settings |
| } |
| } |
|
|
| private func hasExistingGatewayConfig() -> Bool { |
| if self.appModel.activeGatewayConnectConfig != nil { return true } |
| if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true } |
|
|
| let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) |
| if !preferredStableID.isEmpty { return true } |
|
|
| let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) |
| return self.manualGatewayEnabled && !manualHost.isEmpty |
| } |
|
|
| private func maybeAutoOpenSettings() { |
| guard !self.didAutoOpenSettings else { return } |
| guard !self.showOnboarding else { return } |
| let route = Self.startupPresentationRoute( |
| gatewayConnected: self.appModel.gatewayServerName != nil, |
| hasConnectedOnce: self.hasConnectedOnce, |
| onboardingComplete: self.onboardingComplete, |
| hasExistingGatewayConfig: self.hasExistingGatewayConfig(), |
| shouldPresentOnLaunch: false) |
| guard route == .settings else { return } |
| self.didAutoOpenSettings = true |
| self.presentedSheet = .settings |
| } |
|
|
| private func maybeShowQuickSetup() { |
| let shouldPresent = Self.shouldPresentQuickSetup( |
| quickSetupDismissed: self.quickSetupDismissed, |
| showOnboarding: self.showOnboarding, |
| hasPresentedSheet: self.presentedSheet != nil, |
| gatewayConnected: self.appModel.gatewayServerName != nil, |
| hasExistingGatewayConfig: self.hasExistingGatewayConfig(), |
| discoveredGatewayCount: self.gatewayController.gateways.count) |
| guard shouldPresent else { return } |
| self.presentedSheet = .quickSetup |
| } |
| } |
|
|
| private struct HomeCanvasPayload: Codable { |
| var gatewayState: String |
| var eyebrow: String |
| var title: String |
| var subtitle: String |
| var gatewayLabel: String |
| var activeAgentName: String |
| var activeAgentBadge: String |
| var activeAgentCaption: String |
| var agentCount: Int |
| var agents: [HomeCanvasAgentCard] |
| var footer: String |
| } |
|
|
| private struct HomeCanvasAgentCard: Codable { |
| var id: String |
| var name: String |
| var badge: String |
| var caption: String |
| var isActive: Bool |
| } |
|
|
| private struct CanvasContent: View { |
| @Environment(NodeAppModel.self) private var appModel |
| @AppStorage("talk.enabled") private var talkEnabled: Bool = false |
| @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true |
| @State private var showGatewayActions: Bool = false |
| var systemColorScheme: ColorScheme |
| var gatewayStatus: StatusPill.GatewayState |
| var voiceWakeEnabled: Bool |
| var voiceWakeToastText: String? |
| var cameraHUDText: String? |
| var cameraHUDKind: NodeAppModel.CameraHUDKind? |
| var openChat: () -> Void |
| var openSettings: () -> Void |
|
|
| private var brightenButtons: Bool { self.systemColorScheme == .light } |
| private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled } |
|
|
| var body: some View { |
| ZStack { |
| ScreenTab() |
| } |
| .overlay(alignment: .center) { |
| if self.talkActive { |
| TalkOrbOverlay() |
| .transition(.opacity) |
| } |
| } |
| .safeAreaInset(edge: .bottom, spacing: 0) { |
| HomeToolbar( |
| gateway: self.gatewayStatus, |
| voiceWakeEnabled: self.voiceWakeEnabled, |
| activity: self.statusActivity, |
| brighten: self.brightenButtons, |
| talkButtonEnabled: self.talkButtonEnabled, |
| talkActive: self.talkActive, |
| talkTint: self.appModel.seamColor, |
| onStatusTap: { |
| if self.gatewayStatus == .connected { |
| self.showGatewayActions = true |
| } else { |
| self.openSettings() |
| } |
| }, |
| onChatTap: { |
| self.openChat() |
| }, |
| onTalkTap: { |
| let next = !self.talkActive |
| self.talkEnabled = next |
| self.appModel.setTalkEnabled(next) |
| }, |
| onSettingsTap: { |
| self.openSettings() |
| }) |
| } |
| .overlay(alignment: .topLeading) { |
| if let voiceWakeToastText, !voiceWakeToastText.isEmpty { |
| VoiceWakeToast( |
| command: voiceWakeToastText, |
| brighten: self.brightenButtons) |
| .padding(.leading, 10) |
| .safeAreaPadding(.top, 58) |
| .transition(.move(edge: .top).combined(with: .opacity)) |
| } |
| } |
| .gatewayActionsDialog( |
| isPresented: self.$showGatewayActions, |
| onDisconnect: { self.appModel.disconnectGateway() }, |
| onOpenSettings: { self.openSettings() }) |
| .onAppear { |
| |
| if self.talkEnabled != self.appModel.talkMode.isEnabled { |
| self.appModel.setTalkEnabled(self.talkEnabled) |
| } |
| } |
| } |
|
|
| private var statusActivity: StatusPill.Activity? { |
| StatusActivityBuilder.build( |
| appModel: self.appModel, |
| voiceWakeEnabled: self.voiceWakeEnabled, |
| cameraHUDText: self.cameraHUDText, |
| cameraHUDKind: self.cameraHUDKind) |
| } |
| } |
|
|
| private struct CameraFlashOverlay: View { |
| var nonce: Int |
|
|
| @State private var opacity: CGFloat = 0 |
| @State private var task: Task<Void, Never>? |
|
|
| var body: some View { |
| Color.white |
| .opacity(self.opacity) |
| .ignoresSafeArea() |
| .allowsHitTesting(false) |
| .onChange(of: self.nonce) { _, _ in |
| self.task?.cancel() |
| self.task = Task { @MainActor in |
| withAnimation(.easeOut(duration: 0.08)) { |
| self.opacity = 0.85 |
| } |
| try? await Task.sleep(nanoseconds: 110_000_000) |
| withAnimation(.easeOut(duration: 0.32)) { |
| self.opacity = 0 |
| } |
| } |
| } |
| } |
| } |
|
|