mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
[codex] Add iOS Apple Review demo mode (#90919)
Merged via squash.
Prepared head SHA: e7f7db3cb5
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
parent
bb056dca84
commit
59ed6413d9
27 changed files with 698 additions and 82 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
253
apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift
Normal file
253
apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift
Normal file
|
|
@ -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<OpenClawChatTransportEvent> {
|
||||
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<T: Decodable>(_ 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?
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "{}",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -107,12 +107,5 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue