mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 16:47:29 +00:00
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 0a7ab8589a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
506 lines
20 KiB
Swift
506 lines
20 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
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
|
|
}
|
|
// On first run or explicit launch onboarding state, onboarding always wins.
|
|
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
|
return .onboarding
|
|
}
|
|
// Settings auto-open is a recovery path for previously-connected installs only.
|
|
if !hasExistingGatewayConfig {
|
|
return .settings
|
|
}
|
|
return .none
|
|
}
|
|
|
|
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()
|
|
.sheet(item: self.$presentedSheet) { sheet in
|
|
switch sheet {
|
|
case .settings:
|
|
SettingsTab()
|
|
.environment(self.appModel)
|
|
.environment(self.appModel.voiceWake)
|
|
.environment(self.gatewayController)
|
|
case .chat:
|
|
ChatSheet(
|
|
// Chat RPCs run on the operator session (read/write scopes).
|
|
gateway: self.appModel.operatorSession,
|
|
sessionKey: self.appModel.mainSessionKey,
|
|
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.evaluateOnboardingPresentation(force: false) }
|
|
.onAppear { self.maybeAutoOpenSettings() }
|
|
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
|
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
|
.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() }
|
|
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
|
.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() }
|
|
.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 {
|
|
if self.appModel.gatewayServerName != nil { return .connected }
|
|
|
|
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if text.localizedCaseInsensitiveContains("connecting") ||
|
|
text.localizedCaseInsensitiveContains("reconnecting")
|
|
{
|
|
return .connecting
|
|
}
|
|
|
|
if text.localizedCaseInsensitiveContains("error") {
|
|
return .error
|
|
}
|
|
|
|
return .disconnected
|
|
}
|
|
|
|
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 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 GatewaySettingsStore.loadLastGatewayConnection() != nil { 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() {
|
|
guard !self.quickSetupDismissed else { return }
|
|
guard !self.showOnboarding else { return }
|
|
guard self.presentedSheet == nil else { return }
|
|
guard self.appModel.gatewayServerName == nil else { return }
|
|
guard !self.gatewayController.gateways.isEmpty else { return }
|
|
self.presentedSheet = .quickSetup
|
|
}
|
|
}
|
|
|
|
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 }
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
ScreenTab()
|
|
|
|
VStack(spacing: 10) {
|
|
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
|
|
self.openChat()
|
|
}
|
|
.accessibilityLabel("Chat")
|
|
|
|
if self.talkButtonEnabled {
|
|
// Talk mode lives on a side bubble so it doesn't get buried in settings.
|
|
OverlayButton(
|
|
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
|
|
brighten: self.brightenButtons,
|
|
tint: self.appModel.seamColor,
|
|
isActive: self.appModel.talkMode.isEnabled)
|
|
{
|
|
let next = !self.appModel.talkMode.isEnabled
|
|
self.talkEnabled = next
|
|
self.appModel.setTalkEnabled(next)
|
|
}
|
|
.accessibilityLabel("Talk Mode")
|
|
}
|
|
|
|
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
|
self.openSettings()
|
|
}
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
.padding(.top, 10)
|
|
.padding(.trailing, 10)
|
|
}
|
|
.overlay(alignment: .center) {
|
|
if self.appModel.talkMode.isEnabled {
|
|
TalkOrbOverlay()
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
StatusPill(
|
|
gateway: self.gatewayStatus,
|
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
|
activity: self.statusActivity,
|
|
brighten: self.brightenButtons,
|
|
onTap: {
|
|
if self.gatewayStatus == .connected {
|
|
self.showGatewayActions = true
|
|
} else {
|
|
self.openSettings()
|
|
}
|
|
})
|
|
.padding(.leading, 10)
|
|
.safeAreaPadding(.top, 10)
|
|
}
|
|
.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))
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Gateway",
|
|
isPresented: self.$showGatewayActions,
|
|
titleVisibility: .visible)
|
|
{
|
|
Button("Disconnect", role: .destructive) {
|
|
self.appModel.disconnectGateway()
|
|
}
|
|
Button("Open Settings") {
|
|
self.openSettings()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Disconnect from the gateway?")
|
|
}
|
|
}
|
|
|
|
private var statusActivity: StatusPill.Activity? {
|
|
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
|
if self.appModel.isBackgrounded {
|
|
return StatusPill.Activity(
|
|
title: "Foreground required",
|
|
systemImage: "exclamationmark.triangle.fill",
|
|
tint: .orange)
|
|
}
|
|
|
|
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let gatewayLower = gatewayStatus.lowercased()
|
|
if gatewayLower.contains("repair") {
|
|
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
|
}
|
|
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
|
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
|
}
|
|
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
|
|
|
if self.appModel.screenRecordActive {
|
|
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
|
}
|
|
|
|
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
|
let systemImage: String
|
|
let tint: Color?
|
|
switch cameraHUDKind {
|
|
case .photo:
|
|
systemImage = "camera.fill"
|
|
tint = nil
|
|
case .recording:
|
|
systemImage = "video.fill"
|
|
tint = .red
|
|
case .success:
|
|
systemImage = "checkmark.circle.fill"
|
|
tint = .green
|
|
case .error:
|
|
systemImage = "exclamationmark.triangle.fill"
|
|
tint = .red
|
|
}
|
|
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
|
}
|
|
|
|
if self.voiceWakeEnabled {
|
|
let voiceStatus = self.appModel.voiceWake.statusText
|
|
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
|
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
|
}
|
|
if voiceStatus == "Paused" {
|
|
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
|
if self.appModel.talkMode.isEnabled {
|
|
return nil
|
|
}
|
|
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
|
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private struct OverlayButton: View {
|
|
let systemImage: String
|
|
let brighten: Bool
|
|
var tint: Color?
|
|
var isActive: Bool = false
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: self.action) {
|
|
Image(systemName: self.systemImage)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
|
.padding(10)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
.white.opacity(self.brighten ? 0.26 : 0.18),
|
|
.white.opacity(self.brighten ? 0.08 : 0.04),
|
|
.clear,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing))
|
|
.blendMode(.overlay)
|
|
}
|
|
.overlay {
|
|
if let tint {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
tint.opacity(self.isActive ? 0.22 : 0.14),
|
|
tint.opacity(self.isActive ? 0.10 : 0.06),
|
|
.clear,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing))
|
|
.blendMode(.overlay)
|
|
}
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(
|
|
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
|
|
lineWidth: self.isActive ? 0.7 : 0.5)
|
|
}
|
|
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|