From 59ed6413d9cf3fc12b92a74013237fe7244ed9d2 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sat, 6 Jun 2026 17:43:48 +0300 Subject: [PATCH] [codex] Add iOS Apple Review demo mode (#90919) Merged via squash. Prepared head SHA: e7f7db3cb54c71920589d8d3439770ada3df8767 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- apps/ios/CHANGELOG.md | 4 +- apps/ios/README.md | 6 +- .../Chat/AppleReviewDemoChatTransport.swift | 253 ++++++++++++++++++ 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 +- apps/ios/Sources/Design/ChatProTab.swift | 33 ++- .../ios/Sources/Design/CommandCenterTab.swift | 44 ++- apps/ios/Sources/Design/SettingsProTab.swift | 3 + .../Design/SettingsProTabActions.swift | 91 ++++++- .../Design/SettingsProTabSections.swift | 28 +- apps/ios/Sources/Design/TalkProTab.swift | 23 +- apps/ios/Sources/Info.plist | 7 - apps/ios/Sources/Model/NodeAppModel.swift | 81 +++++- .../Onboarding/GatewayOnboardingView.swift | 7 + .../Onboarding/OnboardingWizardView.swift | 103 ++++++- .../Sources/Onboarding/QRScannerView.swift | 8 + apps/ios/SwiftSources.input.xcfilelist | 1 + apps/ios/Tests/TalkProStateTests.swift | 25 ++ .../fastlane/metadata/en-US/description.txt | 8 +- .../metadata/en-US/promotional_text.txt | 2 +- .../fastlane/metadata/en-US/release_notes.txt | 4 +- .../metadata/review_information/notes.txt | 4 +- apps/ios/project.yml | 10 +- docs/platforms/ios.md | 2 +- 27 files changed, 698 insertions(+), 82 deletions(-) create mode 100644 apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index ea7bd40d8c0..be4184c92c3 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -2,7 +2,9 @@ ## 2026.6.2 - 2026-06-02 -Maintenance update for the current OpenClaw release. +OpenClaw is now available on iPhone. + +Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows. ## 2026.6.1 - 2026-06-01 diff --git a/apps/ios/README.md b/apps/ios/README.md index 9389c77f892..28df7b4af0d 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,6 +1,6 @@ # OpenClaw iOS (Super Alpha) -This iOS app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node` on iPhone and iPad. +This iOS app is super-alpha and internal-use only. The first public App Store release targets iPhone and connects to an OpenClaw Gateway as a `role: node`. ## Distribution Status @@ -34,7 +34,7 @@ open OpenClaw.xcodeproj 3. In Xcode: - Scheme: `OpenClaw` - - Destination: connected iPhone or iPad (recommended for real behavior) + - Destination: connected iPhone (recommended for real behavior) - Build configuration: `Debug` - Run (`Product` -> `Run`) 4. If signing fails on a personal team: @@ -251,7 +251,7 @@ gateway can only send pushes for iOS devices that paired with that gateway. ## Computer Use Relationship -The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone or iPad canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits. +The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits. ## Location Automation Use Case (Testing) diff --git a/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift b/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift new file mode 100644 index 00000000000..139216002c5 --- /dev/null +++ b/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift @@ -0,0 +1,253 @@ +import Foundation +import OpenClawChatUI +import OpenClawProtocol + +enum AppleReviewDemoMode { + static let setupCode = "APPLE-REVIEW-DEMO" + static let gatewayName = "Apple Review Demo Gateway" + static let gatewayAddress = "Local demo mode" + static let gatewayID = "apple-review-demo" + + static func isSetupCode(_ value: String) -> Bool { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(self.setupCode) == .orderedSame + } + + static var agents: [AgentSummary] { + [ + AgentSummary( + id: "main", + name: "Main", + identity: ["emoji": AnyCodable("OC")], + workspace: "Apple Review Demo", + model: ["provider": AnyCodable("demo"), "model": AnyCodable("local-demo")], + agentruntime: ["kind": AnyCodable("local")], + thinkinglevels: nil, + thinkingoptions: ["auto", "low", "medium"], + thinkingdefault: "auto"), + ] + } +} + +struct AppleReviewDemoChatTransport: OpenClawChatTransport { + private let store = AppleReviewDemoChatStore() + + func createSession( + key: String, + label _: String?, + parentSessionKey _: String?) async throws -> OpenClawChatCreateSessionResponse + { + try await self.store.createSession(key: key) + } + + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + try await self.store.history(sessionKey: sessionKey) + } + + func listModels() async throws -> [OpenClawChatModelChoice] { + [ + OpenClawChatModelChoice( + modelID: "local-demo", + name: "Apple Review Demo", + provider: "demo", + contextWindow: 128_000), + ] + } + + func sendMessage( + sessionKey: String, + message: String, + thinking _: String, + idempotencyKey: String, + attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + try await self.store.sendMessage( + sessionKey: sessionKey, + message: message, + runId: idempotencyKey) + } + + func abortRun(sessionKey _: String, runId _: String) async throws {} + + func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { + try await self.store.sessions() + } + + func setSessionModel(sessionKey _: String, model _: String?) async throws {} + + func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {} + + func requestHealth(timeoutMs _: Int) async throws -> Bool { + true + } + + func waitForRunCompletion(runId _: String, timeoutMs _: Int) async -> Bool { + true + } + + func events() -> AsyncStream { + AsyncStream { continuation in + continuation.yield(.health(ok: true)) + continuation.finish() + } + } + + func setActiveSessionKey(_: String) async throws {} + + func resetSession(sessionKey _: String) async throws { + await self.store.reset() + } + + func compactSession(sessionKey _: String) async throws {} +} + +private actor AppleReviewDemoChatStore { + private let sessionKey = "main" + private var messages: [OpenClawChatMessage] + + init() { + self.messages = AppleReviewDemoChatStore.seedMessages() + } + + func createSession(key: String) throws -> OpenClawChatCreateSessionResponse { + try Self.decode( + CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"), + as: OpenClawChatCreateSessionResponse.self) + } + + func history(sessionKey: String) throws -> OpenClawChatHistoryPayload { + let normalizedSessionKey = Self.normalizedSessionKey(sessionKey) + return try Self.decode( + HistoryPayload( + sessionKey: normalizedSessionKey, + sessionId: "apple-review-demo-\(normalizedSessionKey)", + messages: self.messages, + thinkingLevel: "auto"), + as: OpenClawChatHistoryPayload.self) + } + + func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse { + let now = Date().timeIntervalSince1970 * 1000 + self.messages.append(Self.message(role: "user", text: message, timestamp: now)) + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\"" + self.messages.append( + Self.message( + role: "assistant", + text: """ + Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \ + selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \ + OpenClaw Gateway. + """, + timestamp: now + 1)) + return try Self.decode( + SendPayload(runId: runId, status: "ok"), + as: OpenClawChatSendResponse.self) + } + + func sessions() throws -> OpenClawChatSessionsListResponse { + let entry = OpenClawChatSessionEntry( + key: self.sessionKey, + kind: "chat", + displayName: "Apple Review Demo", + surface: "ios", + subject: "Gateway review flow", + room: nil, + space: nil, + updatedAt: Date().timeIntervalSince1970 * 1000, + sessionId: "apple-review-demo-main", + systemSent: true, + abortedLastRun: false, + thinkingLevel: "auto", + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: "demo", + model: "local-demo", + contextTokens: 128_000, + thinkingLevels: [ + OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"), + OpenClawChatThinkingLevelOption(id: "low", label: "Low"), + OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"), + ], + thinkingOptions: ["auto", "low", "medium"], + thinkingDefault: "auto") + return OpenClawChatSessionsListResponse( + ts: Date().timeIntervalSince1970 * 1000, + path: nil, + count: 1, + defaults: OpenClawChatSessionsDefaults( + modelProvider: "demo", + model: "local-demo", + contextTokens: 128_000, + thinkingLevels: [ + OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"), + OpenClawChatThinkingLevelOption(id: "low", label: "Low"), + OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"), + ], + thinkingOptions: ["auto", "low", "medium"], + thinkingDefault: "auto", + mainSessionKey: self.sessionKey), + sessions: [entry]) + } + + func reset() { + self.messages = Self.seedMessages() + } + + private static func seedMessages() -> [OpenClawChatMessage] { + let now = Date().timeIntervalSince1970 * 1000 + return [ + self.message( + role: "assistant", + text: """ + Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \ + without a private Gateway. + """, + timestamp: now), + ] + } + + private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage { + OpenClawChatMessage( + role: role, + content: [ + OpenClawChatMessageContent( + type: "text", + text: text, + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: timestamp) + } + + private static func normalizedSessionKey(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "main" : trimmed + } + + private static func decode(_ value: some Encodable, as type: T.Type) throws -> T { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(type, from: data) + } + + private struct HistoryPayload: Encodable { + var sessionKey: String + var sessionId: String? + var messages: [OpenClawChatMessage]? + var thinkingLevel: String? + } + + private struct SendPayload: Encodable { + var runId: String + var status: String + } + + private struct CreateSessionPayload: Encodable { + var ok: Bool? + var key: String + var sessionId: String? + } +} diff --git a/apps/ios/Sources/Design/AgentProModels.swift b/apps/ios/Sources/Design/AgentProModels.swift index 79e0c8f810e..f2ad4df54fd 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 0c0746153bc..f0a85c31b5f 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 f03eb361a4f..f6dee7e20f3 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 93c60f21654..5ef313383a1 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 9002f5e9f83..70f966a187a 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/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index 9b2ef8742e3..a7006a80434 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -6,6 +6,7 @@ struct ChatProTab: View { @Environment(NodeAppModel.self) private var appModel @Environment(\.colorScheme) private var colorScheme @State private var viewModel: OpenClawChatViewModel? + @State private var viewModelUsesAppleReviewDemoTransport = false var body: some View { NavigationStack { @@ -27,6 +28,7 @@ struct ChatProTab: View { isComposerEnabled: self.gatewayConnected, messagePlaceholder: self.messagePlaceholder, talkControl: self.talkControl) + .id(ObjectIdentifier(viewModel)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } else { ProCard { @@ -53,6 +55,10 @@ struct ChatProTab: View { .onChange(of: self.appModel.chatSessionKey) { _, _ in self.syncChatViewModel() } + .onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in + self.syncChatViewModel() + self.viewModel?.refresh() + } .onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in guard connected else { return } self.syncChatViewModel() @@ -102,10 +108,29 @@ struct ChatProTab: View { private func syncChatViewModel() { let sessionKey = self.appModel.chatSessionKey + let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled guard let viewModel else { + self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport self.viewModel = OpenClawChatViewModel( sessionKey: sessionKey, - transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession), + transport: usesDemoTransport + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession), + onSessionChanged: { sessionKey in + self.appModel.focusChatSession(sessionKey) + }, + diagnosticsLog: { message in + GatewayDiagnostics.log(message) + }) + return + } + if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport { + self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport + self.viewModel = OpenClawChatViewModel( + sessionKey: sessionKey, + transport: usesDemoTransport + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession), onSessionChanged: { sessionKey in self.appModel.focusChatSession(sessionKey) }, @@ -158,8 +183,10 @@ struct ChatProTab: View { } private var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected && - self.appModel.isOperatorGatewayConnected + guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else { + return false + } + return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected } private var messagePlaceholder: String { diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index 16eb64c894d..2bc43eac1ca 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -308,12 +308,21 @@ struct CommandCenterTab: View { private var recentSessionsRefreshID: String { [ - self.appModel.isOperatorGatewayConnected ? "connected" : "offline", + self.sessionListMode, self.appModel.chatSessionKey, self.scenePhase == .active ? "active" : "inactive", ].joined(separator: ":") } + private var sessionListAvailable: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected + } + + private var sessionListMode: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.appModel.isOperatorGatewayConnected ? "operator" : "offline" + } + private var sessionItems: [WorkItem] { self.sessionWorkItems } @@ -339,7 +348,7 @@ struct CommandCenterTab: View { private func refreshRecentSessionsIfNeeded() async { guard self.scenePhase == .active else { return } - guard self.appModel.isOperatorGatewayConnected else { + guard self.sessionListAvailable else { if self.defaultChatSessionEntry != nil { self.defaultChatSessionEntry = nil } @@ -350,7 +359,9 @@ struct CommandCenterTab: View { } do { - let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession) + let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession) let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit) self.defaultChatSessionEntry = response.sessions.first { $0.key == self.appModel.defaultChatSessionKey @@ -612,10 +623,10 @@ private struct CommandSessionsScreen: View { } else if self.sessionRows.isEmpty { CommandEmptyStateRow( icon: self.appModel - .isOperatorGatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash", - title: self.appModel.isOperatorGatewayConnected ? "No recent sessions" : "Gateway offline", + .isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash", + title: self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline", detail: self.appModel - .isOperatorGatewayConnected ? "Start a chat and it will appear here." : + .isCommandSessionListAvailable ? "Start a chat and it will appear here." : "Connect to the gateway.") .padding(.horizontal, 10) .padding(.bottom, 10) @@ -642,7 +653,7 @@ private struct CommandSessionsScreen: View { if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" } let count = self.sessionRows.count if count == 0 { - return self.appModel.isOperatorGatewayConnected ? "No recent sessions" : "Gateway offline" + return self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline" } return "\(count) \(count == 1 ? "session" : "sessions")" } @@ -661,7 +672,7 @@ private struct CommandSessionsScreen: View { } private var refreshID: String { - self.appModel.isOperatorGatewayConnected ? "connected" : "offline" + self.appModel.commandSessionListMode } private func open(_ item: CommandCenterTab.WorkItem) { @@ -676,7 +687,7 @@ private struct CommandSessionsScreen: View { } private func refreshSessions() async { - guard self.appModel.isOperatorGatewayConnected else { + guard self.appModel.isCommandSessionListAvailable else { self.sessions = [] self.loadErrorText = nil return @@ -687,7 +698,9 @@ private struct CommandSessionsScreen: View { defer { self.isLoading = false } do { - let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession) + let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession) let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit) self.sessions = response.sessions } catch { @@ -696,3 +709,14 @@ private struct CommandSessionsScreen: View { } } } + +extension NodeAppModel { + fileprivate var isCommandSessionListAvailable: Bool { + self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected + } + + fileprivate var commandSessionListMode: String { + if self.isAppleReviewDemoModeEnabled { return "demo" } + return self.isOperatorGatewayConnected ? "operator" : "offline" + } +} diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index 1906ae7549c..389534f5932 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -135,6 +135,9 @@ struct SettingsProTab: View { onGatewayLink: { link in self.handleScannedGatewayLink(link) }, + onSetupCode: { code in + self.handleScannedSetupCode(code) + }, onError: { error in self.showQRScanner = false self.setupStatusText = "Scanner error: \(error)" diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 5e13a0ed0c8..e66f5e6f992 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()) @@ -220,6 +223,14 @@ extension SettingsProTab { return false } + if AppleReviewDemoMode.isSetupCode(raw) { + self.stagedGatewaySetupLink = nil + self.setupCode = "" + self.setupStatusText = "Apple Review demo mode enabled." + self.appModel.enterAppleReviewDemoMode() + return false + } + guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return false @@ -272,6 +283,15 @@ extension SettingsProTab { Task { await self.connectAfterScannedGatewayLink() } } + func handleScannedSetupCode(_ code: String) { + guard AppleReviewDemoMode.isSetupCode(code) else { return } + self.showQRScanner = false + self.setupCode = "" + self.stagedGatewaySetupLink = nil + self.setupStatusText = "Apple Review demo mode enabled." + self.appModel.enterAppleReviewDemoMode() + } + func connectAfterScannedGatewayLink() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard let port = self.resolvedManualPort(host: host) else { @@ -603,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 { @@ -662,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 687bc70c09e..dda26bba17c 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 938aefba3f1..6423c3b755e 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/Info.plist b/apps/ios/Sources/Info.plist index 97a954d7e7d..5e45dcad40f 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -107,12 +107,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ee115d164b3..8c150c6317d 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -111,6 +111,7 @@ final class NodeAppModel { var gatewayStatusText: String = "Offline" var nodeStatusText: String = "Offline" var operatorStatusText: String = "Offline" + private(set) var isAppleReviewDemoModeEnabled: Bool = false var isOperatorGatewayConnected: Bool { self.operatorConnected } @@ -458,6 +459,7 @@ final class NodeAppModel { await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.gatewayConnected = false // Foreground recovery must actively restart the saved gateway config. @@ -549,6 +551,7 @@ final class NodeAppModel { await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.gatewayConnected = false self.talkMode.updateGatewayConnected(false) @@ -586,6 +589,12 @@ final class NodeAppModel { } func setTalkEnabled(_ enabled: Bool) { + if self.isAppleReviewDemoModeEnabled { + UserDefaults.standard.set(false, forKey: "talk.enabled") + self.talkMode.setEnabled(false) + self.talkMode.statusText = "Demo mode only" + return + } UserDefaults.standard.set(enabled, forKey: "talk.enabled") if enabled { // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT. @@ -911,6 +920,7 @@ final class NodeAppModel { await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.gatewayConnected = false self.gatewayStatusText = "Reconnecting…" @@ -1958,6 +1968,7 @@ extension NodeAppModel { /// Preferred entry-point: apply a single config object and start both sessions. func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) { + self.isAppleReviewDemoModeEnabled = false self.connectToGateway( url: cfg.url, // Preserve the caller-provided stableID (may be empty) and let connectToGateway @@ -1981,6 +1992,7 @@ extension NodeAppModel { } func disconnectGateway() { + self.isAppleReviewDemoModeEnabled = false self.gatewayAutoReconnectEnabled = false self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil @@ -2016,6 +2028,7 @@ extension NodeAppModel { extension NodeAppModel { private func prepareForGatewayConnect(url: URL, stableID: String) { + self.isAppleReviewDemoModeEnabled = false self.gatewayAutoReconnectEnabled = true self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil @@ -2058,6 +2071,7 @@ extension NodeAppModel { } private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { + guard !self.isAppleReviewDemoModeEnabled else { return } self.lastGatewayProblem = problem self.gatewayStatusText = problem.statusText self.gatewayServerName = nil @@ -2083,6 +2097,7 @@ extension NodeAppModel { } private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { + guard !self.isAppleReviewDemoModeEnabled else { return } self.operatorGatewayProblem = problem self.lastGatewayProblem = problem self.gatewayStatusText = problem.statusText @@ -2314,12 +2329,15 @@ extension NodeAppModel { sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } - await MainActor.run { + let shouldUseConnection = await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return false } self.setOperatorConnected(true) self.clearOperatorGatewayConnectionProblemIfCurrent() self.forceOperatorTalkPermissionUpgradeRequest = false self.talkMode.updateGatewayConnected(true) + return true } + guard shouldUseConnection else { return } GatewayDiagnostics.log( "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") await self.talkMode.reloadConfig() @@ -2334,6 +2352,7 @@ extension NodeAppModel { onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) LiveActivityManager.shared.endActivity(reason: "operator_disconnected") @@ -2356,8 +2375,9 @@ extension NodeAppModel { } catch { attempt += 1 GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)") - let problem = await MainActor.run { + let problem: GatewayConnectionProblem? = await MainActor.run { let nextProblem = GatewayConnectionProblemMapper.map(error: error) + guard !self.isAppleReviewDemoModeEnabled else { return nil } if let nextProblem { if nextProblem.needsPairingApproval || nextProblem.pauseReconnect { self.applyOperatorGatewayConnectionProblem(nextProblem) @@ -2423,6 +2443,7 @@ extension NodeAppModel { continue } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" self.gatewayServerName = nil self.gatewayRemoteAddress = nil @@ -2449,7 +2470,8 @@ extension NodeAppModel { sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } - await MainActor.run { + let shouldUseConnection = await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return false } self.clearGatewayConnectionProblem() self.gatewayStatusText = "Connected" self.gatewayServerName = url.host ?? "gateway" @@ -2457,7 +2479,9 @@ extension NodeAppModel { self.screen.errorText = nil UserDefaults.standard.set(true, forKey: "gateway.autoconnect") LiveActivityManager.shared.handleReconnect() + return true } + guard shouldUseConnection else { return } let usedBootstrapToken = reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2506,6 +2530,7 @@ extension NodeAppModel { onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason), let lastGatewayProblem = self.lastGatewayProblem { @@ -2551,10 +2576,11 @@ extension NodeAppModel { } attempt += 1 - let problem = await MainActor.run { + let problem: GatewayConnectionProblem? = await MainActor.run { let nextProblem = GatewayConnectionProblemMapper.map( error: error, preserving: self.lastGatewayProblem) + guard !self.isAppleReviewDemoModeEnabled else { return nil } if let nextProblem { self.applyGatewayConnectionProblem(nextProblem) } else { @@ -2595,6 +2621,7 @@ extension NodeAppModel { } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.lastGatewayProblem = nil self.gatewayStatusText = "Offline" LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped") @@ -2733,6 +2760,52 @@ extension NodeAppModel { } } +extension NodeAppModel { + func enterAppleReviewDemoMode() { + self.isAppleReviewDemoModeEnabled = true + self.gatewayAutoReconnectEnabled = false + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + self.lastGatewayProblem = nil + self.operatorGatewayProblem = nil + self.nodeGatewayTask?.cancel() + self.nodeGatewayTask = nil + self.operatorGatewayTask?.cancel() + self.operatorGatewayTask = nil + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil + self.gatewayHealthMonitor.stop() + LiveActivityManager.shared.endActivity(reason: "apple_review_demo") + + Task { + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + } + + self.gatewayStatusText = "Connected" + self.nodeStatusText = "Connected" + self.gatewayServerName = AppleReviewDemoMode.gatewayName + self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress + self.connectedGatewayID = AppleReviewDemoMode.gatewayID + 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" + self.seamColorHex = nil + self.mainSessionBaseKey = "main" + self.selectedAgentId = nil + self.gatewayDefaultAgentId = "main" + self.gatewayAgents = AppleReviewDemoMode.agents + self.focusedChatSessionKey = nil + self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 + } +} + extension NodeAppModel { private struct PendingForegroundNodeAction: Decodable { var id: String diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index 1b1016f848f..51cca245dd4 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -255,6 +255,13 @@ private struct ManualEntryStep: View { return } + if AppleReviewDemoMode.isSetupCode(raw) { + self.setupCode = "" + self.setupStatusText = "Apple Review demo mode enabled." + self.appModel.enterAppleReviewDemoMode() + return + } + guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 3d06bfc1fb2..603cda9f239 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -72,6 +72,8 @@ struct OnboardingWizardView: View { @State private var showGatewayProblemDetails: Bool = false @State private var lastPairingAutoResumeAttemptAt: Date? @State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? + @State private var setupCode: String = "" + @State private var setupCodeStatus: String? private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() let allowSkip: Bool @@ -172,6 +174,9 @@ struct OnboardingWizardView: View { onGatewayLink: { link in self.handleScannedLink(link) }, + onSetupCode: { code in + self.handleScannedSetupCode(code) + }, onError: { error in self.showQRScanner = false self.statusLine = "Scanner error: \(error)" @@ -208,6 +213,10 @@ struct OnboardingWizardView: View { self.handleScannedLink(link) return } + if AppleReviewDemoMode.isSetupCode(message) { + self.handleScannedSetupCode(message) + return + } } self.showQRScanner = false self.scannerError = "No valid QR code found in the selected image." @@ -301,6 +310,8 @@ struct OnboardingWizardView: View { @ViewBuilder private var modeStep: some View { + self.setupCodeSection + Section("Connection Mode") { OnboardingModeRow( title: OnboardingConnectionMode.homeNetwork.title, @@ -604,6 +615,44 @@ struct OnboardingWizardView: View { } extension OnboardingWizardView { + private var setupCodeSection: some View { + Section { + TextField("Paste setup code", text: self.$setupCode) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { + Task { await self.applySetupCodeAndConnect() } + } + + Button { + Task { await self.applySetupCodeAndConnect() } + } label: { + if self.connectingGatewayID == "setup-code" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Applying...") + } + } else { + Text("Apply Setup Code") + } + } + .disabled( + self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || self.connectingGatewayID != nil) + + if let setupCodeStatus, !setupCodeStatus.isEmpty { + Text(setupCodeStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + } header: { + Text("Setup Code") + } footer: { + Text("Use this if you received a setup code instead of a QR code.") + } + } + private func manualConnectionFieldsSection(title: String) -> some View { Section(title) { TextField("Host", text: self.$manualHost) @@ -642,9 +691,49 @@ extension OnboardingWizardView { .disabled(!self.canConnectManual || self.connectingGatewayID != nil) } + private func applySetupCodeAndConnect() async { + self.setupCodeStatus = nil + let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { + self.setupCodeStatus = "Paste a setup code to continue." + return + } + + if AppleReviewDemoMode.isSetupCode(raw) { + self.setupCode = "" + self.setupCodeStatus = "Apple Review demo mode enabled." + self.handleScannedSetupCode(raw) + return + } + + guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { + self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL." + return + } + + self.connectingGatewayID = "setup-code" + self.applyGatewayLink(link) + self.setupCode = "" + self.setupCodeStatus = "Setup code applied. Connecting..." + self.connectMessage = "Connecting via setup code..." + self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..." + self.step = .connect + await self.connectManual() + } + private func handleScannedLink(_ link: GatewayConnectDeepLink) { + self.applyGatewayLink(link) + self.setupCodeStatus = nil + self.showQRScanner = false + self.connectMessage = "Connecting via QR code..." + self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..." + Task { await self.connectManual() } + } + + private func applyGatewayLink(_ link: GatewayConnectDeepLink) { self.manualHost = link.host self.manualPort = link.port + self.manualPortText = String(link.port) self.manualTLS = link.tls let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) if setupAuth.hasBootstrapToken { @@ -661,13 +750,19 @@ extension OnboardingWizardView { } self.pendingManualAuthOverride = setupAuth.manualAuthOverride self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) - self.showQRScanner = false - self.connectMessage = "Connecting via QR code…" - self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…" if self.selectedMode == nil { self.selectedMode = link.tls ? .remoteDomain : .homeNetwork } - Task { await self.connectManual() } + } + + private func handleScannedSetupCode(_ code: String) { + guard AppleReviewDemoMode.isSetupCode(code) else { return } + self.showQRScanner = false + self.connectingGatewayID = nil + self.connectMessage = "Apple Review demo mode enabled." + self.statusLine = "Apple Review demo mode enabled." + self.selectedMode = .homeNetwork + self.appModel.enterAppleReviewDemoMode() } private func openQRScannerFromOnboarding() { diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift index 97cc22b8e3c..249a5cf1c70 100644 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -4,6 +4,7 @@ import VisionKit struct QRScannerView: UIViewControllerRepresentable { let onGatewayLink: (GatewayConnectDeepLink) -> Void + let onSetupCode: (String) -> Void let onError: (String) -> Void let onDismiss: () -> Void @@ -72,6 +73,13 @@ struct QRScannerView: UIViewControllerRepresentable { } return } + if AppleReviewDemoMode.isSetupCode(payload) { + self.handled = true + Task { @MainActor in + self.parent.onSetupCode(payload) + } + return + } } } diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index a2b6b3d651f..e73eadea216 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -1,6 +1,7 @@ Sources/Calendar/CalendarService.swift Sources/Camera/CameraController.swift Sources/Capabilities/NodeCapabilityRouter.swift +Sources/Chat/AppleReviewDemoChatTransport.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/Contacts/ContactsService.swift Sources/Device/DeviceInfoHelper.swift diff --git a/apps/ios/Tests/TalkProStateTests.swift b/apps/ios/Tests/TalkProStateTests.swift index 3d00aee697f..99790535649 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) + } } diff --git a/apps/ios/fastlane/metadata/en-US/description.txt b/apps/ios/fastlane/metadata/en-US/description.txt index 3a6dedb0bbd..0ca98a1f0c5 100644 --- a/apps/ios/fastlane/metadata/en-US/description.txt +++ b/apps/ios/fastlane/metadata/en-US/description.txt @@ -1,12 +1,12 @@ OpenClaw is a personal AI assistant you run on your own devices. -Pair this iOS app with your OpenClaw Gateway to use your iPhone or iPad as a secure node for chat, voice, approvals, sharing, and device-aware automation. +Pair this iOS app with your OpenClaw Gateway to use your iPhone as a secure node for chat, voice, approvals, sharing, and device-aware automation. What you can do: - Pair with your private OpenClaw Gateway by QR code or setup code -- Chat with your assistant from iPhone or iPad +- Chat with your assistant from iPhone - Use realtime Talk mode and push-to-talk -- Review Gateway action approvals from your iPhone or iPad +- Review Gateway action approvals from your iPhone - Share text, links, and media directly from iOS into OpenClaw - Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose - Receive push wakes and node status updates for connected workflows @@ -16,4 +16,4 @@ OpenClaw is local-first: you control your gateway, keys, configuration, and perm Getting started: 1) Set up your OpenClaw Gateway 2) Open the iOS app and pair with your gateway -3) Start using chat, Talk mode, approvals, and automations from your iPhone or iPad +3) Start using chat, Talk mode, approvals, and automations from your iPhone diff --git a/apps/ios/fastlane/metadata/en-US/promotional_text.txt b/apps/ios/fastlane/metadata/en-US/promotional_text.txt index a674636ebe3..0df778e5beb 100644 --- a/apps/ios/fastlane/metadata/en-US/promotional_text.txt +++ b/apps/ios/fastlane/metadata/en-US/promotional_text.txt @@ -1 +1 @@ -Pair your iPhone or iPad with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation. +Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation. diff --git a/apps/ios/fastlane/metadata/en-US/release_notes.txt b/apps/ios/fastlane/metadata/en-US/release_notes.txt index 99afd00b10b..581bab59456 100644 --- a/apps/ios/fastlane/metadata/en-US/release_notes.txt +++ b/apps/ios/fastlane/metadata/en-US/release_notes.txt @@ -1 +1,3 @@ -Maintenance update for the current OpenClaw release. +OpenClaw is now available on iPhone. + +Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows. diff --git a/apps/ios/fastlane/metadata/review_information/notes.txt b/apps/ios/fastlane/metadata/review_information/notes.txt index 22a99b207ce..e0e90d86632 100644 --- a/apps/ios/fastlane/metadata/review_information/notes.txt +++ b/apps/ios/fastlane/metadata/review_information/notes.txt @@ -1 +1,3 @@ -OpenClaw iOS client for gateway-connected workflows. Reviewers can follow the standard onboarding and pairing flow in-app. +OpenClaw normally pairs with a private Gateway. For App Review, tap Set Up Manually on the Connect Gateway screen, paste APPLE-REVIEW-DEMO in Setup Code, then tap Apply Setup Code. This enables local offline demo mode; no Gateway is required. Reviewers can also scan a QR code containing APPLE-REVIEW-DEMO. + +Demo mode marks the app as connected to an Apple Review Demo Gateway and exposes the Chat, Command, Agent, Talk, and Settings surfaces without requiring a running Gateway. Live automation, realtime Talk execution, and external tool calls require pairing with a real OpenClaw Gateway. diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2b26057250a..9d7a3fa1ab6 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -97,7 +97,7 @@ targets: DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)" PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)" - TARGETED_DEVICE_FAMILY: "1,2" + TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete SUPPORTS_LIVE_ACTIVITIES: YES @@ -163,12 +163,6 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad: - - UIInterfaceOrientationPortrait - - UIInterfaceOrientationPortraitUpsideDown - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight - OpenClawShareExtension: type: app-extension platform: iOS @@ -189,6 +183,7 @@ targets: ENABLE_APP_INTENTS_METADATA_GENERATION: NO PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)" PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)" + TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete info: @@ -225,6 +220,7 @@ targets: CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)" + TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete SUPPORTS_LIVE_ACTIVITIES: YES diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index af02d3808da..25e9741d07e 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -246,7 +246,7 @@ Notes: The iOS app is a mobile node surface, not a Codex Computer Use backend. Codex Computer Use and `cua-driver mcp` control a local macOS desktop through MCP -tools; the iOS app exposes iPhone and iPad capabilities through OpenClaw node commands +tools; the iOS app exposes iPhone capabilities through OpenClaw node commands such as `canvas.*`, `camera.*`, `screen.*`, `location.*`, and `talk.*`. Agents can still operate the iOS app through OpenClaw by invoking node