fix(ios): gate demo mode live gateway actions

This commit is contained in:
Nimrod Gutman
2026-06-06 17:15:18 +03:00
parent e16d463931
commit e7f7db3cb5
10 changed files with 150 additions and 35 deletions

View File

@@ -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:

View File

@@ -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) }

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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: "{}",

View File

@@ -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"

View File

@@ -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)

View File

@@ -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:

View File

@@ -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"

View File

@@ -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)
}
}