From e7f7db3cb54c71920589d8d3439770ada3df8767 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sat, 6 Jun 2026 17:15:18 +0300 Subject: [PATCH] fix(ios): gate demo mode live gateway actions --- apps/ios/Sources/Design/AgentProModels.swift | 3 + .../ios/Sources/Design/AgentProTab+Cron.swift | 6 +- .../Design/AgentProTab+GatewayData.swift | 2 +- .../Sources/Design/AgentProTab+Overview.swift | 6 ++ .../Sources/Design/AgentProTab+Skills.swift | 16 +++- .../Design/SettingsProTabActions.swift | 74 ++++++++++++++++--- .../Design/SettingsProTabSections.swift | 28 ++++--- apps/ios/Sources/Design/TalkProTab.swift | 23 +++++- apps/ios/Sources/Model/NodeAppModel.swift | 2 + apps/ios/Tests/TalkProStateTests.swift | 25 +++++++ 10 files changed, 150 insertions(+), 35 deletions(-) diff --git a/apps/ios/Sources/Design/AgentProModels.swift b/apps/ios/Sources/Design/AgentProModels.swift index 79e0c8f810e6..f2ad4df54fd1 100644 --- a/apps/ios/Sources/Design/AgentProModels.swift +++ b/apps/ios/Sources/Design/AgentProModels.swift @@ -332,11 +332,14 @@ struct ConfigPatchParams: Encodable { } enum SkillMutationError: LocalizedError { + case liveGatewayUnavailable case missingConfigHash case invalidPatchPayload var errorDescription: String? { switch self { + case .liveGatewayUnavailable: + "Connect a live gateway to edit agent skills." case .missingConfigHash: "Config hash missing; refresh and retry." case .invalidPatchPayload: diff --git a/apps/ios/Sources/Design/AgentProTab+Cron.swift b/apps/ios/Sources/Design/AgentProTab+Cron.swift index 0c0746153bc4..f0a85c31b5f3 100644 --- a/apps/ios/Sources/Design/AgentProTab+Cron.swift +++ b/apps/ios/Sources/Design/AgentProTab+Cron.swift @@ -99,14 +99,14 @@ extension AgentProTab { } label: { Label("Run", systemImage: "play.fill") } - .disabled(busy || !self.gatewayConnected) + .disabled(busy || !self.liveGatewayConnected) Button { Task { await self.setCronJob(job, enabled: !job.enabled) } } label: { Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark") } - .disabled(busy || !self.gatewayConnected) + .disabled(busy || !self.liveGatewayConnected) } .buttonStyle(.bordered) .controlSize(.mini) @@ -149,7 +149,7 @@ extension AgentProTab { success: String, action: () async throws -> Void) async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.cronActionBusyIDs.insert(job.id) self.cronActionStatusText = nil defer { self.cronActionBusyIDs.remove(job.id) } diff --git a/apps/ios/Sources/Design/AgentProTab+GatewayData.swift b/apps/ios/Sources/Design/AgentProTab+GatewayData.swift index f03eb361a4f3..f6dee7e20f37 100644 --- a/apps/ios/Sources/Design/AgentProTab+GatewayData.swift +++ b/apps/ios/Sources/Design/AgentProTab+GatewayData.swift @@ -117,7 +117,7 @@ extension AgentProTab { @MainActor func refreshOverview(force: Bool) async { guard self.scenePhase == .active else { return } - guard self.appModel.isOperatorGatewayConnected else { + guard self.liveGatewayConnected else { self.overview = nil self.overviewErrorText = nil self.overviewLoading = false diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index 93c60f216549..5ef313383a10 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -542,6 +542,12 @@ extension AgentProTab { GatewayStatusBuilder.build(appModel: self.appModel) == .connected } + var liveGatewayConnected: Bool { + !self.appModel.isAppleReviewDemoModeEnabled && + self.gatewayConnected && + self.appModel.isOperatorGatewayConnected + } + private var searchFieldFill: Color { self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78) } diff --git a/apps/ios/Sources/Design/AgentProTab+Skills.swift b/apps/ios/Sources/Design/AgentProTab+Skills.swift index 9002f5e9f834..70f966a187af 100644 --- a/apps/ios/Sources/Design/AgentProTab+Skills.swift +++ b/apps/ios/Sources/Design/AgentProTab+Skills.swift @@ -107,7 +107,7 @@ extension AgentProTab { } .buttonStyle(.bordered) .controlSize(.small) - .disabled(self.clawHubLoading || !self.gatewayConnected) + .disabled(self.clawHubLoading || !self.liveGatewayConnected) .accessibilityLabel("Search ClawHub") } @@ -212,6 +212,7 @@ extension AgentProTab { } var skillPolicySummary: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode keeps live skill changes disabled." } guard self.gatewayConnected else { return "Connect a gateway to edit skills." } guard let filter = self.agentSkillFilter else { return "All available skills are allowed for this agent." @@ -601,7 +602,7 @@ extension AgentProTab { @MainActor func patchAgentSkills(_ skills: [String]?, busyKey: String) async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.skillMutationBusyKeys.insert(busyKey) self.skillMutationErrorText = nil self.skillMutationStatusText = nil @@ -676,7 +677,7 @@ extension AgentProTab { @MainActor func installClawHubSkill(_ result: ClawHubSearchResultLite) async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.clawHubInstallSlug = result.slug self.clawHubErrorText = nil defer { self.clawHubInstallSlug = nil } @@ -692,7 +693,7 @@ extension AgentProTab { @MainActor func searchClawHubSkills() async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.clawHubLoading = true self.clawHubErrorText = nil defer { self.clawHubLoading = false } @@ -711,6 +712,7 @@ extension AgentProTab { _ skill: SkillStatusEntryLite, action: () async throws -> String) async { + guard self.liveGatewayConnected else { return } let key = skill.effectiveSkillKey self.skillConfigBusyKeys.insert(key) self.skillConfigMessages[key] = nil @@ -733,6 +735,9 @@ extension AgentProTab { params: some Encodable, timeoutSeconds: Int) async throws -> Data { + guard self.liveGatewayConnected else { + throw SkillMutationError.liveGatewayUnavailable + } let data = try JSONEncoder().encode(params) guard let json = String(data: data, encoding: .utf8) else { throw SkillMutationError.invalidPatchPayload @@ -744,6 +749,9 @@ extension AgentProTab { } func requestConfigSnapshot() async throws -> ConfigSnapshotLite { + guard self.liveGatewayConnected else { + throw SkillMutationError.liveGatewayUnavailable + } let data = try await self.appModel.operatorSession.request( method: "config.get", paramsJSON: "{}", diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 5f4e53453067..e66f5e6f992b 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -42,9 +42,9 @@ extension SettingsProTab { self.diagnosticCheckRow( icon: "antenna.radiowaves.left.and.right", title: "Gateway Link", - detail: self.appModel.gatewayDisplayStatusText, - value: self.gatewayConnected ? "online" : "offline", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary) + detail: self.gatewayStatusDetail, + value: self.gatewayStatusValue, + color: self.gatewayStatusColor) Divider().padding(.leading, 60) self.diagnosticCheckRow( icon: "dot.radiowaves.left.and.right", @@ -56,9 +56,9 @@ extension SettingsProTab { self.diagnosticCheckRow( icon: "waveform", title: "Talk Config", - detail: self.appModel.talkMode.gatewayTalkTransportLabel, - value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing", - color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary) + detail: self.gatewayTalkConfigDetail, + value: self.gatewayTalkConfigValue, + color: self.gatewayTalkConfigColor) Divider().padding(.leading, 60) self.diagnosticCheckRow( icon: "bell", @@ -132,6 +132,7 @@ extension SettingsProTab { } func reconnectGateway() async { + guard !self.appModel.isAppleReviewDemoModeEnabled else { return } guard !self.isReconnectingGateway else { return } self.isReconnectingGateway = true defer { self.isReconnectingGateway = false } @@ -153,16 +154,18 @@ extension SettingsProTab { self.isRefreshingGateway = true defer { self.isRefreshingGateway = false } - self.gatewayController.refreshActiveGatewayRegistrationFromSettings() - self.gatewayController.restartDiscovery() - await self.appModel.refreshGatewayOverviewIfConnected() + if !self.appModel.isAppleReviewDemoModeEnabled { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() + self.gatewayController.restartDiscovery() + await self.appModel.refreshGatewayOverviewIfConnected() + } let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() self.applyNotificationStatus(notificationSettings.authorizationStatus) let issueCount = SettingsDiagnostics.issueCount( - gatewayConnected: self.gatewayConnected, + gatewayConnected: self.gatewayDiagnosticConnected, discoveredGatewayCount: self.gatewayController.gateways.count, - talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded, + talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded, notificationStatusText: self.notificationStatusText) self.diagnosticsIssueCount = issueCount self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date()) @@ -620,7 +623,53 @@ extension SettingsProTab { } var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected + !self.appModel.isAppleReviewDemoModeEnabled && + GatewayStatusBuilder.build(appModel: self.appModel) == .connected + } + + var gatewayStatusDetail: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" } + return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText + } + + var gatewayStatusValue: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.gatewayConnected ? "online" : "offline" + } + + var gatewayStatusColor: Color { + if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent } + return self.gatewayConnected ? OpenClawBrand.ok : .secondary + } + + var gatewayDiagnosticConnected: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected + } + + var gatewayDiagnosticTalkConfigLoaded: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded + } + + var approvalEmptyDetail: String { + if self.appModel.isAppleReviewDemoModeEnabled { + return "Live gateway requests are disabled in demo mode." + } + return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway." + } + + var gatewayTalkConfigDetail: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" } + return self.appModel.talkMode.gatewayTalkTransportLabel + } + + var gatewayTalkConfigValue: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing" + } + + var gatewayTalkConfigColor: Color { + if self.appModel.isAppleReviewDemoModeEnabled { return .secondary } + return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary } var gatewayAddress: String { @@ -679,6 +728,7 @@ extension SettingsProTab { } var diagnosticsHealthValue: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } if self.gatewayConnected { return "ready" } if self.gatewayController.gateways.isEmpty { return "check" } return "partial" diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 687bc70c09e6..dda26bba17c5 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -60,14 +60,14 @@ extension SettingsProTab { HStack(spacing: 12) { ProIconBadge( systemName: "antenna.radiowaves.left.and.right", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary) + color: self.gatewayStatusColor) VStack(alignment: .leading, spacing: 3) { Text("Connection") .font(.subheadline.weight(.semibold)) - Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText) + Text(self.gatewayStatusDetail) .font(.caption) - .foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary) + .foregroundStyle(self.gatewayStatusColor) } Spacer(minLength: 8) @@ -100,7 +100,8 @@ extension SettingsProTab { title: "Reconnect", icon: "arrow.triangle.2.circlepath", color: OpenClawBrand.warn, - isBusy: self.isReconnectingGateway) + isBusy: self.isReconnectingGateway, + isDisabled: self.appModel.isAppleReviewDemoModeEnabled) { Task { await self.reconnectGateway() } } @@ -234,9 +235,9 @@ extension SettingsProTab { self.detailStatusCard( icon: "antenna.radiowaves.left.and.right", title: "Gateway", - detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText, - value: self.gatewayConnected ? "online" : "offline", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary) + detail: self.gatewayStatusDetail, + value: self.gatewayStatusValue, + color: self.gatewayStatusColor) self.detailListCard { self.detailRow("Address", value: self.gatewayAddress) @@ -335,8 +336,7 @@ extension SettingsProTab { VStack(alignment: .leading, spacing: 3) { Text("No approvals waiting") .font(.subheadline.weight(.semibold)) - Text(self - .gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.") + Text(self.approvalEmptyDetail) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) @@ -390,7 +390,7 @@ extension SettingsProTab { title: "Health Check", detail: "Run app, permission, and gateway-adjacent checks without editing setup.", value: self.diagnosticsHealthValue, - color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn) + color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn) ProCard(radius: SettingsLayout.cardRadius) { self.gatewayActionButton( @@ -504,6 +504,7 @@ extension SettingsProTab { icon: String, color: Color, isBusy: Bool, + isDisabled: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { @@ -525,7 +526,7 @@ extension SettingsProTab { } } .buttonStyle(.plain) - .disabled(isBusy) + .disabled(isBusy || isDisabled) } func toggleCard( @@ -764,8 +765,13 @@ extension SettingsProTab { self.appModel.setVoiceWakeEnabled(enabled) } self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in + guard !self.appModel.isAppleReviewDemoModeEnabled else { + self.talkEnabled = false + return + } self.appModel.setTalkEnabled(enabled) } + .disabled(self.appModel.isAppleReviewDemoModeEnabled) Picker("Speech Language", selection: self.$talkSpeechLocale) { ForEach(TalkSpeechLocale.supportedOptions()) { option in Text(option.label).tag(option.id) diff --git a/apps/ios/Sources/Design/TalkProTab.swift b/apps/ios/Sources/Design/TalkProTab.swift index 938aefba3f19..6423c3b755ed 100644 --- a/apps/ios/Sources/Design/TalkProTab.swift +++ b/apps/ios/Sources/Design/TalkProTab.swift @@ -13,6 +13,7 @@ struct TalkProTab: View { private var state: TalkProState { TalkProState( gatewayConnected: self.gatewayConnected, + isDemoMode: self.appModel.isAppleReviewDemoModeEnabled, isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled, statusText: self.appModel.talkMode.statusText, isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded, @@ -282,7 +283,8 @@ struct TalkProTab: View { } private var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected + !self.appModel.isAppleReviewDemoModeEnabled && + GatewayStatusBuilder.build(appModel: self.appModel) == .connected } private var headerSubtitle: String { @@ -296,6 +298,7 @@ struct TalkProTab: View { private var heroSubtitle: String { if self.state .prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." } + if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." } if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." } if !self.appModel.talkMode.gatewayTalkConfigLoaded { return "Open Voice settings after the gateway loads Talk configuration." @@ -327,10 +330,14 @@ struct TalkProTab: View { } private func alignPersistedTalkState() { - if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction, + if self.appModel.isAppleReviewDemoModeEnabled, self.talkEnabled || self.appModel.talkMode.isEnabled { self.stopTalk() + } else if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction, + self.talkEnabled || self.appModel.talkMode.isEnabled + { + self.stopTalk() } else if self.talkEnabled != self.appModel.talkMode.isEnabled { self.appModel.setTalkEnabled(self.talkEnabled) } @@ -362,6 +369,7 @@ struct TalkProTab: View { } private func startTalk() { + guard !self.appModel.isAppleReviewDemoModeEnabled else { return } self.talkEnabled = true self.appModel.talkMode.updateMainSessionKey(self.appModel.chatSessionKey) self.appModel.setTalkEnabled(true) @@ -391,6 +399,7 @@ enum TalkProWaveformMode: Equatable { struct TalkProState: Equatable { let gatewayConnected: Bool + let isDemoMode: Bool let isEnabled: Bool let statusText: String let isConfigLoaded: Bool @@ -404,6 +413,7 @@ struct TalkProState: Equatable { } var title: String { + if self.isDemoMode { return "Demo mode only" } if !self.gatewayConnected { return "Gateway offline" } switch self.permissionState { case .missingScope, .requestFailed: @@ -429,6 +439,7 @@ struct TalkProState: Equatable { } var chipText: String { + if self.isDemoMode { return "Demo" } if !self.gatewayConnected { return "Offline" } switch self.permissionState { case .missingScope, .requestFailed: @@ -450,6 +461,7 @@ struct TalkProState: Equatable { } var icon: String { + if self.isDemoMode { return "waveform.slash" } if !self.gatewayConnected { return "wifi.slash" } switch self.permissionState { case .missingScope, .requestFailed: @@ -472,6 +484,7 @@ struct TalkProState: Equatable { } var color: Color { + if self.isDemoMode { return .secondary } if !self.gatewayConnected { return .secondary } switch self.permissionState { case .requestFailed, .loadFailed: @@ -485,6 +498,7 @@ struct TalkProState: Equatable { } var primaryAction: TalkProPrimaryAction { + if self.isDemoMode { return .waiting } if !self.gatewayConnected { return .openSettings } switch self.permissionState { case .missingScope, .requestFailed: @@ -504,7 +518,7 @@ struct TalkProState: Equatable { case .stop: "Stop Talk" case .enablePermission: "Enable Talk" case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings" - case .waiting: "Waiting for Approval" + case .waiting: self.isDemoMode ? "Demo Mode Only" : "Waiting for Approval" } } @@ -514,7 +528,7 @@ struct TalkProState: Equatable { case .stop: "stop.fill" case .enablePermission: "key.fill" case .openSettings: "gearshape.fill" - case .waiting: "hourglass" + case .waiting: self.isDemoMode ? "lock.fill" : "hourglass" } } @@ -542,6 +556,7 @@ struct TalkProState: Equatable { } func waveformMode(micLevel: Double) -> TalkProWaveformMode { + if self.isDemoMode { return .still } if !self.gatewayConnected { return .still } switch self.permissionState { case .requestingUpgrade, .upgradeRequested: diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 07e845c106e9..8c150c6317d5 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -2790,6 +2790,8 @@ extension NodeAppModel { self.activeGatewayConnectConfig = nil self.gatewayConnected = true self.setOperatorConnected(false) + UserDefaults.standard.set(false, forKey: "talk.enabled") + UserDefaults.standard.set(false, forKey: "talk.background.enabled") self.talkMode.updateGatewayConnected(false) self.talkMode.setEnabled(false) self.talkMode.statusText = "Demo mode only" diff --git a/apps/ios/Tests/TalkProStateTests.swift b/apps/ios/Tests/TalkProStateTests.swift index 3d00aee697f4..997905356492 100644 --- a/apps/ios/Tests/TalkProStateTests.swift +++ b/apps/ios/Tests/TalkProStateTests.swift @@ -5,6 +5,7 @@ import Testing @Test func disabledTalkWithoutLoadedConfigCanStartAndRetryLoad() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: false, statusText: "Offline", isConfigLoaded: false, @@ -23,6 +24,7 @@ import Testing @Test func enabledTalkWithoutLoadedConfigCanBeStopped() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: true, statusText: "Offline", isConfigLoaded: false, @@ -41,6 +43,7 @@ import Testing @Test func enabledTalkWithLoadedConfigCanBeStopped() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: true, statusText: "Ready", isConfigLoaded: true, @@ -57,6 +60,7 @@ import Testing @Test func missingScopeTakesPriorityOverUnloadedConfig() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: false, statusText: "Offline", isConfigLoaded: false, @@ -70,4 +74,25 @@ import Testing #expect(state.primaryAction == .enablePermission) #expect(state.primaryButtonTitle == "Enable Talk") } + + @Test func demoModeKeepsTalkDisabled() { + let state = TalkProState( + gatewayConnected: true, + isDemoMode: true, + isEnabled: true, + statusText: "Ready", + isConfigLoaded: true, + isListening: true, + isSpeaking: true, + isUserSpeechDetected: true, + permissionState: .ready) + + #expect(state.title == "Demo mode only") + #expect(state.chipText == "Demo") + #expect(state.icon == "waveform.slash") + #expect(state.primaryAction == .waiting) + #expect(state.primaryButtonTitle == "Demo Mode Only") + #expect(state.primaryButtonIcon == "lock.fill") + #expect(state.waveformMode(micLevel: 0.8) == .still) + } }