fix: harden ios app build hygiene

This commit is contained in:
Peter Steinberger
2026-04-28 01:41:59 +01:00
parent 2fe213ebf2
commit b294f7c467
97 changed files with 1150 additions and 1044 deletions

View File

@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
- Memory Core: stream fallback vector search scoring with a bounded top-K result set so large indexes do not materialize every chunk embedding when sqlite-vec is unavailable. (#73069) Thanks @parkertoddbrooks. - Memory Core: stream fallback vector search scoring with a bounded top-K result set so large indexes do not materialize every chunk embedding when sqlite-vec is unavailable. (#73069) Thanks @parkertoddbrooks.
- Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys. - Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys.
- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy. - macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy.
- iOS app: refresh SwiftPM/XcodeGen source hygiene, make app, extension, watch, and curated shared Swift files pass the prebuild SwiftFormat and SwiftLint checks, move relay registration off deprecated StoreKit receipt APIs, and keep simulator builds and logic tests warning-free. Thanks @ngutman.
- Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit. - Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit.
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev. - Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
- Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev. - Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev.

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72", "originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
"pins" : [ "pins" : [
{ {
"identity" : "commander", "identity" : "commander",
@@ -10,24 +10,6 @@
"version" : "0.2.1" "version" : "0.2.1"
} }
}, },
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{ {
"identity" : "swift-syntax", "identity" : "swift-syntax",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -45,24 +27,6 @@
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0" "version" : "0.99.0"
} }
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
"version" : "0.3.1"
}
} }
], ],
"version" : 3 "version" : 3

View File

@@ -13,7 +13,9 @@ public struct WakeWordSegment: Sendable, Equatable {
self.range = range self.range = range
} }
public var end: TimeInterval { start + duration } public var end: TimeInterval {
self.start + self.duration
}
} }
public struct WakeWordGateConfig: Sendable, Equatable { public struct WakeWordGateConfig: Sendable, Equatable {
@@ -24,7 +26,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init( public init(
triggers: [String], triggers: [String],
minPostTriggerGap: TimeInterval = 0.45, minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1) { minCommandLength: Int = 1)
{
self.triggers = triggers self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength self.minCommandLength = minCommandLength
@@ -78,10 +81,10 @@ public enum WakeWordGate {
segments: [WakeWordSegment], segments: [WakeWordSegment],
config: WakeWordGateConfig) config: WakeWordGateConfig)
-> WakeWordGateMatch? { -> WakeWordGateMatch? {
let triggerTokens = normalizeTriggers(config.triggers) let triggerTokens = self.normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil } guard !triggerTokens.isEmpty else { return nil }
let tokens = normalizeSegments(segments) let tokens = self.normalizeSegments(segments)
guard !tokens.isEmpty else { return nil } guard !tokens.isEmpty else { return nil }
var best: MatchCandidate? var best: MatchCandidate?
@@ -115,7 +118,7 @@ public enum WakeWordGate {
} }
guard let best else { return nil } guard let best else { return nil }
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd) let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation) .trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil } guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch( return WakeWordGateMatch(
@@ -145,7 +148,7 @@ public enum WakeWordGate {
guard !text.isEmpty else { return false } guard !text.isEmpty else { return false }
let normalized = text.lowercased() let normalized = text.lowercased()
for trigger in triggers { for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased() let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue } if token.isEmpty { continue }
if normalized.contains(token) { return true } if normalized.contains(token) { return true }
} }
@@ -155,11 +158,11 @@ public enum WakeWordGate {
public static func stripWake(text: String, triggers: [String]) -> String { public static func stripWake(text: String, triggers: [String]) -> String {
var out = text var out = text
for trigger in triggers { for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation) let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
guard !token.isEmpty else { continue } guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive]) out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
} }
return out.trimmingCharacters(in: whitespaceAndPunctuation) return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
} }
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] { private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
@@ -167,7 +170,7 @@ public enum WakeWordGate {
for trigger in triggers { for trigger in triggers {
let tokens = trigger let tokens = trigger
.split(whereSeparator: { $0.isWhitespace }) .split(whereSeparator: { $0.isWhitespace })
.map { normalizeToken(String($0)) } .map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
if tokens.isEmpty { continue } if tokens.isEmpty { continue }
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens)) output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
@@ -177,7 +180,7 @@ public enum WakeWordGate {
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] { private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in segments.compactMap { segment in
let normalized = normalizeToken(segment.text) let normalized = self.normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil } guard !normalized.isEmpty else { return nil }
return Token( return Token(
normalized: normalized, normalized: normalized,
@@ -190,7 +193,7 @@ public enum WakeWordGate {
private static func normalizeToken(_ token: String) -> String { private static func normalizeToken(_ token: String) -> String {
token token
.trimmingCharacters(in: whitespaceAndPunctuation) .trimmingCharacters(in: self.whitespaceAndPunctuation)
.lowercased() .lowercased()
} }

View File

@@ -5,11 +5,11 @@ import WidgetKit
struct OpenClawLiveActivity: Widget { struct OpenClawLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
lockScreenView(context: context) self.lockScreenView(context: context)
} dynamicIsland: { context in } dynamicIsland: { context in
DynamicIsland { DynamicIsland {
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
statusDot(state: context.state) self.statusDot(state: context.state)
} }
DynamicIslandExpandedRegion(.center) { DynamicIslandExpandedRegion(.center) {
Text(context.state.statusText) Text(context.state.statusText)
@@ -17,25 +17,24 @@ struct OpenClawLiveActivity: Widget {
.lineLimit(1) .lineLimit(1)
} }
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
trailingView(state: context.state) self.trailingView(state: context.state)
} }
} compactLeading: { } compactLeading: {
statusDot(state: context.state) self.statusDot(state: context.state)
} compactTrailing: { } compactTrailing: {
Text(context.state.statusText) Text(context.state.statusText)
.font(.caption2) .font(.caption2)
.lineLimit(1) .lineLimit(1)
.frame(maxWidth: 64) .frame(maxWidth: 64)
} minimal: { } minimal: {
statusDot(state: context.state) self.statusDot(state: context.state)
} }
} }
} }
@ViewBuilder
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View { private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
statusDot(state: context.state) self.statusDot(state: context.state)
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("OpenClaw") Text("OpenClaw")
@@ -45,7 +44,7 @@ struct OpenClawLiveActivity: Widget {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
trailingView(state: context.state) self.trailingView(state: context.state)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -69,10 +68,9 @@ struct OpenClawLiveActivity: Widget {
} }
} }
@ViewBuilder
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View { private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
Circle() Circle()
.fill(dotColor(state: state)) .fill(self.dotColor(state: state))
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
} }

View File

@@ -4,6 +4,8 @@
Maintenance update for the current OpenClaw development release. Maintenance update for the current OpenClaw development release.
- Refreshed build hygiene for the iOS app, Share extension, Activity widget, Watch app, and curated shared Swift sources; relay registration now uses StoreKit app transaction JWS data instead of deprecated receipt APIs.
## 2026.4.25 - 2026-04-25 ## 2026.4.25 - 2026-04-25
Maintenance update for the current OpenClaw development release. Maintenance update for the current OpenClaw development release.

View File

@@ -213,7 +213,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration. - The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect. - The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin. - If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration. - Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only. - Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
## Official Build Relay Trust Model ## Official Build Relay Trust Model
@@ -222,7 +222,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The app must pair with the gateway and establish both node and operator sessions. - The app must pair with the gateway and establish both node and operator sessions.
- The operator session is used to fetch `gateway.identity.get`. - The operator session is used to fetch `gateway.identity.get`.
- `iOS -> relay` - `iOS -> relay`
- The app registers with the relay over HTTPS using App Attest plus the app receipt. - The app registers with the relay over HTTPS using App Attest plus a StoreKit app transaction JWS.
- The relay requires the official production/TestFlight distribution path, which is why local - The relay requires the official production/TestFlight distribution path, which is why local
Xcode/dev installs cannot use the hosted relay. Xcode/dev installs cannot use the hosted relay.
- `gateway delegation` - `gateway delegation`
@@ -247,6 +247,10 @@ gateway can only send pushes for iOS devices that paired with that gateway.
- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications. - iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
- Share extension deep-link forwarding into the connected gateway session. - Share extension deep-link forwarding into the connected gateway session.
## 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 canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
## Location Automation Use Case (Testing) ## Location Automation Use Case (Testing)
Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism. Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism.

View File

@@ -90,8 +90,8 @@ final class ShareViewController: UIViewController {
let payload = extracted.payload let payload = extracted.payload
self.pendingAttachments = extracted.attachments self.pendingAttachments = extracted.attachments
self.logger.info( self.logger.info(
"share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)" // swiftlint:disable:next line_length
) "share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)")
let message = self.composeDraft(from: payload) let message = self.composeDraft(from: payload)
await MainActor.run { await MainActor.run {
self.draftTextView.text = message self.draftTextView.text = message
@@ -287,7 +287,7 @@ final class ShareViewController: UIViewController {
let isInvalidConnectParams = let isInvalidConnectParams =
(code.contains("invalid") && code.contains("connect")) (code.contains("invalid") && code.contains("connect"))
|| message.contains("invalid connect params") || message.contains("invalid connect params")
if isInvalidConnectParams && mentionsClientIdPath { if isInvalidConnectParams, mentionsClientIdPath {
return true return true
} }
} }
@@ -405,7 +405,6 @@ final class ShareViewController: UIViewController {
} else { } else {
unknownCount += 1 unknownCount += 1
} }
} }
} }
@@ -475,7 +474,7 @@ final class ShareViewController: UIViewController {
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier), if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier),
let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
url.scheme != nil url.scheme != nil
{ {
return url return url
} }

View File

@@ -1,17 +1,17 @@
import AVFoundation import AVFoundation
import OpenClawKit
import Foundation import Foundation
import OpenClawKit
import os import os
actor CameraController { actor CameraController {
struct CameraDeviceInfo: Codable, Sendable { struct CameraDeviceInfo: Codable {
var id: String var id: String
var name: String var name: String
var position: String var position: String
var deviceType: String var deviceType: String
} }
enum CameraError: LocalizedError, Sendable { enum CameraError: LocalizedError {
case cameraUnavailable case cameraUnavailable
case microphoneUnavailable case microphoneUnavailable
case permissionDenied(kind: String) case permissionDenied(kind: String)
@@ -142,7 +142,7 @@ actor CameraController {
} }
func listDevices() -> [CameraDeviceInfo] { func listDevices() -> [CameraDeviceInfo] {
return Self.discoverVideoDevices().map { device in Self.discoverVideoDevices().map { device in
CameraDeviceInfo( CameraDeviceInfo(
id: device.uniqueID, id: device.uniqueID,
name: device.localizedName, name: device.localizedName,
@@ -152,7 +152,7 @@ actor CameraController {
} }
private func ensureAccess(for mediaType: AVMediaType) async throws { private func ensureAccess(for mediaType: AVMediaType) async throws {
if !(await CameraAuthorization.isAuthorized(for: mediaType)) { if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
} }
} }
@@ -162,7 +162,7 @@ actor CameraController {
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { if let deviceId, !deviceId.isEmpty {
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) { if let match = discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
return match return match
} }
} }
@@ -270,8 +270,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput( func photoOutput(
_ output: AVCapturePhotoOutput, _ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto, didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error? error: Error?)
) { {
let alreadyResumed = self.resumed.withLock { old in let alreadyResumed = self.resumed.withLock { old in
let was = old let was = old
old = true old = true
@@ -303,8 +303,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput( func photoOutput(
_ output: AVCapturePhotoOutput, _ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error? error: Error?)
) { {
guard let error else { return } guard let error else { return }
let alreadyResumed = self.resumed.withLock { old in let alreadyResumed = self.resumed.withLock { old in
let was = old let was = old

View File

@@ -1,10 +1,10 @@
import Foundation
import OpenClawChatUI import OpenClawChatUI
import OpenClawKit import OpenClawKit
import OpenClawProtocol import OpenClawProtocol
import Foundation
import OSLog import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { struct IOSGatewayChatTransport: OpenClawChatTransport {
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport") private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
private let gateway: GatewayNodeSession private let gateway: GatewayNodeSession
@@ -70,10 +70,9 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
{ {
let startLogMessage = let startLogMessage =
"chat.send start sessionKey=\(sessionKey) " "chat.send start sessionKey=\(sessionKey) "
+ "len=\(message.count) attachments=\(attachments.count)" + "len=\(message.count) attachments=\(attachments.count)"
Self.logger.info( Self.logger.info(
"\(startLogMessage, privacy: .public)" "\(startLogMessage, privacy: .public)")
)
struct Params: Codable { struct Params: Codable {
var sessionKey: String var sessionKey: String
var message: String var message: String

View File

@@ -72,7 +72,7 @@ final class ContactsService: ContactsServicing {
contact.givenName = givenName ?? "" contact.givenName = givenName ?? ""
contact.familyName = familyName ?? "" contact.familyName = familyName ?? ""
contact.organizationName = organizationName ?? "" contact.organizationName = organizationName ?? ""
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName { if contact.givenName.isEmpty, contact.familyName.isEmpty, let displayName {
contact.givenName = displayName contact.givenName = displayName
} }
contact.phoneNumbers = phoneNumbers.map { contact.phoneNumbers = phoneNumbers.map {
@@ -86,13 +86,12 @@ final class ContactsService: ContactsServicing {
save.add(contact, toContainerWithIdentifier: nil) save.add(contact, toContainerWithIdentifier: nil)
try store.execute(save) try store.execute(save)
let persisted: CNContact let persisted: CNContact = if !contact.identifier.isEmpty {
if !contact.identifier.isEmpty { try store.unifiedContact(
persisted = try store.unifiedContact(
withIdentifier: contact.identifier, withIdentifier: contact.identifier,
keysToFetch: Self.payloadKeys) keysToFetch: Self.payloadKeys)
} else { } else {
persisted = contact contact
} }
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted)) return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
@@ -137,7 +136,7 @@ final class ContactsService: ContactsServicing {
phoneNumbers: [String], phoneNumbers: [String],
emails: [String]) throws -> CNContact? emails: [String]) throws -> CNContact?
{ {
if phoneNumbers.isEmpty && emails.isEmpty { if phoneNumbers.isEmpty, emails.isEmpty {
return nil return nil
} }
@@ -163,13 +162,13 @@ final class ContactsService: ContactsServicing {
phoneNumbers: [String], phoneNumbers: [String],
emails: [String]) -> CNContact? emails: [String]) -> CNContact?
{ {
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty }) let normalizedPhones = Set(phoneNumbers.map { self.normalizePhone($0) }.filter { !$0.isEmpty })
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty }) let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
var seen = Set<String>() var seen = Set<String>()
for contact in contacts { for contact in contacts {
guard seen.insert(contact.identifier).inserted else { continue } guard seen.insert(contact.identifier).inserted else { continue }
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) }) let contactPhones = Set(contact.phoneNumbers.map { self.normalizePhone($0.value.stringValue) })
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() }) let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) { if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
@@ -198,13 +197,13 @@ final class ContactsService: ContactsServicing {
givenName: contact.givenName, givenName: contact.givenName,
familyName: contact.familyName, familyName: contact.familyName,
organizationName: contact.organizationName, organizationName: contact.organizationName,
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue }, phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
emails: contact.emailAddresses.map { String($0.value) }) emails: contact.emailAddresses.map { String($0.value) })
} }
#if DEBUG #if DEBUG
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool { static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
} }
#endif #endif
} }

View File

@@ -1,8 +1,7 @@
import Darwin
import Foundation import Foundation
import UIKit import UIKit
import Darwin
/// Shared device and platform info for Settings, gateway node payloads, and device status. /// Shared device and platform info for Settings, gateway node payloads, and device status.
enum DeviceInfoHelper { enum DeviceInfoHelper {
/// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads.
@@ -65,8 +64,8 @@ enum DeviceInfoHelper {
/// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs. /// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs.
static func openClawVersionString() -> String { static func openClawVersionString() -> String {
let version = appVersion() let version = self.appVersion()
let build = appBuild() let build = self.appBuild()
if build.isEmpty || build == version { if build.isEmpty || build == version {
return version return version
} }

View File

@@ -5,25 +5,25 @@ enum NodeDisplayName {
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"] private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
static func isGeneric(_ name: String) -> Bool { static func isGeneric(_ name: String) -> Bool {
Self.genericNames.contains(name) self.genericNames.contains(name)
} }
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String { static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
switch interfaceIdiom { switch interfaceIdiom {
case .phone: case .phone:
return "iPhone Node" "iPhone Node"
case .pad: case .pad:
return "iPad Node" "iPad Node"
default: default:
return "iOS Node" "iOS Node"
} }
} }
static func resolve( static func resolve(
existing: String?, existing: String?,
deviceName: String, deviceName: String,
interfaceIdiom: UIUserInterfaceIdiom interfaceIdiom: UIUserInterfaceIdiom) -> String
) -> String { {
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) { if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
return trimmedExisting return trimmedExisting

View File

@@ -31,4 +31,3 @@ enum EventKitAuthorization {
} }
} }
} }

View File

@@ -9,7 +9,7 @@ import OpenClawKit
/// ///
/// Both sessions should derive all connection inputs from this config so we /// Both sessions should derive all connection inputs from this config so we
/// don't accidentally persist gateway-scoped state under different keys. /// don't accidentally persist gateway-scoped state under different keys.
struct GatewayConnectConfig: Sendable { struct GatewayConnectConfig {
let url: URL let url: URL
let stableID: String let stableID: String
let tls: GatewayTLSParams? let tls: GatewayTLSParams?

View File

@@ -3,12 +3,12 @@ import Contacts
import CoreLocation import CoreLocation
import CoreMotion import CoreMotion
import CryptoKit import CryptoKit
import Darwin
import EventKit import EventKit
import Foundation import Foundation
import Darwin
import OpenClawKit
import Network import Network
import Observation import Observation
import OpenClawKit
import os import os
import Photos import Photos
import ReplayKit import ReplayKit
@@ -28,7 +28,9 @@ final class GatewayConnectionController {
let fingerprintSha256: String let fingerprintSha256: String
let isManual: Bool let isManual: Bool
var id: String { self.stableID } var id: String {
self.stableID
}
} }
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
@@ -86,7 +88,6 @@ final class GatewayConnectionController {
self.updateFromDiscovery() self.updateFromDiscovery()
} }
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error. /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
await self.connectDiscoveredGateway(gateway) await self.connectDiscoveredGateway(gateway)
@@ -177,7 +178,7 @@ final class GatewayConnectionController {
guard let fp = await self.probeTLSFingerprint(url: url) else { guard let fp = await self.probeTLSFingerprint(url: url) else {
self.appModel?.gatewayStatusText = self.appModel?.gatewayStatusText =
"TLS handshake failed for \(host):\(resolvedPort). " "TLS handshake failed for \(host):\(resolvedPort). "
+ "Remote gateways must use HTTPS/WSS." + "Remote gateways must use HTTPS/WSS."
return return
} }
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true) self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
@@ -557,11 +558,11 @@ final class GatewayConnectionController {
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint { switch endpoint {
case let .hostPort(host, port): case let .hostPort(host, port):
return (host: host.debugDescription, port: Int(port.rawValue)) (host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _): case let .service(name, type, domain, _):
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain) await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default: default:
return nil nil
} }
} }
@@ -569,8 +570,8 @@ final class GatewayConnectionController {
name: String, name: String,
type: String, type: String,
domain: String, domain: String,
timeoutSeconds: TimeInterval = 3.0 timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
) async -> (host: String, port: Int)? { {
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one, // NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves // we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
@@ -636,8 +637,8 @@ final class GatewayConnectionController {
} }
guard let addrs = svc.addresses else { return nil } guard let addrs = svc.addresses else { return nil }
for addrData in addrs { for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil } guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
@@ -764,7 +765,8 @@ final class GatewayConnectionController {
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String { private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
if let stableID, if let stableID,
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) { let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID)
{
return override return override
} }
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")? let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
@@ -781,7 +783,7 @@ final class GatewayConnectionController {
} }
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return nil } guard !trimmedHost.isEmpty else { return nil }
if useTLS && self.shouldForceTLS(host: trimmedHost) { if useTLS, self.shouldForceTLS(host: trimmedHost) {
return 443 return 443
} }
return 18789 return 18789
@@ -929,9 +931,9 @@ final class GatewayConnectionController {
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool { private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
switch status { switch status {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorizedWhenInUse:
return true true
default: default:
return false false
} }
} }
@@ -1045,8 +1047,8 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
func urlSession( func urlSession(
_ session: URLSession, _ session: URLSession,
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
) { {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust let trust = challenge.protectionSpace.serverTrust
else { else {

View File

@@ -19,9 +19,9 @@ enum GatewayConnectionIssue: Equatable {
var needsAuthToken: Bool { var needsAuthToken: Bool {
switch self { switch self {
case .tokenMissing, .unauthorized: case .tokenMissing, .unauthorized:
return true true
default: default:
return false false
} }
} }
@@ -40,17 +40,17 @@ enum GatewayConnectionIssue: Equatable {
} }
switch problem.kind { switch problem.kind {
case .deviceIdentityRequired, case .deviceIdentityRequired,
.deviceSignatureExpired, .deviceSignatureExpired,
.deviceNonceRequired, .deviceNonceRequired,
.deviceNonceMismatch, .deviceNonceMismatch,
.deviceSignatureInvalid, .deviceSignatureInvalid,
.devicePublicKeyInvalid, .devicePublicKeyInvalid,
.deviceIdMismatch, .deviceIdMismatch,
.tailscaleIdentityMissing, .tailscaleIdentityMissing,
.tailscaleProxyMissing, .tailscaleProxyMissing,
.tailscaleWhoisFailed, .tailscaleWhoisFailed,
.tailscaleIdentityMismatch, .tailscaleIdentityMismatch,
.authRateLimited: .authRateLimited:
return .unauthorized return .unauthorized
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .network return .network

View File

@@ -1,7 +1,7 @@
import OpenClawKit
import Foundation import Foundation
import Network import Network
import Observation import Observation
import OpenClawKit
@MainActor @MainActor
@Observable @Observable
@@ -13,7 +13,10 @@ final class GatewayDiscoveryModel {
} }
struct DiscoveredGateway: Identifiable, Equatable { struct DiscoveredGateway: Identifiable, Equatable {
var id: String { self.stableID } var id: String {
self.stableID
}
var name: String var name: String
var endpoint: NWEndpoint var endpoint: NWEndpoint
var stableID: String var stableID: String

View File

@@ -3,7 +3,7 @@ import OpenClawKit
@MainActor @MainActor
final class GatewayHealthMonitor { final class GatewayHealthMonitor {
struct Config: Sendable { struct Config {
var intervalSeconds: Double var intervalSeconds: Double
var timeoutSeconds: Double var timeoutSeconds: Double
var maxFailures: Int var maxFailures: Int
@@ -17,8 +17,8 @@ final class GatewayHealthMonitor {
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3), config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds) try? await Task.sleep(nanoseconds: nanoseconds)
} })
) { {
self.config = config self.config = config
self.sleep = sleep self.sleep = sleep
} }
@@ -67,7 +67,7 @@ final class GatewayHealthMonitor {
{ {
let timeout = max(0.0, timeoutSeconds) let timeout = max(0.0, timeoutSeconds)
if timeout == 0 { if timeout == 0 {
return (try? await check()) ?? false return await (try? check()) ?? false
} }
do { do {
let timeoutError = NSError( let timeoutError = NSError(

View File

@@ -59,58 +59,57 @@ struct GatewayProblemBanner: View {
.padding(14) .padding(14)
.background( .background(
.thinMaterial, .thinMaterial,
in: RoundedRectangle(cornerRadius: 16, style: .continuous) in: RoundedRectangle(cornerRadius: 16, style: .continuous))
)
} }
private var iconName: String { private var iconName: String {
switch self.problem.kind { switch self.problem.kind {
case .pairingRequired, case .pairingRequired,
.pairingRoleUpgradeRequired, .pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired: .pairingMetadataUpgradeRequired:
return "person.crop.circle.badge.clock" "person.crop.circle.badge.clock"
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return "wifi.exclamationmark" "wifi.exclamationmark"
case .deviceIdentityRequired, case .deviceIdentityRequired,
.deviceSignatureExpired, .deviceSignatureExpired,
.deviceNonceRequired, .deviceNonceRequired,
.deviceNonceMismatch, .deviceNonceMismatch,
.deviceSignatureInvalid, .deviceSignatureInvalid,
.devicePublicKeyInvalid, .devicePublicKeyInvalid,
.deviceIdMismatch: .deviceIdMismatch:
return "lock.shield" "lock.shield"
default: default:
return "exclamationmark.triangle.fill" "exclamationmark.triangle.fill"
} }
} }
private var tint: Color { private var tint: Color {
switch self.problem.kind { switch self.problem.kind {
case .pairingRequired, case .pairingRequired,
.pairingRoleUpgradeRequired, .pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired: .pairingMetadataUpgradeRequired:
return .orange .orange
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .yellow .yellow
default: default:
return .red .red
} }
} }
private var ownerLabel: String { private var ownerLabel: String {
switch self.problem.owner { switch self.problem.owner {
case .gateway: case .gateway:
return "Fix on gateway" "Fix on gateway"
case .iphone: case .iphone:
return "Fix on iPhone" "Fix on iPhone"
case .both: case .both:
return "Check both" "Check both"
case .network: case .network:
return "Check network" "Check network"
case .unknown: case .unknown:
return "Needs attention" "Needs attention"
} }
} }
} }
@@ -218,15 +217,15 @@ struct GatewayProblemDetailsSheet: View {
private var ownerSummary: String { private var ownerSummary: String {
switch self.problem.owner { switch self.problem.owner {
case .gateway: case .gateway:
return "Primary fix: gateway" "Primary fix: gateway"
case .iphone: case .iphone:
return "Primary fix: this iPhone" "Primary fix: this iPhone"
case .both: case .both:
return "Primary fix: check both this iPhone and the gateway" "Primary fix: check both this iPhone and the gateway"
case .network: case .network:
return "Primary fix: network or remote access" "Primary fix: network or remote access"
case .unknown: case .unknown:
return "Primary fix: review details and retry" "Primary fix: review details and retry"
} }
} }
} }

View File

@@ -1,8 +1,8 @@
import Foundation import Foundation
import OpenClawKit import OpenClawKit
// NetService-based resolver for Bonjour services. /// NetService-based resolver for Bonjour services.
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing. /// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
final class GatewayServiceResolver: NSObject, NetServiceDelegate { final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService private let service: NetService
private let completion: ((host: String, port: Int)?) -> Void private let completion: ((host: String, port: Int)?) -> Void
@@ -38,7 +38,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
self.finish(result: nil) self.finish(result: nil)
} }
private func finish(result: ((host: String, port: Int))?) { private func finish(result: (host: String, port: Int)?) {
guard !self.didFinish else { return } guard !self.didFinish else { return }
self.didFinish = true self.didFinish = true
self.service.stop() self.service.stop()

View File

@@ -52,8 +52,7 @@ enum GatewaySettingsStore {
static func loadPreferredGatewayStableID() -> String? { static func loadPreferredGatewayStableID() -> String? {
if let value = KeychainStore.loadString( if let value = KeychainStore.loadString(
service: self.gatewayService, service: self.gatewayService,
account: self.preferredGatewayStableIDAccount account: self.preferredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty !value.isEmpty
{ {
return value return value
@@ -79,8 +78,7 @@ enum GatewaySettingsStore {
static func loadLastDiscoveredGatewayStableID() -> String? { static func loadLastDiscoveredGatewayStableID() -> String? {
if let value = KeychainStore.loadString( if let value = KeychainStore.loadString(
service: self.gatewayService, service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount account: self.lastDiscoveredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty !value.isEmpty
{ {
return value return value
@@ -160,18 +158,18 @@ enum GatewaySettingsStore {
var stableID: String { var stableID: String {
switch self { switch self {
case let .manual(_, _, _, stableID): case let .manual(_, _, _, stableID):
return stableID stableID
case let .discovered(stableID, _): case let .discovered(stableID, _):
return stableID stableID
} }
} }
var useTLS: Bool { var useTLS: Bool {
switch self { switch self {
case let .manual(_, _, useTLS, _): case let .manual(_, _, useTLS, _):
return useTLS useTLS
case let .discovered(_, useTLS): case let .discovered(_, useTLS):
return useTLS useTLS
} }
} }
} }
@@ -446,7 +444,6 @@ enum GatewaySettingsStore {
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
} }
} }
} }
enum GatewayDiagnostics { enum GatewayDiagnostics {
@@ -518,7 +515,7 @@ enum GatewayDiagnostics {
static func bootstrap() { static func bootstrap() {
guard let url = fileURL else { return } guard let url = fileURL else { return }
queue.async { self.queue.async {
self.truncateLogIfNeeded(url: url) self.truncateLogIfNeeded(url: url)
let timestamp = self.isoTimestamp() let timestamp = self.isoTimestamp()
let line = "[\(timestamp)] gateway diagnostics started\n" let line = "[\(timestamp)] gateway diagnostics started\n"
@@ -532,10 +529,10 @@ enum GatewayDiagnostics {
static func log(_ message: String) { static func log(_ message: String) {
let timestamp = self.isoTimestamp() let timestamp = self.isoTimestamp()
let line = "[\(timestamp)] \(message)" let line = "[\(timestamp)] \(message)"
logger.info("\(line, privacy: .public)") self.logger.info("\(line, privacy: .public)")
guard let url = fileURL else { return } guard let url = fileURL else { return }
queue.async { self.queue.async {
let shouldTruncate = self.logWritesSinceCheck.withLock { count in let shouldTruncate = self.logWritesSinceCheck.withLock { count in
count += 1 count += 1
if count >= self.logSizeCheckEveryWrites { if count >= self.logSizeCheckEveryWrites {
@@ -556,7 +553,7 @@ enum GatewayDiagnostics {
static func reset() { static func reset() {
guard let url = fileURL else { return } guard let url = fileURL else { return }
queue.async { self.queue.async {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
} }
} }

View File

@@ -40,4 +40,3 @@ enum TCPProbe {
} }
} }
} }

View File

@@ -98,8 +98,7 @@ private struct HomeToolbarStatusButton: View {
.scaleEffect( .scaleEffect(
self.gateway == .connecting && !self.reduceMotion self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85) ? (self.pulse ? 1.15 : 0.85)
: 1.0 : 1.0)
)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title) Text(self.gateway.title)
@@ -214,8 +213,7 @@ private struct HomeToolbarActionButton: View {
(self.tint ?? .white).opacity( (self.tint ?? .white).opacity(
self.isActive self.isActive
? 0.34 ? 0.34
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16)) : (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))),
),
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6)) lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
} }
} }

View File

@@ -1,6 +1,6 @@
import OpenClawKit
import CoreLocation import CoreLocation
import Foundation import Foundation
import OpenClawKit
@MainActor @MainActor
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon { final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {

View File

@@ -11,8 +11,8 @@ enum SignificantLocationMonitor {
locationService: any LocationServicing, locationService: any LocationServicing,
locationMode: OpenClawLocationMode, locationMode: OpenClawLocationMode,
gateway: GatewayNodeSession, gateway: GatewayNodeSession,
beforeSend: (@MainActor @Sendable () async -> Void)? = nil beforeSend: (@MainActor @Sendable () async -> Void)? = nil)
) { {
guard locationMode == .always else { return } guard locationMode == .always else { return }
let status = locationService.authorizationStatus() let status = locationService.authorizationStatus()
guard status == .authorizedAlways else { return } guard status == .authorizedAlways else { return }

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
import Photos
import OpenClawKit import OpenClawKit
import Photos
import UIKit import UIKit
final class PhotoLibraryService: PhotosServicing { final class PhotoLibraryService: PhotosServicing {
@@ -139,7 +139,7 @@ final class PhotoLibraryService: PhotosServicing {
if newWidth >= currentImage.size.width { if newWidth >= currentImage.size.width {
break break
} }
currentImage = resize(image: currentImage, targetWidth: newWidth) currentImage = self.resize(image: currentImage, targetWidth: newWidth)
} }
throw NSError(domain: "Photos", code: 4, userInfo: [ throw NSError(domain: "Photos", code: 4, userInfo: [

View File

@@ -1,15 +1,15 @@
import Observation
import OpenClawChatUI import OpenClawChatUI
import OpenClawKit import OpenClawKit
import OpenClawProtocol import OpenClawProtocol
import Observation
import os import os
import Security import Security
import SwiftUI import SwiftUI
import UIKit import UIKit
import UserNotifications import UserNotifications
// Wrap errors without pulling non-Sendable types into async notification paths. /// Wrap errors without pulling non-Sendable types into async notification paths.
private struct NotificationCallError: Error, Sendable { private struct NotificationCallError: Error {
let message: String let message: String
} }
@@ -18,7 +18,7 @@ private struct GatewayRelayIdentityResponse: Decodable {
let publicKey: String let publicKey: String
} }
// Ensures notification requests return promptly even if the system prompt blocks. /// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable { private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>? private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
@@ -61,7 +61,7 @@ final class NodeAppModel {
let request: AgentDeepLink let request: AgentDeepLink
} }
struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable { struct ExecApprovalPrompt: Identifiable, Equatable, Codable {
let id: String let id: String
let commandText: String let commandText: String
let commandPreview: String? let commandPreview: String?
@@ -124,6 +124,7 @@ final class NodeAppModel {
var gatewayDisplayStatusText: String { var gatewayDisplayStatusText: String {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
} }
var seamColorHex: String? var seamColorHex: String?
private var mainSessionBaseKey: String = "main" private var mainSessionBaseKey: String = "main"
var selectedAgentId: String? var selectedAgentId: String?
@@ -141,7 +142,7 @@ final class NodeAppModel {
private var lastAgentDeepLinkPromptAt: Date = .distantPast private var lastAgentDeepLinkPromptAt: Date = .distantPast
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>? @ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
// Primary "node" connection: used for device capabilities and node.invoke requests. /// Primary "node" connection: used for device capabilities and node.invoke requests.
private let nodeGateway = GatewayNodeSession() private let nodeGateway = GatewayNodeSession()
// Secondary "operator" connection: used for chat/talk/config/voicewake requests. // Secondary "operator" connection: used for chat/talk/config/voicewake requests.
private let operatorGateway = GatewayNodeSession() private let operatorGateway = GatewayNodeSession()
@@ -188,8 +189,14 @@ final class NodeAppModel {
private var apnsDeviceTokenHex: String? private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String? private var apnsLastRegisteredTokenHex: String?
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager() @ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession { self.nodeGateway } var gatewaySession: GatewayNodeSession {
var operatorSession: GatewayNodeSession { self.operatorGateway } self.nodeGateway
}
var operatorSession: GatewayNodeSession {
self.operatorGateway
}
private(set) var activeGatewayConnectConfig: GatewayConnectConfig? private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1" private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
@@ -377,7 +384,6 @@ final class NodeAppModel {
} }
} }
func setScenePhase(_ phase: ScenePhase) { func setScenePhase(_ phase: ScenePhase) {
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled") let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))") GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
@@ -429,7 +435,7 @@ final class NodeAppModel {
let operatorWasConnected = await MainActor.run { self.operatorConnected } let operatorWasConnected = await MainActor.run { self.operatorConnected }
if operatorWasConnected { if operatorWasConnected {
// Prefer keeping the connection if it's healthy; reconnect only when needed. // Prefer keeping the connection if it's healthy; reconnect only when needed.
let healthy = (try? await self.operatorGateway.request( let healthy = await (try? self.operatorGateway.request(
method: "health", method: "health",
paramsJSON: nil, paramsJSON: nil,
timeoutSeconds: 2)) != nil timeoutSeconds: 2)) != nil
@@ -512,7 +518,7 @@ final class NodeAppModel {
self.backgroundReconnectSuppressed = false self.backgroundReconnectSuppressed = false
let leaseLogMessage = let leaseLogMessage =
"Background reconnect lease reason=\(reason) " "Background reconnect lease reason=\(reason) "
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)" + "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)") self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
} }
@@ -525,7 +531,7 @@ final class NodeAppModel {
guard changed else { return } guard changed else { return }
let suppressLogMessage = let suppressLogMessage =
"Background reconnect suppressed reason=\(reason) " "Background reconnect suppressed reason=\(reason) "
+ "disconnect=\(disconnectIfNeeded)" + "disconnect=\(disconnectIfNeeded)"
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)") self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
guard disconnectIfNeeded else { return } guard disconnectIfNeeded else { return }
Task { [weak self] in Task { [weak self] in
@@ -646,7 +652,7 @@ final class NodeAppModel {
self.applyMainSessionKey(decoded.mainkey) self.applyMainSessionKey(decoded.mainkey)
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !selected.isEmpty && !decoded.agents.contains(where: { $0.id == selected }) { if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
self.selectedAgentId = nil self.selectedAgentId = nil
} }
self.talkMode.updateMainSessionKey(self.mainSessionKey) self.talkMode.updateMainSessionKey(self.mainSessionKey)
@@ -769,8 +775,7 @@ final class NodeAppModel {
let data = try await self.operatorGateway.request( let data = try await self.operatorGateway.request(
method: "health", method: "health",
paramsJSON: nil, paramsJSON: nil,
timeoutSeconds: 6 timeoutSeconds: 6)
)
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else { guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
return false return false
} }
@@ -1057,6 +1062,7 @@ final class NodeAppModel {
""" """
let resultJSON = try await self.screen.eval(javaScript: js) let resultJSON = try await self.screen.eval(javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
default: default:
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
@@ -1294,8 +1300,8 @@ final class NodeAppModel {
} }
private static func isNotificationAuthorizationAllowed( private static func isNotificationAuthorizationAllowed(
_ status: NotificationAuthorizationStatus _ status: NotificationAuthorizationStatus) -> Bool
) -> Bool { {
switch status { switch status {
case .authorized, .provisional, .ephemeral: case .authorized, .provisional, .ephemeral:
true true
@@ -1306,8 +1312,8 @@ final class NodeAppModel {
private func runNotificationCall<T: Sendable>( private func runNotificationCall<T: Sendable>(
timeoutSeconds: Double, timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
) async -> Result<T, NotificationCallError> { {
let latch = NotificationInvokeLatch<T>() let latch = NotificationInvokeLatch<T>()
var opTask: Task<Void, Never>? var opTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>? var timeoutTask: Task<Void, Never>?
@@ -1481,12 +1487,11 @@ final class NodeAppModel {
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
} }
} }
} }
private extension NodeAppModel { extension NodeAppModel {
// Central registry for node invoke routing to keep commands in one place. /// Central registry for node invoke routing to keep commands in one place.
func buildCapabilityRouter() -> NodeCapabilityRouter { private func buildCapabilityRouter() -> NodeCapabilityRouter {
var handlers: [String: NodeCapabilityRouter.Handler] = [:] var handlers: [String: NodeCapabilityRouter.Handler] = [:]
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) { func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
@@ -1610,7 +1615,7 @@ private extension NodeAppModel {
return NodeCapabilityRouter(handlers: handlers) return NodeCapabilityRouter(handlers: handlers)
} }
func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command { switch req.command {
case OpenClawWatchCommand.status.rawValue: case OpenClawWatchCommand.status.rawValue:
let status = await self.watchMessagingService.status() let status = await self.watchMessagingService.status()
@@ -1627,7 +1632,7 @@ private extension NodeAppModel {
let normalizedParams = Self.normalizeWatchNotifyParams(params) let normalizedParams = Self.normalizeWatchNotifyParams(params)
let title = normalizedParams.title let title = normalizedParams.title
let body = normalizedParams.body let body = normalizedParams.body
if title.isEmpty && body.isEmpty { if title.isEmpty, body.isEmpty {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
@@ -1670,18 +1675,18 @@ private extension NodeAppModel {
} }
} }
func locationMode() -> OpenClawLocationMode { private func locationMode() -> OpenClawLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return OpenClawLocationMode(rawValue: raw) ?? .off return OpenClawLocationMode(rawValue: raw) ?? .off
} }
func isLocationPreciseEnabled() -> Bool { private func isLocationPreciseEnabled() -> Bool {
// iOS settings now expose a single location mode control. // iOS settings now expose a single location mode control.
// Default location tool precision stays high unless a command explicitly requests balanced. // Default location tool precision stays high unless a command explicitly requests balanced.
true true
} }
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T { fileprivate static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else { guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 20, userInfo: [ throw NSError(domain: "Gateway", code: 20, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
@@ -1690,7 +1695,7 @@ private extension NodeAppModel {
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
} }
static func encodePayload(_ obj: some Encodable) throws -> String { fileprivate static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj) let data = try JSONEncoder().encode(obj)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
@@ -1700,17 +1705,17 @@ private extension NodeAppModel {
return json return json
} }
func isCameraEnabled() -> Bool { private func isCameraEnabled() -> Bool {
// Default-on: if the key doesn't exist yet, treat it as enabled. // Default-on: if the key doesn't exist yet, treat it as enabled.
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true } if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "camera.enabled") return UserDefaults.standard.bool(forKey: "camera.enabled")
} }
func triggerCameraFlash() { private func triggerCameraFlash() {
self.cameraFlashNonce &+= 1 self.cameraFlashNonce &+= 1
} }
func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.cameraHUDDismissTask?.cancel() self.cameraHUDDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
@@ -1854,8 +1859,8 @@ extension NodeAppModel {
} }
} }
private extension NodeAppModel { extension NodeAppModel {
func prepareForGatewayConnect(url: URL, stableID: String) { private func prepareForGatewayConnect(url: URL, stableID: String) {
self.gatewayAutoReconnectEnabled = true self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil self.gatewayPairingRequestId = nil
@@ -1878,13 +1883,13 @@ private extension NodeAppModel {
self.apnsLastRegisteredTokenHex = nil self.apnsLastRegisteredTokenHex = nil
} }
func clearGatewayConnectionProblem() { private func clearGatewayConnectionProblem() {
self.lastGatewayProblem = nil self.lastGatewayProblem = nil
self.gatewayPairingPaused = false self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil self.gatewayPairingRequestId = nil
} }
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
self.lastGatewayProblem = problem self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText self.gatewayStatusText = problem.statusText
self.gatewayServerName = nil self.gatewayServerName = nil
@@ -1903,14 +1908,14 @@ private extension NodeAppModel {
} }
} }
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool { private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
guard let lastGatewayProblem else { return false } guard let lastGatewayProblem else { return false }
return GatewayConnectionProblemMapper.shouldPreserve( return GatewayConnectionProblemMapper.shouldPreserve(
previousProblem: lastGatewayProblem, previousProblem: lastGatewayProblem,
overDisconnectReason: reason) overDisconnectReason: reason)
} }
func shouldStartOperatorGatewayLoop( private func shouldStartOperatorGatewayLoop(
token: String?, token: String?,
bootstrapToken: String?, bootstrapToken: String?,
password: String?, password: String?,
@@ -1923,12 +1928,12 @@ private extension NodeAppModel {
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator")) hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
} }
func hasStoredGatewayRoleToken(_ role: String) -> Bool { private func hasStoredGatewayRoleToken(_ role: String) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate() let identity = DeviceIdentityStore.loadOrCreate()
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
} }
nonisolated static func shouldStartOperatorGatewayLoop( fileprivate nonisolated static func shouldStartOperatorGatewayLoop(
token: String?, token: String?,
bootstrapToken: String?, bootstrapToken: String?,
password: String?, password: String?,
@@ -1949,7 +1954,8 @@ private extension NodeAppModel {
return hasStoredOperatorToken return hasStoredOperatorToken
} }
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? { fileprivate nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?)
-> GatewayConnectConfig? {
guard let config else { return nil } guard let config else { return nil }
let trimmedBootstrapToken = config.bootstrapToken? let trimmedBootstrapToken = config.bootstrapToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -1964,7 +1970,7 @@ private extension NodeAppModel {
nodeOptions: config.nodeOptions) nodeOptions: config.nodeOptions)
} }
func currentGatewayReconnectAuth( private func currentGatewayReconnectAuth(
fallbackToken: String?, fallbackToken: String?,
fallbackBootstrapToken: String?, fallbackBootstrapToken: String?,
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?) fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
@@ -1975,7 +1981,7 @@ private extension NodeAppModel {
return (fallbackToken, fallbackBootstrapToken, fallbackPassword) return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
} }
func clearPersistedGatewayBootstrapTokenIfNeeded() { private func clearPersistedGatewayBootstrapTokenIfNeeded() {
// Always drop the in-memory bootstrap token after the first successful // Always drop the in-memory bootstrap token after the first successful
// bootstrap connect so reconnect loops cannot reuse a spent token. // bootstrap connect so reconnect loops cannot reuse a spent token.
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig) self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
@@ -1999,7 +2005,7 @@ private extension NodeAppModel {
sessionBox: WebSocketSessionBox?) async sessionBox: WebSocketSessionBox?) async
{ {
self.clearPersistedGatewayBootstrapTokenIfNeeded() self.clearPersistedGatewayBootstrapTokenIfNeeded()
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop( if self.operatorGatewayTask == nil, self.shouldStartOperatorGatewayLoop(
token: token, token: token,
bootstrapToken: nil, bootstrapToken: nil,
password: password, password: password,
@@ -2020,7 +2026,7 @@ private extension NodeAppModel {
_ = await self.requestNotificationAuthorizationIfNeeded() _ = await self.requestNotificationAuthorizationIfNeeded()
} }
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
guard self.isBackgrounded else { return } guard self.isBackgrounded else { return }
guard !self.backgroundReconnectSuppressed else { return } guard !self.backgroundReconnectSuppressed else { return }
guard let leaseUntil = self.backgroundReconnectLeaseUntil else { guard let leaseUntil = self.backgroundReconnectLeaseUntil else {
@@ -2032,12 +2038,12 @@ private extension NodeAppModel {
} }
} }
func shouldPauseReconnectLoopInBackground(source: String) -> Bool { private func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
self.refreshBackgroundReconnectSuppressionIfNeeded(source: source) self.refreshBackgroundReconnectSuppressionIfNeeded(source: source)
return self.isBackgrounded && self.backgroundReconnectSuppressed return self.isBackgrounded && self.backgroundReconnectSuppressed
} }
func startOperatorGatewayLoop( private func startOperatorGatewayLoop(
url: URL, url: URL,
stableID: String, stableID: String,
token: String?, token: String?,
@@ -2141,7 +2147,7 @@ private extension NodeAppModel {
// Legacy reconnect state machine; follow-up refactor needed to split into helpers. // Legacy reconnect state machine; follow-up refactor needed to split into helpers.
// swiftlint:disable:next function_body_length // swiftlint:disable:next function_body_length
func startNodeGatewayLoop( private func startNodeGatewayLoop(
url: URL, url: URL,
stableID: String, stableID: String,
token: String?, token: String?,
@@ -2216,7 +2222,7 @@ private extension NodeAppModel {
let usedBootstrapToken = let usedBootstrapToken =
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty == false .isEmpty == false
if usedBootstrapToken { if usedBootstrapToken {
await self.handleSuccessfulBootstrapGatewayOnboarding( await self.handleSuccessfulBootstrapGatewayOnboarding(
url: url, url: url,
@@ -2230,8 +2236,7 @@ private extension NodeAppModel {
( (
sessionKey: self.mainSessionKey, sessionKey: self.mainSessionKey,
deliveryChannel: self.shareDeliveryChannel, deliveryChannel: self.shareDeliveryChannel,
deliveryTo: self.shareDeliveryTo deliveryTo: self.shareDeliveryTo)
)
} }
ShareGatewayRelaySettings.saveConfig( ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig( ShareGatewayRelayConfig(
@@ -2243,8 +2248,7 @@ private extension NodeAppModel {
deliveryTo: relayData.deliveryTo)) deliveryTo: relayData.deliveryTo))
GatewayDiagnostics.log( GatewayDiagnostics.log(
"gateway connected host=\(url.host ?? "?") " "gateway connected host=\(url.host ?? "?") "
+ "scheme=\(url.scheme ?? "?")" + "scheme=\(url.scheme ?? "?")")
)
if let addr = await self.nodeGateway.currentRemoteAddress() { if let addr = await self.nodeGateway.currentRemoteAddress() {
await MainActor.run { self.gatewayRemoteAddress = addr } await MainActor.run { self.gatewayRemoteAddress = addr }
} }
@@ -2295,8 +2299,8 @@ private extension NodeAppModel {
if Task.isCancelled { break } if Task.isCancelled { break }
if !didFallbackClientId, if !didFallbackClientId,
let fallbackClientId = self.legacyClientIdFallback( let fallbackClientId = self.legacyClientIdFallback(
currentClientId: currentOptions.clientId, currentClientId: currentOptions.clientId,
error: error) error: error)
{ {
didFallbackClientId = true didFallbackClientId = true
currentOptions.clientId = fallbackClientId currentOptions.clientId = fallbackClientId
@@ -2368,7 +2372,7 @@ private extension NodeAppModel {
} }
} }
func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool { private func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate() let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator")? .loadToken(deviceId: identity.deviceId, role: "operator")?
@@ -2379,11 +2383,11 @@ private extension NodeAppModel {
storedOperatorScopes: storedOperatorScopes) storedOperatorScopes: storedOperatorScopes)
} }
nonisolated static func shouldRequestOperatorApprovalScope( fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
token: String?, token: String?,
password: String?, password: String?,
storedOperatorScopes: [String] storedOperatorScopes: [String]) -> Bool
) -> Bool { {
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty { if !trimmedToken.isEmpty {
return true return true
@@ -2395,11 +2399,11 @@ private extension NodeAppModel {
return storedOperatorScopes.contains("operator.approvals") return storedOperatorScopes.contains("operator.approvals")
} }
func makeOperatorConnectOptions( private func makeOperatorConnectOptions(
clientId: String, clientId: String,
displayName: String?, displayName: String?,
includeApprovalScope: Bool includeApprovalScope: Bool) -> GatewayConnectOptions
) -> GatewayConnectOptions { {
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"] var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
// Preserve reconnect compatibility for older paired operator tokens that were // Preserve reconnect compatibility for older paired operator tokens that were
// approved before iOS requested operator.approvals by default. // approved before iOS requested operator.approvals by default.
@@ -2418,7 +2422,7 @@ private extension NodeAppModel {
includeDeviceIdentity: true) includeDeviceIdentity: true)
} }
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? { private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalizedClientId == "openclaw-ios" else { return nil } guard normalizedClientId == "openclaw-ios" else { return nil }
let message = error.localizedDescription.lowercased() let message = error.localizedDescription.lowercased()
@@ -2428,7 +2432,7 @@ private extension NodeAppModel {
return "moltbot-ios" return "moltbot-ios"
} }
func isOperatorConnected() async -> Bool { private func isOperatorConnected() async -> Bool {
self.operatorConnected self.operatorConnected
} }
} }
@@ -2568,8 +2572,10 @@ extension NodeAppModel {
PendingForegroundNodeActionsResponse.self, PendingForegroundNodeActionsResponse.self,
from: payload) from: payload)
guard !decoded.actions.isEmpty else { return } guard !decoded.actions.isEmpty else { return }
// swiftlint:disable:next line_length self.pendingActionLogger
self.pendingActionLogger.info("Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)") .info(
// swiftlint:disable:next line_length
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger) await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch { } catch {
// Best-effort only. // Best-effort only.
@@ -2591,8 +2597,10 @@ extension NodeAppModel {
command: action.command, command: action.command,
paramsJSON: action.paramsJSON) paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req) let result = await self.handleInvoke(req)
// swiftlint:disable:next line_length self.pendingActionLogger
self.pendingActionLogger.info("Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)") .info(
// swiftlint:disable:next line_length
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
guard result.ok else { return } guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction( let acked = await self.ackPendingForegroundNodeAction(
id: action.id, id: action.id,
@@ -2616,17 +2624,19 @@ extension NodeAppModel {
timeoutSeconds: 6) timeoutSeconds: 6)
return true return true
} catch { } catch {
// swiftlint:disable:next line_length self.pendingActionLogger
self.pendingActionLogger.error("Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)") .error(
// swiftlint:disable:next line_length
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
return false return false
} }
} }
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) { switch await self.watchReplyCoordinator.ingest(event, isGatewayConnected: self.isGatewayConnected()) {
case .dropMissingFields: case .dropMissingFields:
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
case .deduped(let replyId): case let .deduped(replyId):
self.watchReplyLogger.debug( self.watchReplyLogger.debug(
"watch reply deduped replyId=\(replyId, privacy: .public)") "watch reply deduped replyId=\(replyId, privacy: .public)")
case let .queue(replyId, actionId): case let .queue(replyId, actionId):
@@ -2638,7 +2648,7 @@ extension NodeAppModel {
} }
private func flushQueuedWatchRepliesIfConnected() async { private func flushQueuedWatchRepliesIfConnected() async {
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) { for event in await self.watchReplyCoordinator.drainIfConnected(self.isGatewayConnected()) {
await self.forwardWatchReplyToAgent(event) await self.forwardWatchReplyToAgent(event)
} }
} }
@@ -2660,13 +2670,13 @@ extension NodeAppModel {
try await self.sendAgentRequest(link: link) try await self.sendAgentRequest(link: link)
let forwardedMessage = let forwardedMessage =
"watch reply forwarded replyId=\(event.replyId) " "watch reply forwarded replyId=\(event.replyId) "
+ "action=\(event.actionId)" + "action=\(event.actionId)"
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)") self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
self.openChatRequestID &+= 1 self.openChatRequestID &+= 1
} catch { } catch {
let failedMessage = let failedMessage =
"watch reply forwarding failed replyId=\(event.replyId) " "watch reply forwarding failed replyId=\(event.replyId) "
+ "error=\(error.localizedDescription)" + "error=\(error.localizedDescription)"
self.watchReplyLogger.error("\(failedMessage, privacy: .public)") self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
self.watchReplyCoordinator.requeueFront(event) self.watchReplyCoordinator.requeueFront(event)
} }
@@ -2811,7 +2821,7 @@ extension NodeAppModel {
risk: nil) risk: nil)
} }
nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt( private nonisolated static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
reason: String) -> Bool reason: String) -> Bool
{ {
reason == "resolve_retry" reason == "resolve_retry"
@@ -2828,8 +2838,10 @@ extension NodeAppModel {
self.watchExecApprovalLogger.debug( self.watchExecApprovalLogger.debug(
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)") "watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
} catch { } catch {
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.error("watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)") .error(
// swiftlint:disable:next line_length
"watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
} }
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot") await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
} }
@@ -2850,8 +2862,10 @@ extension NodeAppModel {
do { do {
_ = try await self.watchMessagingService.sendExecApprovalResolved(message) _ = try await self.watchMessagingService.sendExecApprovalResolved(message)
} catch { } catch {
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.error("watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)") .error(
// swiftlint:disable:next line_length
"watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
} }
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot") await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
} }
@@ -2870,8 +2884,10 @@ extension NodeAppModel {
do { do {
_ = try await self.watchMessagingService.sendExecApprovalExpired(message) _ = try await self.watchMessagingService.sendExecApprovalExpired(message)
} catch { } catch {
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.error("watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)") .error(
// swiftlint:disable:next line_length
"watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
} }
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)") await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
} }
@@ -2900,13 +2916,17 @@ extension NodeAppModel {
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message) _ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
GatewayDiagnostics.log( GatewayDiagnostics.log(
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)") "watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.debug("watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)") .debug(
// swiftlint:disable:next line_length
"watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
} catch { } catch {
GatewayDiagnostics.log( GatewayDiagnostics.log(
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)") "watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.error("watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)") .error(
// swiftlint:disable:next line_length
"watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
} }
} }
@@ -2917,7 +2937,7 @@ extension NodeAppModel {
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)") GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
} }
nonisolated private static func watchExecApprovalIDsNeedingFetch( private nonisolated static func watchExecApprovalIDsNeedingFetch(
candidateIDs: [String], candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String] cachedApprovalIDs: [String]) -> [String]
{ {
@@ -2972,8 +2992,10 @@ extension NodeAppModel {
forApprovalID: approvalId, forApprovalID: approvalId,
notificationCenter: self.notificationCenter) notificationCenter: self.notificationCenter)
case let .failed(message): case let .failed(message):
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.error("watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)") .error(
// swiftlint:disable:next line_length
"watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
} }
} }
} }
@@ -3054,8 +3076,10 @@ extension NodeAppModel {
reason: .notFound) reason: .notFound)
return true return true
case let .failed(message): case let .failed(message):
// swiftlint:disable:next line_length self.watchExecApprovalLogger
self.watchExecApprovalLogger.error("watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)") .error(
// swiftlint:disable:next line_length
"watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
return false return false
} }
} }
@@ -3084,9 +3108,9 @@ extension NodeAppModel {
let pushKind = Self.openclawPushKind(userInfo) let pushKind = Self.openclawPushKind(userInfo)
let receivedMessage = let receivedMessage =
"Silent push received wakeId=\(wakeId) " "Silent push received wakeId=\(wakeId) "
+ "kind=\(pushKind) " + "kind=\(pushKind) "
+ "backgrounded=\(self.isBackgrounded) " + "backgrounded=\(self.isBackgrounded) "
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)" + "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded( if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
@@ -3108,8 +3132,10 @@ extension NodeAppModel {
{ {
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId) let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
if handled { if handled {
// swiftlint:disable:next line_length self.execApprovalNotificationLogger
self.execApprovalNotificationLogger.info("Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)") .info(
// swiftlint:disable:next line_length
"Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
} }
return handled return handled
} }
@@ -3117,9 +3143,9 @@ extension NodeAppModel {
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
let outcomeMessage = let outcomeMessage =
"Silent push outcome wakeId=\(wakeId) " "Silent push outcome wakeId=\(wakeId) "
+ "applied=\(result.applied) " + "applied=\(result.applied) "
+ "reason=\(result.reason) " + "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)" + "durationMs=\(result.durationMs)"
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)") self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
return result.applied return result.applied
} }
@@ -3128,16 +3154,16 @@ extension NodeAppModel {
let wakeId = Self.makePushWakeAttemptID() let wakeId = Self.makePushWakeAttemptID()
let receivedMessage = let receivedMessage =
"Background refresh wake received wakeId=\(wakeId) " "Background refresh wake received wakeId=\(wakeId) "
+ "trigger=\(trigger) " + "trigger=\(trigger) "
+ "backgrounded=\(self.isBackgrounded) " + "backgrounded=\(self.isBackgrounded) "
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)" + "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
let outcomeMessage = let outcomeMessage =
"Background refresh wake outcome wakeId=\(wakeId) " "Background refresh wake outcome wakeId=\(wakeId) "
+ "applied=\(result.applied) " + "applied=\(result.applied) "
+ "reason=\(result.reason) " + "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)" + "durationMs=\(result.durationMs)"
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)") self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
return result.applied return result.applied
} }
@@ -3157,7 +3183,7 @@ extension NodeAppModel {
{ {
let throttledMessage = let throttledMessage =
"Location wake throttled wakeId=\(wakeId) " "Location wake throttled wakeId=\(wakeId) "
+ "elapsedSec=\(now.timeIntervalSince(last))" + "elapsedSec=\(now.timeIntervalSince(last))"
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)") self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
return return
} }
@@ -3165,15 +3191,15 @@ extension NodeAppModel {
let beginMessage = let beginMessage =
"Location wake begin wakeId=\(wakeId) " "Location wake begin wakeId=\(wakeId) "
+ "backgrounded=\(self.isBackgrounded) " + "backgrounded=\(self.isBackgrounded) "
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)" + "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
self.locationWakeLogger.info("\(beginMessage, privacy: .public)") self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
let triggerMessage = let triggerMessage =
"Location wake trigger wakeId=\(wakeId) " "Location wake trigger wakeId=\(wakeId) "
+ "applied=\(result.applied) " + "applied=\(result.applied) "
+ "reason=\(result.reason) " + "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)" + "durationMs=\(result.durationMs)"
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)") self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
guard result.applied else { return } guard result.applied else { return }
@@ -3201,7 +3227,7 @@ extension NodeAppModel {
return return
} }
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex { if !usesRelayTransport, token == self.apnsLastRegisteredTokenHex {
return return
} }
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -3330,8 +3356,10 @@ extension NodeAppModel {
self.clearPendingExecApprovalPromptIfMatches(approvalId) self.clearPendingExecApprovalPromptIfMatches(approvalId)
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound) await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
case let .failed(message): case let .failed(message):
// swiftlint:disable:next line_length self.execApprovalNotificationLogger
self.execApprovalNotificationLogger.error("Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)") .error(
// swiftlint:disable:next line_length
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
} }
} }
@@ -3369,7 +3397,7 @@ extension NodeAppModel {
expiresAtMs: details.expiresAtMs) expiresAtMs: details.expiresAtMs)
} }
nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect( private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: String, sourceReason: String,
isBackgrounded: Bool) -> Bool isBackgrounded: Bool) -> Bool
{ {
@@ -3387,24 +3415,22 @@ extension NodeAppModel {
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
{ {
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
let fetchReason: String let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
if let normalizedSourceReason, !normalizedSourceReason.isEmpty { normalizedSourceReason
fetchReason = normalizedSourceReason
} else { } else {
fetchReason = "direct" "direct"
} }
GatewayDiagnostics.log( GatewayDiagnostics.log(
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)") "watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
let connected: Bool let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: fetchReason, sourceReason: fetchReason,
isBackgrounded: self.isBackgrounded) isBackgrounded: self.isBackgrounded)
{ {
connected = await self.ensureOperatorApprovalConnectionForWatchReview( await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12_000, timeoutMs: 12000,
reason: fetchReason) reason: fetchReason)
} else { } else {
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000) await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
} }
guard connected else { guard connected else {
GatewayDiagnostics.log( GatewayDiagnostics.log(
@@ -3472,8 +3498,8 @@ extension NodeAppModel {
func handleExecApprovalNotificationDecision( func handleExecApprovalNotificationDecision(
approvalId: String, approvalId: String,
decision: String decision: String) async
) async { {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return } guard !normalizedApprovalID.isEmpty else { return }
@@ -3499,8 +3525,8 @@ extension NodeAppModel {
private func resolveExecApprovalNotificationDecision( private func resolveExecApprovalNotificationDecision(
approvalId: String, approvalId: String,
decision: String, decision: String,
sourceReason: String? = nil sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome
) async -> ExecApprovalResolutionOutcome { {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -3509,16 +3535,15 @@ extension NodeAppModel {
return .failed(message: "Invalid approval request.") return .failed(message: "Invalid approval request.")
} }
let connected: Bool let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: resolutionReason, sourceReason: resolutionReason,
isBackgrounded: self.isBackgrounded) isBackgrounded: self.isBackgrounded)
{ {
connected = await self.ensureOperatorApprovalConnectionForWatchReview( await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12_000, timeoutMs: 12000,
reason: resolutionReason) reason: resolutionReason)
} else { } else {
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000) await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
} }
guard connected else { guard connected else {
self.execApprovalNotificationLogger.error( self.execApprovalNotificationLogger.error(
@@ -3573,7 +3598,7 @@ extension NodeAppModel {
self.dismissPendingExecApprovalPrompt() self.dismissPendingExecApprovalPrompt()
} }
nonisolated private static func isApprovalNotificationStaleError(_ error: Error) -> Bool { private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false } guard let gatewayError = error as? GatewayResponseError else { return false }
if gatewayError.code != "INVALID_REQUEST" { if gatewayError.code != "INVALID_REQUEST" {
return false return false
@@ -3584,7 +3609,7 @@ extension NodeAppModel {
return gatewayError.message.lowercased().contains("unknown or expired approval id") return gatewayError.message.lowercased().contains("unknown or expired approval id")
} }
nonisolated private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool { private nonisolated static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false } guard let gatewayError = error as? GatewayResponseError else { return false }
if gatewayError.code != "INVALID_REQUEST" { if gatewayError.code != "INVALID_REQUEST" {
return false return false
@@ -3698,7 +3723,7 @@ extension NodeAppModel {
GatewayDiagnostics.log( GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true") "watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1_000)) / 1000.0 + 8.0)) let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1000)) / 1000.0 + 8.0))
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)") self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
GatewayDiagnostics.log( GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_lease_granted " "watch exec approval: watch_request_reconnect_lease_granted "
@@ -3722,7 +3747,7 @@ extension NodeAppModel {
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") " "watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
+ "reason=\(reconnectReason)") + "reason=\(reconnectReason)")
let initialWaitMs = min(2_500, max(750, timeoutMs / 4)) let initialWaitMs = min(2500, max(750, timeoutMs / 4))
GatewayDiagnostics.log( GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_wait " "watch exec approval: watch_request_reconnect_wait "
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)") + "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
@@ -3772,8 +3797,8 @@ extension NodeAppModel {
} }
private func reconnectGatewaySessionsForSilentPushIfNeeded( private func reconnectGatewaySessionsForSilentPushIfNeeded(
wakeId: String wakeId: String) async -> SilentPushWakeAttemptResult
) async -> SilentPushWakeAttemptResult { {
let startedAt = Date() let startedAt = Date()
let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
@@ -3817,8 +3842,7 @@ extension NodeAppModel {
let data = try await self.operatorGateway.request( let data = try await self.operatorGateway.request(
method: "voicewake.get", method: "voicewake.get",
paramsJSON: "{}", paramsJSON: "{}",
timeoutSeconds: 8 timeoutSeconds: 8)
)
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
VoiceWakePreferences.saveTriggerWords(triggers) VoiceWakePreferences.saveTriggerWords(triggers)
} catch { } catch {
@@ -3876,8 +3900,8 @@ extension NodeAppModel {
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return } guard !message.isEmpty else { return }
self.deepLinkLogger.info( self.deepLinkLogger.info(
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" // swiftlint:disable:next line_length
) "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)")
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
self.screen.errorText = "Deep link too large (message exceeds " self.screen.errorText = "Deep link too large (message exceeds "
@@ -4173,8 +4197,8 @@ extension NodeAppModel {
func _test_makeOperatorConnectOptions( func _test_makeOperatorConnectOptions(
clientId: String, clientId: String,
displayName: String?, displayName: String?,
includeApprovalScope: Bool includeApprovalScope: Bool) -> GatewayConnectOptions
) -> GatewayConnectOptions { {
self.makeOperatorConnectOptions( self.makeOperatorConnectOptions(
clientId: clientId, clientId: clientId,
displayName: displayName, displayName: displayName,
@@ -4244,8 +4268,8 @@ extension NodeAppModel {
host: String?, host: String?,
nodeId: String?, nodeId: String?,
agentId: String?, agentId: String?,
expiresAtMs: Int? expiresAtMs: Int?) -> ExecApprovalPrompt?
) -> ExecApprovalPrompt? { {
self.makeExecApprovalPrompt( self.makeExecApprovalPrompt(
from: ExecApprovalGetResponse( from: ExecApprovalGetResponse(
id: id, id: id,
@@ -4282,8 +4306,8 @@ extension NodeAppModel {
nonisolated static func _test_shouldRequestOperatorApprovalScope( nonisolated static func _test_shouldRequestOperatorApprovalScope(
token: String?, token: String?,
password: String?, password: String?,
storedOperatorScopes: [String] storedOperatorScopes: [String]) -> Bool
) -> Bool { {
self.shouldRequestOperatorApprovalScope( self.shouldRequestOperatorApprovalScope(
token: token, token: token,
password: password, password: password,
@@ -4291,8 +4315,8 @@ extension NodeAppModel {
} }
nonisolated static func _test_clearingBootstrapToken( nonisolated static func _test_clearingBootstrapToken(
in config: GatewayConnectConfig? in config: GatewayConnectConfig?) -> GatewayConnectConfig?
) -> GatewayConnectConfig? { {
self.clearingBootstrapToken(in: config) self.clearingBootstrapToken(in: config)
} }
@@ -4313,7 +4337,6 @@ extension NodeAppModel {
clientDisplayName: nil), clientDisplayName: nil),
sessionBox: nil) sessionBox: nil)
} }
} }
#endif #endif
// swiftlint:enable type_body_length file_length // swiftlint:enable type_body_length file_length

View File

@@ -62,7 +62,7 @@ final class MotionService: MotionServicing {
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO) let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
let pedometer = CMPedometer() let pedometer = CMPedometer()
let payload: OpenClawPedometerPayload = try await withCheckedThrowingContinuation { cont in return try await withCheckedThrowingContinuation { cont in
pedometer.queryPedometerData(from: start, to: end) { data, error in pedometer.queryPedometerData(from: start, to: end) { data, error in
if let error { if let error {
cont.resume(throwing: error) cont.resume(throwing: error)
@@ -79,7 +79,6 @@ final class MotionService: MotionServicing {
} }
} }
} }
return payload
} }
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) { private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {

View File

@@ -95,7 +95,6 @@ private struct AutoDetectStep: View {
} }
return nil return nil
} }
} }
private struct ManualEntryStep: View { private struct ManualEntryStep: View {
@@ -229,7 +228,7 @@ private struct ManualEntryStep: View {
private func manualPortValue() -> Int? { private func manualPortValue() -> Int? {
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
return Int(trimmed.filter { $0.isNumber }) return Int(trimmed.filter(\.isNumber))
} }
private func resetManualForm() { private func resetManualForm() {
@@ -334,7 +333,6 @@ private func resetGatewayConnectionState(
} }
@MainActor @MainActor
@ViewBuilder
private func gatewayConnectionStatusSection( private func gatewayConnectionStatusSection(
appModel: NodeAppModel, appModel: NodeAppModel,
gatewayController: GatewayConnectionController, gatewayController: GatewayConnectionController,
@@ -373,8 +371,8 @@ private struct ConnectionStatusBox: View {
static func defaultLines( static func defaultLines(
appModel: NodeAppModel, appModel: NodeAppModel,
gatewayController: GatewayConnectionController gatewayController: GatewayConnectionController) -> [String]
) -> [String] { {
var lines: [String] = [ var lines: [String] = [
"gateway: \(appModel.gatewayDisplayStatusText)", "gateway: \(appModel.gatewayDisplayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)", "discovery: \(gatewayController.discoveryStatusText)",

View File

@@ -25,7 +25,7 @@ enum OnboardingStateStore {
@MainActor @MainActor
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool { static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
if defaults.bool(forKey: Self.completedDefaultsKey) { return false } if defaults.bool(forKey: self.completedDefaultsKey) { return false }
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect // If we have a last-known connection config, don't force onboarding on launch. Auto-connect
// should handle reconnecting, and users can always open onboarding manually if needed. // should handle reconnecting, and users can always open onboarding manually if needed.
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false } if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
@@ -33,28 +33,28 @@ enum OnboardingStateStore {
} }
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) { static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
defaults.set(true, forKey: Self.completedDefaultsKey) defaults.set(true, forKey: self.completedDefaultsKey)
if let mode { if let mode {
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey) defaults.set(mode.rawValue, forKey: self.lastModeDefaultsKey)
} }
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey) defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
} }
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool { static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey) !defaults.bool(forKey: self.firstRunIntroSeenDefaultsKey)
} }
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) { static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey) defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
} }
static func markIncomplete(defaults: UserDefaults = .standard) { static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey) defaults.set(false, forKey: self.completedDefaultsKey)
} }
static func reset(defaults: UserDefaults = .standard) { static func reset(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey) defaults.set(false, forKey: self.completedDefaultsKey)
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey) defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
} }
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? { static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {

View File

@@ -1,5 +1,5 @@
import CoreImage
import Combine import Combine
import CoreImage
import OpenClawKit import OpenClawKit
import PhotosUI import PhotosUI
import SwiftUI import SwiftUI
@@ -151,8 +151,7 @@ struct OnboardingWizardView: View {
#selector(UIResponder.resignFirstResponder), #selector(UIResponder.resignFirstResponder),
to: nil, to: nil,
from: nil, from: nil,
for: nil for: nil)
)
} }
} }
} }
@@ -160,137 +159,136 @@ struct OnboardingWizardView: View {
.gatewayTrustPromptAlert() .gatewayTrustPromptAlert()
.alert("QR Scanner Unavailable", isPresented: Binding( .alert("QR Scanner Unavailable", isPresented: Binding(
get: { self.scannerError != nil }, get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } } set: { if !$0 { self.scannerError = nil } }))
)) { {
Button("OK", role: .cancel) {} Button("OK", role: .cancel) {}
} message: { } message: {
Text(self.scannerError ?? "") Text(self.scannerError ?? "")
} }
.sheet(isPresented: self.$showQRScanner) { .sheet(isPresented: self.$showQRScanner) {
NavigationStack { NavigationStack {
QRScannerView( QRScannerView(
onGatewayLink: { link in onGatewayLink: { link in
self.handleScannedLink(link) self.handleScannedLink(link)
}, },
onError: { error in onError: { error in
self.showQRScanner = false self.showQRScanner = false
self.statusLine = "Scanner error: \(error)" self.statusLine = "Scanner error: \(error)"
self.scannerError = error self.scannerError = error
}, },
onDismiss: { onDismiss: {
self.showQRScanner = false self.showQRScanner = false
}) })
.ignoresSafeArea() .ignoresSafeArea()
.navigationTitle("Scan QR Code") .navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false } Button("Cancel") { self.showQRScanner = false }
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
PhotosPicker(selection: self.$selectedPhoto, matching: .images) { PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
Label("Photos", systemImage: "photo") Label("Photos", systemImage: "photo")
}
}
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
self.showQRScanner = false
self.scannerError = "Could not load the selected image."
return
}
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
self.handleScannedLink(link)
return
}
if let url = URL(string: message),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handleScannedLink(link)
return
} }
} }
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
self.showQRScanner = false self.showQRScanner = false
self.scannerError = "Could not load the selected image." self.scannerError = "No valid QR code found in the selected image."
return
} }
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
self.handleScannedLink(link)
return
}
if let url = URL(string: message),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handleScannedLink(link)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
} }
} }
} .sheet(isPresented: self.$showGatewayProblemDetails) {
.sheet(isPresented: self.$showGatewayProblemDetails) { if let currentProblem = self.currentProblem {
if let currentProblem = self.currentProblem { GatewayProblemDetailsSheet(
GatewayProblemDetailsSheet( problem: currentProblem,
problem: currentProblem, primaryActionTitle: "Retry",
primaryActionTitle: "Retry", onPrimaryAction: {
onPrimaryAction: { Task { await self.retryLastAttempt() }
Task { await self.retryLastAttempt() } })
}) }
} }
} .onAppear {
.onAppear { self.initializeState()
self.initializeState()
}
.onDisappear {
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = nil
}
.onChange(of: self.discoveryDomain) { _, _ in
self.scheduleDiscoveryRestart()
}
.onChange(of: self.manualPortText) { _, newValue in
let digits = newValue.filter(\.isNumber)
if digits != newValue {
self.manualPortText = digits
return
} }
guard let parsed = Int(digits), parsed > 0 else { .onDisappear {
self.manualPort = 0 self.discoveryRestartTask?.cancel()
return self.discoveryRestartTask = nil
} }
self.manualPort = min(parsed, 65535) .onChange(of: self.discoveryDomain) { _, _ in
} self.scheduleDiscoveryRestart()
.onChange(of: self.manualPort) { _, newValue in
let normalized = newValue > 0 ? String(newValue) : ""
if self.manualPortText != normalized {
self.manualPortText = normalized
} }
} .onChange(of: self.manualPortText) { _, newValue in
.onChange(of: self.gatewayToken) { _, newValue in let digits = newValue.filter(\.isNumber)
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword) if digits != newValue {
} self.manualPortText = digits
.onChange(of: self.gatewayPassword) { _, newValue in return
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue) }
} guard let parsed = Int(digits), parsed > 0 else {
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in self.manualPort = 0
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText) return
} }
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in self.manualPort = min(parsed, 65535)
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue) }
} .onChange(of: self.manualPort) { _, newValue in
.onChange(of: self.appModel.gatewayServerName) { _, newValue in let normalized = newValue > 0 ? String(newValue) : ""
guard newValue != nil else { return } if self.manualPortText != normalized {
self.showQRScanner = false self.manualPortText = normalized
self.statusLine = "Connected." }
if !self.didMarkCompleted, let selectedMode { }
OnboardingStateStore.markCompleted(mode: selectedMode) .onChange(of: self.gatewayToken) { _, newValue in
self.didMarkCompleted = true self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
}
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
}
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
self.showQRScanner = false
self.statusLine = "Connected."
if !self.didMarkCompleted, let selectedMode {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
self.onClose()
}
.onChange(of: self.scenePhase) { _, newValue in
guard newValue == .active else { return }
self.attemptAutomaticPairingResumeIfNeeded()
}
.onReceive(Self.pairingAutoResumeTicker) { _ in
self.attemptAutomaticPairingResumeIfNeeded()
} }
self.onClose()
}
.onChange(of: self.scenePhase) { _, newValue in
guard newValue == .active else { return }
self.attemptAutomaticPairingResumeIfNeeded()
}
.onReceive(Self.pairingAutoResumeTicker) { _ in
self.attemptAutomaticPairingResumeIfNeeded()
}
} }
@ViewBuilder
private var introStep: some View { private var introStep: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
@@ -369,7 +367,6 @@ struct OnboardingWizardView: View {
} }
} }
@ViewBuilder
private var welcomeStep: some View { private var welcomeStep: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
@@ -712,7 +709,6 @@ struct OnboardingWizardView: View {
} }
} }
@ViewBuilder
private func manualConnectionFieldsSection(title: String) -> some View { private func manualConnectionFieldsSection(title: String) -> some View {
Section(title) { Section(title) {
TextField("Host", text: self.$manualHost) TextField("Host", text: self.$manualHost)
@@ -868,8 +864,7 @@ struct OnboardingWizardView: View {
let detector = CIDetector( let detector = CIDetector(
ofType: CIDetectorTypeQRCode, ofType: CIDetectorTypeQRCode,
context: nil, context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
)
let features = detector?.features(in: ciImage) ?? [] let features = detector?.features(in: ciImage) ?? []
for feature in features { for feature in features {
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString { if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
@@ -891,6 +886,7 @@ struct OnboardingWizardView: View {
self.connectMessage = nil self.connectMessage = nil
self.step = target self.step = target
} }
private var canConnectManual: Bool { private var canConnectManual: Bool {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
@@ -919,7 +915,7 @@ struct OnboardingWizardView: View {
if self.selectedMode == nil { if self.selectedMode == nil {
self.selectedMode = OnboardingStateStore.lastMode() self.selectedMode = OnboardingStateStore.lastMode()
} }
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" { if self.selectedMode == .developerLocal, self.manualHost == "openclaw.local" {
self.manualHost = "localhost" self.manualHost = "localhost"
self.manualTLS = false self.manualTLS = false
} }

View File

@@ -1,9 +1,9 @@
import SwiftUI import BackgroundTasks
import Foundation import Foundation
import OpenClawKit import OpenClawKit
import os import os
import SwiftUI
import UIKit import UIKit
import BackgroundTasks
@preconcurrency import UserNotifications @preconcurrency import UserNotifications
private struct PendingWatchPromptAction { private struct PendingWatchPromptAction {
@@ -88,16 +88,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
self.appModel ?? OpenClawAppModelRegistry.appModel self.appModel ?? OpenClawAppModelRegistry.appModel
} }
#if DEBUG #if DEBUG
func _test_resolvedAppModel() -> NodeAppModel? { func _test_resolvedAppModel() -> NodeAppModel? {
self.resolvedAppModel() self.resolvedAppModel()
} }
#endif #endif
func application( func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
) -> Bool
{ {
GatewayDiagnostics.log("app delegate: didFinishLaunching") GatewayDiagnostics.log("app delegate: didFinishLaunching")
if self.appModel == nil { if self.appModel == nil {
@@ -151,7 +150,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
guard let appModel = self.resolvedAppModel() else { guard let appModel = self.resolvedAppModel() else {
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo) if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
== ExecApprovalNotificationBridge.requestedKind, == ExecApprovalNotificationBridge.requestedKind,
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
{ {
self.pendingExecApprovalRequestedPushIDs.append(approvalId) self.pendingExecApprovalRequestedPushIDs.append(approvalId)
} }
@@ -179,8 +178,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
private func registerBackgroundWakeRefreshTask() { private func registerBackgroundWakeRefreshTask() {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier, forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier,
using: nil using: nil)
) { [weak self] task in { [weak self] task in
guard let refreshTask = task as? BGAppRefreshTask else { guard let refreshTask = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false) task.setTaskCompleted(success: false)
return return
@@ -196,17 +195,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
let scheduledLogMessage = let scheduledLogMessage =
"Scheduled background wake refresh reason=\(reason) " "Scheduled background wake refresh reason=\(reason) "
+ "delaySeconds=\(max(60, delay))" + "delaySeconds=\(max(60, delay))"
self.backgroundWakeLogger.info( self.backgroundWakeLogger.info(
"\(scheduledLogMessage, privacy: .public)" "\(scheduledLogMessage, privacy: .public)")
)
} catch { } catch {
let failedLogMessage = let failedLogMessage =
"Failed scheduling background wake refresh reason=\(reason) " "Failed scheduling background wake refresh reason=\(reason) "
+ "error=\(error.localizedDescription)" + "error=\(error.localizedDescription)"
self.backgroundWakeLogger.error( self.backgroundWakeLogger.error(
"\(failedLogMessage, privacy: .public)" "\(failedLogMessage, privacy: .public)")
)
} }
} }
@@ -475,14 +472,13 @@ enum WatchPromptNotificationBridge {
private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] { private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] {
actions.enumerated().map { index, action in actions.enumerated().map { index, action in
let identifier: String let identifier: String = switch index {
switch index {
case 0: case 0:
identifier = self.actionPrimaryIdentifier self.actionPrimaryIdentifier
case 1: case 1:
identifier = self.actionSecondaryIdentifier self.actionSecondaryIdentifier
default: default:
identifier = "\(self.actionIdentifierPrefix)\(index)" "\(self.actionIdentifierPrefix)\(index)"
} }
return UNNotificationAction( return UNNotificationAction(
identifier: identifier, identifier: identifier,
@@ -494,12 +490,12 @@ enum WatchPromptNotificationBridge {
private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions {
switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "destructive": case "destructive":
return [.destructive] [.destructive]
case "foreground": case "foreground":
// For mirrored watch actions, keep handling in background when possible. // For mirrored watch actions, keep handling in background when possible.
return [] []
default: default:
return [] []
} }
} }
@@ -510,7 +506,7 @@ enum WatchPromptNotificationBridge {
case .authorized, .provisional, .ephemeral: case .authorized, .provisional, .ephemeral:
return true return true
case .notDetermined: case .notDetermined:
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false } if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center) let updatedStatus = await self.notificationAuthorizationStatus(center: center)
if self.isAuthorizationStatusAllowed(updatedStatus) { if self.isAuthorizationStatusAllowed(updatedStatus) {
@@ -540,8 +536,8 @@ enum WatchPromptNotificationBridge {
} }
private static func notificationAuthorizationStatus( private static func notificationAuthorizationStatus(
center: UNUserNotificationCenter center: UNUserNotificationCenter) async -> UNAuthorizationStatus
) async -> UNAuthorizationStatus { {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
center.getNotificationSettings { settings in center.getNotificationSettings { settings in
continuation.resume(returning: settings.authorizationStatus) continuation.resume(returning: settings.authorizationStatus)
@@ -565,8 +561,8 @@ enum WatchPromptNotificationBridge {
private static func addNotificationRequest( private static func addNotificationRequest(
_ request: UNNotificationRequest, _ request: UNNotificationRequest,
center: UNUserNotificationCenter center: UNUserNotificationCenter) async throws
) async throws { {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
center.add(request) { error in center.add(request) { error in
ThrowingContinuationSupport.resumeVoid(continuation, error: error) ThrowingContinuationSupport.resumeVoid(continuation, error: error)

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
import UserNotifications @preconcurrency import UserNotifications
struct ExecApprovalNotificationPrompt: Sendable, Equatable { struct ExecApprovalNotificationPrompt: Equatable {
let approvalId: String let approvalId: String
} }
@@ -38,8 +38,7 @@ enum ExecApprovalNotificationBridge {
static func parsePrompt( static func parsePrompt(
actionIdentifier: String, actionIdentifier: String,
userInfo: [AnyHashable: Any] userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
) -> ExecApprovalNotificationPrompt?
{ {
guard actionIdentifier == UNNotificationDefaultActionIdentifier guard actionIdentifier == UNNotificationDefaultActionIdentifier
|| actionIdentifier == self.reviewActionIdentifier || actionIdentifier == self.reviewActionIdentifier
@@ -54,8 +53,7 @@ enum ExecApprovalNotificationBridge {
@MainActor @MainActor
static func handleResolvedPushIfNeeded( static func handleResolvedPushIfNeeded(
userInfo: [AnyHashable: Any], userInfo: [AnyHashable: Any],
notificationCenter: NotificationCentering notificationCenter: NotificationCentering) async -> Bool
) async -> Bool
{ {
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind, guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
let approvalId = self.approvalID(from: userInfo) let approvalId = self.approvalID(from: userInfo)
@@ -70,8 +68,8 @@ enum ExecApprovalNotificationBridge {
@MainActor @MainActor
static func removeNotifications( static func removeNotifications(
forApprovalID approvalId: String, forApprovalID approvalId: String,
notificationCenter: NotificationCentering notificationCenter: NotificationCentering) async
) async { {
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return } guard !normalizedID.isEmpty else { return }

View File

@@ -84,7 +84,7 @@ actor PushRegistrationManager {
} }
guard let installationId = GatewaySettingsStore.loadStableInstanceID()? guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!installationId.isEmpty !installationId.isEmpty
else { else {
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration") throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
} }
@@ -145,7 +145,7 @@ actor PushRegistrationManager {
guard let expiresAtMs else { return true } guard let expiresAtMs else { return true }
let nowMs = Int64(Date().timeIntervalSince1970 * 1000) let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
// Refresh shortly before expiry so reconnect-path republishes a live handle. // Refresh shortly before expiry so reconnect-path republishes a live handle.
return expiresAtMs <= nowMs + 60_000 return expiresAtMs <= nowMs + 60000
} }
private static func sha256Hex(_ value: String) -> String { private static func sha256Hex(_ value: String) -> String {

View File

@@ -24,7 +24,7 @@ enum PushRelayError: LocalizedError {
case .unsupportedAppAttest: case .unsupportedAppAttest:
"App Attest unavailable on this device" "App Attest unavailable on this device"
case .missingReceipt: case .missingReceipt:
"App Store receipt missing after refresh" "App Store app transaction missing after refresh"
} }
} }
} }
@@ -85,33 +85,6 @@ private struct RelayErrorResponse: Decodable {
var reason: String? var reason: String?
} }
private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
private var continuation: CheckedContinuation<Void, Error>?
private var activeRequest: SKReceiptRefreshRequest?
func refresh() async throws {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let request = SKReceiptRefreshRequest()
self.activeRequest = request
request.delegate = self
request.start()
}
}
func requestDidFinish(_ request: SKRequest) {
self.continuation?.resume(returning: ())
self.continuation = nil
self.activeRequest = nil
}
func request(_ request: SKRequest, didFailWithError error: Error) {
self.continuation?.resume(throwing: error)
self.continuation = nil
self.activeRequest = nil
}
}
private struct PushRelayAppAttestProof { private struct PushRelayAppAttestProof {
var keyId: String var keyId: String
var attestationObject: String? var attestationObject: String?
@@ -197,25 +170,27 @@ private final class PushRelayAppAttestService {
private final class PushRelayReceiptProvider { private final class PushRelayReceiptProvider {
func loadReceiptBase64() async throws -> String { func loadReceiptBase64() async throws -> String {
if let receipt = self.readReceiptData() { do {
return receipt.base64EncodedString() let result = try await AppTransaction.shared
return try Self.appTransactionBase64(result)
} catch {
let refreshed = try await AppTransaction.refresh()
return try Self.appTransactionBase64(refreshed)
} }
let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
try await refreshCoordinator.refresh()
if let refreshed = self.readReceiptData() {
return refreshed.base64EncodedString()
}
throw PushRelayError.missingReceipt
} }
private func readReceiptData() -> Data? { private static func appTransactionBase64(
guard let url = Bundle.main.appStoreReceiptURL else { return nil } _ result: StoreKit.VerificationResult<AppTransaction>) throws -> String
guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil } {
return data let jws = result.jwsRepresentation.trimmingCharacters(in: .whitespacesAndNewlines)
guard !jws.isEmpty else {
throw PushRelayError.missingReceipt
}
return Data(jws.utf8).base64EncodedString()
} }
} }
// The client is constructed once and used behind PushRegistrationManager actor isolation. /// The client is constructed once and used behind PushRegistrationManager actor isolation.
final class PushRelayClient: @unchecked Sendable { final class PushRelayClient: @unchecked Sendable {
private let baseURL: URL private let baseURL: URL
private let session: URLSession private let session: URLSession
@@ -294,8 +269,7 @@ final class PushRelayClient: @unchecked Sendable {
status: status, status: status,
message: Self.decodeErrorMessage(data: data)) message: Self.decodeErrorMessage(data: data))
} }
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data) return try self.decode(PushRelayRegisterResponse.self, from: data)
return decoded
} }
private func fetchChallenge() async throws -> PushRelayChallengeResponse { private func fetchChallenge() async throws -> PushRelayChallengeResponse {

View File

@@ -23,11 +23,11 @@ final class RemindersService: RemindersServicing {
let filtered = (items ?? []).filter { reminder in let filtered = (items ?? []).filter { reminder in
switch statusFilter { switch statusFilter {
case .all: case .all:
return true true
case .completed: case .completed:
return reminder.isCompleted reminder.isCompleted
case .incomplete: case .incomplete:
return !reminder.isCompleted !reminder.isCompleted
} }
} }
let selected = Array(filtered.prefix(limit)) let selected = Array(filtered.prefix(limit))

View File

@@ -1,6 +1,6 @@
import OpenClawProtocol
import SwiftUI import SwiftUI
import UIKit import UIKit
import OpenClawProtocol
struct RootCanvas: View { struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel @Environment(NodeAppModel.self) private var appModel
@@ -262,7 +262,7 @@ struct RootCanvas: View {
eyebrow: "Connected to \(gatewayLabel)", eyebrow: "Connected to \(gatewayLabel)",
title: "Your agents are ready", title: "Your agents are ready",
subtitle: subtitle:
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
gatewayLabel: gatewayLabel, gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName, activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC", activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
@@ -276,7 +276,7 @@ struct RootCanvas: View {
eyebrow: "Reconnecting", eyebrow: "Reconnecting",
title: "OpenClaw is syncing back up", title: "OpenClaw is syncing back up",
subtitle: subtitle:
"The gateway session is coming back online. " "The gateway session is coming back online. "
+ "Agent shortcuts should settle automatically in a moment.", + "Agent shortcuts should settle automatically in a moment.",
gatewayLabel: gatewayLabel, gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName, activeAgentName: self.appModel.activeAgentName,
@@ -291,7 +291,7 @@ struct RootCanvas: View {
eyebrow: "Welcome to OpenClaw", eyebrow: "Welcome to OpenClaw",
title: "Your phone stays quiet until it is needed", title: "Your phone stays quiet until it is needed",
subtitle: subtitle:
"Pair this device to your gateway to wake it only for real work, " "Pair this device to your gateway to wake it only for real work, "
+ "keep a live agent overview handy, and avoid battery-draining background loops.", + "keep a live agent overview handy, and avoid battery-draining background loops.",
gatewayLabel: gatewayLabel, gatewayLabel: gatewayLabel,
activeAgentName: "Main", activeAgentName: "Main",
@@ -300,7 +300,7 @@ struct RootCanvas: View {
agentCount: agents.count, agentCount: agents.count,
agents: Array(agents.prefix(4)), agents: Array(agents.prefix(4)),
footer: footer:
"When connected, the gateway can wake the phone with a silent push " "When connected, the gateway can wake the phone with a silent push "
+ "instead of holding an always-on session.") + "instead of holding an always-on session.")
} }
} }
@@ -352,7 +352,7 @@ struct RootCanvas: View {
let words = self.homeCanvasName(for: agent) let words = self.homeCanvasName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) .split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2) .prefix(2)
let initials = words.compactMap { $0.first }.map(String.init).joined() let initials = words.compactMap(\.first).map(String.init).joined()
if !initials.isEmpty { if !initials.isEmpty {
return initials.uppercased() return initials.uppercased()
} }
@@ -468,8 +468,13 @@ private struct CanvasContent: View {
var openSettings: () -> Void var openSettings: () -> Void
var retryGatewayConnection: () -> Void var retryGatewayConnection: () -> Void
private var brightenButtons: Bool { self.systemColorScheme == .light } private var brightenButtons: Bool {
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled } self.systemColorScheme == .light
}
private var talkActive: Bool {
self.appModel.talkMode.isEnabled || self.talkEnabled
}
var body: some View { var body: some View {
ZStack { ZStack {

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Observation import Observation
import OpenClawKit
import UIKit import UIKit
import WebKit import WebKit
@@ -194,7 +194,7 @@ final class ScreenController {
NSLocalizedDescriptionKey: "web view unavailable", NSLocalizedDescriptionKey: "web view unavailable",
]) ])
} }
let image: UIImage = try await withCheckedThrowingContinuation { cont in return try await withCheckedThrowingContinuation { cont in
webView.takeSnapshot(with: config) { image, error in webView.takeSnapshot(with: config) { image, error in
if let error { if let error {
cont.resume(throwing: error) cont.resume(throwing: error)
@@ -209,7 +209,6 @@ final class ScreenController {
cont.resume(returning: image) cont.resume(returning: image)
} }
} }
return image
} }
func attachWebView(_ webView: WKWebView) { func attachWebView(_ webView: WKWebView) {

View File

@@ -319,7 +319,6 @@ final class ScreenRecordService: @unchecked Sendable {
} }
} }
} }
} }
@MainActor @MainActor

View File

@@ -69,7 +69,7 @@ protocol MotionServicing: Sendable {
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
} }
struct WatchMessagingStatus: Sendable, Equatable { struct WatchMessagingStatus: Equatable {
var supported: Bool var supported: Bool
var paired: Bool var paired: Bool
var appInstalled: Bool var appInstalled: Bool
@@ -77,7 +77,7 @@ struct WatchMessagingStatus: Sendable, Equatable {
var activationState: String var activationState: String
} }
struct WatchQuickReplyEvent: Sendable, Equatable { struct WatchQuickReplyEvent: Equatable {
var replyId: String var replyId: String
var promptId: String var promptId: String
var actionId: String var actionId: String
@@ -88,7 +88,7 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
var transport: String var transport: String
} }
struct WatchExecApprovalResolveEvent: Sendable, Equatable { struct WatchExecApprovalResolveEvent: Equatable {
var replyId: String var replyId: String
var approvalId: String var approvalId: String
var decision: OpenClawWatchExecApprovalDecision var decision: OpenClawWatchExecApprovalDecision
@@ -96,13 +96,13 @@ struct WatchExecApprovalResolveEvent: Sendable, Equatable {
var transport: String var transport: String
} }
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable { struct WatchExecApprovalSnapshotRequestEvent: Equatable {
var requestId: String var requestId: String
var sentAtMs: Int? var sentAtMs: Int?
var transport: String var transport: String
} }
struct WatchNotificationSendResult: Sendable, Equatable { struct WatchNotificationSendResult: Equatable {
var deliveredImmediately: Bool var deliveredImmediately: Bool
var queuedForDelivery: Bool var queuedForDelivery: Bool
var transport: String var transport: String

View File

@@ -6,7 +6,7 @@ struct NotificationSnapshot: @unchecked Sendable {
let userInfo: [AnyHashable: Any] let userInfo: [AnyHashable: Any]
} }
enum NotificationAuthorizationStatus: Sendable { enum NotificationAuthorizationStatus {
case notDetermined case notDetermined
case denied case denied
case authorized case authorized

View File

@@ -21,13 +21,12 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
}, },
errorHandler: { error in errorHandler: { error in
continuation.resume(throwing: error) continuation.resume(throwing: error)
} })
)
} }
} }
final class WatchConnectivityTransport: NSObject, @unchecked Sendable { final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private let session: WCSession? private let session: WCSession?
private let callbacksLock = NSLock() private let callbacksLock = NSLock()
@@ -228,7 +227,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
} }
} }
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus { private nonisolated static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus( WatchMessagingStatus(
supported: true, supported: true,
paired: session.isPaired, paired: session.isPaired,
@@ -237,7 +236,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
activationState: self.activationStateLabel(session.activationState)) activationState: self.activationStateLabel(session.activationState))
} }
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String { private nonisolated static func activationStateLabel(_ state: WCSessionActivationState) -> String {
switch state { switch state {
case .notActivated: case .notActivated:
"notActivated" "notActivated"

View File

@@ -21,7 +21,7 @@ enum WatchMessagingPayloadCodec {
"title": params.title, "title": params.title,
"body": params.body, "body": params.body,
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
"sentAtMs": nowMs(), "sentAtMs": self.nowMs(),
] ]
if let promptId = nonEmpty(params.promptId) { if let promptId = nonEmpty(params.promptId) {
payload["promptId"] = promptId payload["promptId"] = promptId
@@ -88,7 +88,7 @@ enum WatchMessagingPayloadCodec {
{ {
var payload: [String: Any] = [ var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue, "type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
"approval": encodeExecApprovalItem(message.approval), "approval": self.encodeExecApprovalItem(message.approval),
] ]
if let sentAtMs = message.sentAtMs { if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs payload["sentAtMs"] = sentAtMs
@@ -140,7 +140,7 @@ enum WatchMessagingPayloadCodec {
{ {
var payload: [String: Any] = [ var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue, "type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
"approvals": message.approvals.map(encodeExecApprovalItem), "approvals": message.approvals.map(self.encodeExecApprovalItem),
] ]
if let sentAtMs = message.sentAtMs { if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs payload["sentAtMs"] = sentAtMs
@@ -161,11 +161,11 @@ enum WatchMessagingPayloadCodec {
guard let actionId = nonEmpty(payload["actionId"] as? String) else { guard let actionId = nonEmpty(payload["actionId"] as? String) else {
return nil return nil
} }
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown" let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown"
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = nonEmpty(payload["actionLabel"] as? String) let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
let sessionKey = nonEmpty(payload["sessionKey"] as? String) let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let note = nonEmpty(payload["note"] as? String) let note = self.nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchQuickReplyEvent( return WatchQuickReplyEvent(
@@ -192,7 +192,7 @@ enum WatchMessagingPayloadCodec {
else { else {
return nil return nil
} }
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalResolveEvent( return WatchExecApprovalResolveEvent(
replyId: replyId, replyId: replyId,
@@ -209,7 +209,7 @@ enum WatchMessagingPayloadCodec {
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else { guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
return nil return nil
} }
let requestId = nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalSnapshotRequestEvent( return WatchExecApprovalSnapshotRequestEvent(
requestId: requestId, requestId: requestId,

View File

@@ -1,6 +1,6 @@
import OpenClawKit
import Network import Network
import Observation import Observation
import OpenClawKit
import os import os
import SwiftUI import SwiftUI
import UIKit import UIKit
@@ -247,8 +247,7 @@ struct SettingsTab: View {
.padding(10) .padding(10)
.background( .background(
.thinMaterial, .thinMaterial,
in: RoundedRectangle(cornerRadius: 10, style: .continuous) in: RoundedRectangle(cornerRadius: 10, style: .continuous))
)
} }
} }
} label: { } label: {
@@ -270,15 +269,17 @@ struct SettingsTab: View {
self.featureToggle( self.featureToggle(
"Voice Wake", "Voice Wake",
isOn: self.$voiceWakeEnabled, isOn: self.$voiceWakeEnabled,
help: "Enables wake-word activation to start a hands-free session.") { newValue in help: "Enables wake-word activation to start a hands-free session.")
self.appModel.setVoiceWakeEnabled(newValue) { newValue in
} self.appModel.setVoiceWakeEnabled(newValue)
}
self.featureToggle( self.featureToggle(
"Talk Mode", "Talk Mode",
isOn: self.$talkEnabled, isOn: self.$talkEnabled,
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in help: "Enables voice conversation mode with your connected OpenClaw agent.")
self.appModel.setTalkEnabled(newValue) { newValue in
} self.appModel.setTalkEnabled(newValue)
}
Picker("Speech Language", selection: self.$talkSpeechLocale) { Picker("Speech Language", selection: self.$talkSpeechLocale) {
ForEach(TalkSpeechLocale.supportedOptions()) { option in ForEach(TalkSpeechLocale.supportedOptions()) { option in
Text(option.label).tag(option.id) Text(option.label).tag(option.id)
@@ -301,8 +302,7 @@ struct SettingsTab: View {
"Allow Camera", "Allow Camera",
isOn: self.$cameraEnabled, isOn: self.$cameraEnabled,
help: "Allows the gateway to request photos or short video clips " help: "Allows the gateway to request photos or short video clips "
+ "while OpenClaw is foregrounded." + "while OpenClaw is foregrounded.")
)
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Location Access") Text("Location Access")
@@ -313,8 +313,7 @@ struct SettingsTab: View {
message: "Controls location permissions for OpenClaw. " message: "Controls location permissions for OpenClaw. "
+ "Off disables location tools, While Using enables " + "Off disables location tools, While Using enables "
+ "foreground location, and Always enables " + "foreground location, and Always enables "
+ "background location." + "background location.")
)
} label: { } label: {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -347,8 +346,7 @@ struct SettingsTab: View {
? ( ? (
self.appModel.talkMode.gatewayTalkApiKeyConfigured self.appModel.talkMode.gatewayTalkApiKeyConfigured
? "Configured" ? "Configured"
: "Not configured" : "Not configured")
)
: "Not loaded") : "Not loaded")
LabeledContent( LabeledContent(
"Default Model", "Default Model",
@@ -365,7 +363,7 @@ struct SettingsTab: View {
isOn: self.$talkButtonEnabled, isOn: self.$talkButtonEnabled,
help: "Shows the Talk control in the main toolbar.") help: "Shows the Talk control in the main toolbar.")
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
.lineLimit(2 ... 6) .lineLimit(2...6)
.textInputAutocapitalization(.sentences) .textInputAutocapitalization(.sentences)
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Default Share Instruction") Text("Default Share Instruction")
@@ -376,8 +374,7 @@ struct SettingsTab: View {
self.activeFeatureHelp = FeatureHelp( self.activeFeatureHelp = FeatureHelp(
title: "Default Share Instruction", title: "Default Share Instruction",
message: "Appends this instruction when sharing content " message: "Appends this instruction when sharing content "
+ "into OpenClaw from iOS." + "into OpenClaw from iOS.")
)
} label: { } label: {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -441,8 +438,7 @@ struct SettingsTab: View {
} message: { } message: {
Text( Text(
"This will disconnect, clear saved gateway connection + credentials, " "This will disconnect, clear saved gateway connection + credentials, "
+ "and reopen the onboarding wizard." + "and reopen the onboarding wizard.")
)
} }
.alert(item: self.$activeFeatureHelp) { help in .alert(item: self.$activeFeatureHelp) { help in
Alert( Alert(
@@ -635,8 +631,8 @@ struct SettingsTab: View {
_ title: String, _ title: String,
isOn: Binding<Bool>, isOn: Binding<Bool>,
help: String, help: String,
onChange: ((Bool) -> Void)? = nil onChange: ((Bool) -> Void)? = nil) -> some View
) -> some View { {
HStack(spacing: 8) { HStack(spacing: 8) {
Toggle(title, isOn: isOn) Toggle(title, isOn: isOn)
Button { Button {
@@ -754,8 +750,7 @@ struct SettingsTab: View {
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
GatewayDiagnostics.log( GatewayDiagnostics.log(
"setup code applied host=\(host) port=\(resolvedPort ?? -1) " "setup code applied host=\(host) port=\(resolvedPort ?? -1) "
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)" + "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
)
guard let port = resolvedPort else { guard let port = resolvedPort else {
self.setupStatusText = "Failed: invalid port" self.setupStatusText = "Failed: invalid port"
return return
@@ -858,7 +853,7 @@ struct SettingsTab: View {
} }
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") { if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
return 443 return 443
} }
return 18789 return 18789
@@ -868,7 +863,7 @@ struct SettingsTab: View {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false } guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() { if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
let msg = "Tailscale is off on this iPhone. Turn it on, then try again." let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
self.setupStatusText = msg self.setupStatusText = msg
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)") GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
@@ -1095,4 +1090,5 @@ struct SettingsTab: View {
return lines return lines
} }
} }
// swiftlint:enable type_body_length // swiftlint:enable type_body_length

View File

@@ -1,5 +1,5 @@
import SwiftUI
import Combine import Combine
import SwiftUI
struct VoiceWakeWordsSettingsView: View { struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel @Environment(NodeAppModel.self) private var appModel

View File

@@ -6,8 +6,8 @@ enum StatusActivityBuilder {
appModel: NodeAppModel, appModel: NodeAppModel,
voiceWakeEnabled: Bool, voiceWakeEnabled: Bool,
cameraHUDText: String?, cameraHUDText: String?,
cameraHUDKind: NodeAppModel.CameraHUDKind? cameraHUDKind: NodeAppModel.CameraHUDKind?) -> StatusPill.Activity?
) -> StatusPill.Activity? { {
// Keep the top pill consistent across tabs (camera + voice wake + pairing states). // Keep the top pill consistent across tabs (camera + voice wake + pairing states).
if appModel.isBackgrounded { if appModel.isBackgrounded {
return StatusPill.Activity( return StatusPill.Activity(
@@ -19,9 +19,9 @@ enum StatusActivityBuilder {
if let gatewayProblem = appModel.lastGatewayProblem { if let gatewayProblem = appModel.lastGatewayProblem {
switch gatewayProblem.kind { switch gatewayProblem.kind {
case .pairingRequired, case .pairingRequired,
.pairingRoleUpgradeRequired, .pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired: .pairingMetadataUpgradeRequired:
return StatusPill.Activity( return StatusPill.Activity(
title: "Approval pending", title: "Approval pending",
systemImage: "person.crop.circle.badge.clock", systemImage: "person.crop.circle.badge.clock",
@@ -93,4 +93,3 @@ enum StatusActivityBuilder {
return nil return nil
} }
} }

View File

@@ -18,8 +18,7 @@ private struct StatusGlassCardModifier: ViewModifier {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder( .strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5 lineWidth: self.contrast == .increased ? 1.0 : 0.5)
)
} }
.shadow(color: .black.opacity(0.25), radius: 12, y: 6) .shadow(color: .black.opacity(0.25), radius: 12, y: 6)
} }
@@ -32,8 +31,6 @@ extension View {
StatusGlassCardModifier( StatusGlassCardModifier(
brighten: brighten, brighten: brighten,
verticalPadding: verticalPadding, verticalPadding: verticalPadding,
horizontalPadding: horizontalPadding horizontalPadding: horizontalPadding))
)
)
} }
} }

View File

@@ -54,8 +54,7 @@ struct StatusPill: View {
.scaleEffect( .scaleEffect(
self.gateway == .connecting && !self.reduceMotion self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85) ? (self.pulse ? 1.15 : 0.85)
: 1.0 : 1.0)
)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title) Text(self.gateway.title)

View File

@@ -20,8 +20,8 @@ enum TalkModeGatewayConfigParser {
config: [String: Any], config: [String: Any],
defaultProvider: String, defaultProvider: String,
defaultModelIdFallback: String, defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int defaultSilenceTimeoutMs: Int) -> TalkModeGatewayConfigState
) -> TalkModeGatewayConfigState { {
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any]) let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
let selection = TalkConfigParsing.selectProviderConfig( let selection = TalkConfigParsing.selectProviderConfig(
talk, talk,

View File

@@ -1,9 +1,9 @@
import AVFAudio import AVFAudio
import Foundation
import Observation
import OpenClawChatUI import OpenClawChatUI
import OpenClawKit import OpenClawKit
import OpenClawProtocol import OpenClawProtocol
import Foundation
import Observation
import OSLog import OSLog
import Speech import Speech
@@ -99,7 +99,7 @@ final class TalkModeManager: NSObject {
private var gateway: GatewayNodeSession? private var gateway: GatewayNodeSession?
private var gatewayConnected = false private var gatewayConnected = false
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000 private var silenceWindow: TimeInterval = .init(TalkModeManager.defaultSilenceTimeoutMs) / 1000
private var lastAudioActivity: Date? private var lastAudioActivity: Date?
private var noiseFloorSamples: [Double] = [] private var noiseFloorSamples: [Double] = []
private var noiseFloor: Double? private var noiseFloor: Double?
@@ -488,16 +488,16 @@ final class TalkModeManager: NSObject {
private func startRecognition() throws { private func startRecognition() throws {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
if self.allowSimulatorCapture { if self.allowSimulatorCapture {
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
self.recognitionRequest?.shouldReportPartialResults = true self.recognitionRequest?.shouldReportPartialResults = true
return return
} }
if !self.allowSimulatorCapture { if !self.allowSimulatorCapture {
throw NSError(domain: "TalkMode", code: 2, userInfo: [ throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator", NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
]) ])
} }
#endif #endif
self.stopRecognition() self.stopRecognition()
@@ -550,8 +550,7 @@ final class TalkModeManager: NSObject {
let threshold = min(0.35, max(0.12, avg + 0.10)) let threshold = min(0.35, max(0.12, avg + 0.10))
GatewayDiagnostics.log( GatewayDiagnostics.log(
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) " "talk audio: noiseFloor=\(String(format: "%.3f", avg)) "
+ "threshold=\(String(format: "%.3f", threshold))" + "threshold=\(String(format: "%.3f", threshold))")
)
} }
} }
@@ -576,8 +575,7 @@ final class TalkModeManager: NSObject {
GatewayDiagnostics.log( GatewayDiagnostics.log(
"talk speech: recognition started mode=\(String(describing: self.captureMode)) " "talk speech: recognition started mode=\(String(describing: self.captureMode)) "
+ "engineRunning=\(self.audioEngine.isRunning)" + "engineRunning=\(self.audioEngine.isRunning)")
)
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self else { return } guard let self else { return }
if let error { if let error {
@@ -722,7 +720,7 @@ final class TalkModeManager: NSObject {
guard self.isListening, !self.isSpeechOutputActive else { return } guard self.isListening, !self.isSpeechOutputActive else { return }
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !transcript.isEmpty else { return } guard !transcript.isEmpty else { return }
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max() let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap(\.self).max()
guard let lastActivity else { return } guard let lastActivity else { return }
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return } if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
await self.processTranscript(transcript, restartAfter: true) await self.processTranscript(transcript, restartAfter: true)
@@ -733,13 +731,13 @@ final class TalkModeManager: NSObject {
guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return } guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return }
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !transcript.isEmpty else { return } guard !transcript.isEmpty else { return }
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max() let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap(\.self).max()
guard let lastActivity else { return } guard let lastActivity else { return }
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return } if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
_ = await self.endPushToTalk() _ = await self.endPushToTalk()
} }
// Guardrail for PTT once so we don't stay open indefinitely. /// Guardrail for PTT once so we don't stay open indefinitely.
private func schedulePTTTimeout(seconds: TimeInterval) { private func schedulePTTTimeout(seconds: TimeInterval) {
guard seconds > 0 else { return } guard seconds > 0 else { return }
let nanos = UInt64(seconds * 1_000_000_000) let nanos = UInt64(seconds * 1_000_000_000)
@@ -1103,7 +1101,10 @@ final class TalkModeManager: NSObject {
result = await self.mp3Player.play(stream: rawStream) result = await self.mp3Player.play(stream: rawStream)
} }
let duration = Date().timeIntervalSince(started) let duration = Date().timeIntervalSince(started)
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s") self.logger
.info(
// swiftlint:disable:next line_length
"elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
if !result.finished, let interruptedAt = result.interruptedAt { if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt self.lastInterruptedAtSeconds = interruptedAt
} }
@@ -1186,9 +1187,9 @@ final class TalkModeManager: NSObject {
return !route.outputs.contains { output in return !route.outputs.contains { output in
switch output.portType { switch output.portType {
case .builtInSpeaker, .builtInReceiver: case .builtInSpeaker, .builtInReceiver:
return true true
default: default:
return false false
} }
} }
} }
@@ -1392,8 +1393,7 @@ final class TalkModeManager: NSObject {
private func consumeIncrementalPrefetchedAudioIfAvailable( private func consumeIncrementalPrefetchedAudioIfAvailable(
for segment: String, for segment: String,
context: IncrementalSpeechContext? context: IncrementalSpeechContext?) async -> IncrementalPrefetchedAudio?
) async -> IncrementalPrefetchedAudio?
{ {
guard let context else { guard let context else {
self.cancelIncrementalPrefetch() self.cancelIncrementalPrefetch()
@@ -1467,8 +1467,8 @@ final class TalkModeManager: NSObject {
guard evt.event == "agent", let payload = evt.payload else { continue } guard evt.event == "agent", let payload = evt.payload else { continue }
guard let agentEvent = try? GatewayPayloadDecoding.decode( guard let agentEvent = try? GatewayPayloadDecoding.decode(
payload, payload,
as: OpenClawAgentEventPayload.self as: OpenClawAgentEventPayload.self)
) else { else {
continue continue
} }
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue } guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
@@ -1550,8 +1550,7 @@ final class TalkModeManager: NSObject {
private func makeIncrementalTTSRequest( private func makeIncrementalTTSRequest(
text: String, text: String,
context: IncrementalSpeechContext, context: IncrementalSpeechContext,
outputFormat: String? outputFormat: String?) -> ElevenLabsTTSRequest
) -> ElevenLabsTTSRequest
{ {
ElevenLabsTTSRequest( ElevenLabsTTSRequest(
text: text, text: text,
@@ -1579,8 +1578,7 @@ final class TalkModeManager: NSObject {
private static func monitorStreamFailures( private static func monitorStreamFailures(
_ stream: AsyncThrowingStream<Data, Error>, _ stream: AsyncThrowingStream<Data, Error>,
failureBox: StreamFailureBox failureBox: StreamFailureBox) -> AsyncThrowingStream<Data, Error>
) -> AsyncThrowingStream<Data, Error>
{ {
AsyncThrowingStream { continuation in AsyncThrowingStream { continuation in
let task = Task { let task = Task {
@@ -1622,8 +1620,7 @@ final class TalkModeManager: NSObject {
private func speakIncrementalSegment( private func speakIncrementalSegment(
_ text: String, _ text: String,
context preferredContext: IncrementalSpeechContext? = nil, context preferredContext: IncrementalSpeechContext? = nil,
prefetchedAudio: IncrementalPrefetchedAudio? = nil prefetchedAudio: IncrementalPrefetchedAudio? = nil) async
) async
{ {
let context: IncrementalSpeechContext let context: IncrementalSpeechContext
if let preferredContext { if let preferredContext {
@@ -1651,11 +1648,10 @@ final class TalkModeManager: NSObject {
text: text, text: text,
context: context, context: context,
outputFormat: context.outputFormat) outputFormat: context.outputFormat)
let rawStream: AsyncThrowingStream<Data, Error> let rawStream: AsyncThrowingStream<Data, Error> = if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty { Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
rawStream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
} else { } else {
rawStream = client.streamSynthesize(voiceId: voiceId, request: request) client.streamSynthesize(voiceId: voiceId, request: request)
} }
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat) let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
@@ -1689,7 +1685,6 @@ final class TalkModeManager: NSObject {
self.lastInterruptedAtSeconds = interruptedAt self.lastInterruptedAtSeconds = interruptedAt
} }
} }
} }
private struct IncrementalSpeechBuffer { private struct IncrementalSpeechBuffer {
@@ -1818,7 +1813,7 @@ private struct IncrementalSpeechBuffer {
} }
private static func isSoftBoundary(_ ch: Character, bufferedChars: Int) -> Bool { private static func isSoftBoundary(_ ch: Character, bufferedChars: Int) -> Bool {
bufferedChars >= Self.softBoundaryMinChars && ch.isWhitespace bufferedChars >= self.softBoundaryMinChars && ch.isWhitespace
} }
} }
@@ -1987,8 +1982,7 @@ extension TalkModeManager {
let res = try await gateway.request( let res = try await gateway.request(
method: "talk.config", method: "talk.config",
paramsJSON: "{\"includeSecrets\":true}", paramsJSON: "{\"includeSecrets\":true}",
timeoutSeconds: 8 timeoutSeconds: 8)
)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let parsed = TalkModeGatewayConfigParser.parse( let parsed = TalkModeGatewayConfigParser.parse(
@@ -2060,7 +2054,7 @@ extension TalkModeManager {
.allowBluetoothHFP, .allowBluetoothHFP,
.defaultToSpeaker, .defaultToSpeaker,
]) ])
try? session.setPreferredSampleRate(48_000) try? session.setPreferredSampleRate(48000)
try? session.setPreferredIOBufferDuration(0.02) try? session.setPreferredIOBufferDuration(0.02)
try session.setActive(true, options: []) try session.setActive(true, options: [])
} }
@@ -2101,19 +2095,19 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
var shouldLog = false var shouldLog = false
var shouldEmitLevel = false var shouldEmitLevel = false
var count = 0 var count = 0
lock.lock() self.lock.lock()
bufferCount += 1 self.bufferCount += 1
count = bufferCount count = self.bufferCount
let now = Date() let now = Date()
if now.timeIntervalSince(lastLoggedAt) >= 1.0 { if now.timeIntervalSince(self.lastLoggedAt) >= 1.0 {
lastLoggedAt = now self.lastLoggedAt = now
shouldLog = true shouldLog = true
} }
if now.timeIntervalSince(lastLevelEmitAt) >= 0.12 { if now.timeIntervalSince(self.lastLevelEmitAt) >= 0.12 {
lastLevelEmitAt = now self.lastLevelEmitAt = now
shouldEmitLevel = true shouldEmitLevel = true
} }
lock.unlock() self.lock.unlock()
let rate = buffer.format.sampleRate let rate = buffer.format.sampleRate
let ch = buffer.format.channelCount let ch = buffer.format.channelCount
@@ -2133,12 +2127,12 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
} }
let resolvedRms = rms ?? 0 let resolvedRms = rms ?? 0
lock.lock() self.lock.lock()
lastRms = resolvedRms self.lastRms = resolvedRms
if resolvedRms > maxRmsWindow { maxRmsWindow = resolvedRms } if resolvedRms > self.maxRmsWindow { self.maxRmsWindow = resolvedRms }
let maxRms = maxRmsWindow let maxRms = self.maxRmsWindow
if shouldLog { maxRmsWindow = 0 } if shouldLog { self.maxRmsWindow = 0 }
lock.unlock() self.lock.unlock()
if shouldEmitLevel, let onLevel { if shouldEmitLevel, let onLevel {
onLevel(resolvedRms) onLevel(resolvedRms)
@@ -2146,9 +2140,8 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
guard shouldLog else { return } guard shouldLog else { return }
GatewayDiagnostics.log( GatewayDiagnostics.log(
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) " "\(self.label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))" + "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))")
)
} }
} }

View File

@@ -13,8 +13,8 @@ enum TalkSpeechLocale {
} }
static func supportedOptions( static func supportedOptions(
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales() supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> [Option]
) -> [Option] { {
var seen = Set<String>() var seen = Set<String>()
let dynamic: [Option] = supportedLocales let dynamic: [Option] = supportedLocales
.compactMap { locale in .compactMap { locale in
@@ -33,8 +33,8 @@ enum TalkSpeechLocale {
gatewaySelection: String?, gatewaySelection: String?,
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier, deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
fallbackLocaleID: String = Self.fallbackLocaleID, fallbackLocaleID: String = Self.fallbackLocaleID,
supportedLocaleIDs: Set<String> supportedLocaleIDs: Set<String>) -> String?
) -> String? { {
TalkConfigParsing.resolvedSpeechRecognitionLocaleID( TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
preferredLocaleIDs: [ preferredLocaleIDs: [
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection), TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
@@ -48,8 +48,10 @@ enum TalkSpeechLocale {
static func makeRecognizer( static func makeRecognizer(
localSelection: String?, localSelection: String?,
gatewaySelection: String?, gatewaySelection: String?,
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales() supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> (
) -> (recognizer: SFSpeechRecognizer?, localeID: String?) { recognizer: SFSpeechRecognizer?,
localeID: String?)
{
let supportedIDs = Set(supportedLocales.map(\.identifier)) let supportedIDs = Set(supportedLocales.map(\.identifier))
guard let localeID = self.resolvedLocaleID( guard let localeID = self.resolvedLocaleID(
localSelection: localSelection, localSelection: localSelection,

View File

@@ -1,34 +1,89 @@
Sources/Gateway/GatewayConnectionController.swift Sources/Calendar/CalendarService.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/KeychainStore.swift
Sources/Camera/CameraController.swift Sources/Camera/CameraController.swift
Sources/Capabilities/NodeCapabilityRouter.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSGatewayChatTransport.swift
Sources/Contacts/ContactsService.swift
Sources/Device/DeviceInfoHelper.swift Sources/Device/DeviceInfoHelper.swift
Sources/Device/DeviceStatusService.swift Sources/Device/DeviceStatusService.swift
Sources/Device/NetworkStatusService.swift Sources/Device/NetworkStatusService.swift
Sources/Chat/ChatSheet.swift Sources/Device/NodeDisplayName.swift
Sources/Chat/IOSGatewayChatTransport.swift Sources/EventKit/EventKitAuthorization.swift
Sources/OpenClawApp.swift Sources/Gateway/DeepLinkAgentPromptAlert.swift
Sources/Gateway/ExecApprovalPromptDialog.swift
Sources/Gateway/GatewayConnectConfig.swift
Sources/Gateway/GatewayConnectionController.swift
Sources/Gateway/GatewayConnectionIssue.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewayHealthMonitor.swift
Sources/Gateway/GatewayProblemView.swift
Sources/Gateway/GatewayQuickSetupSheet.swift
Sources/Gateway/GatewayServiceResolver.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/GatewaySetupCode.swift
Sources/Gateway/GatewayTrustPromptAlert.swift
Sources/Gateway/KeychainStore.swift
Sources/Gateway/TCPProbe.swift
Sources/HomeToolbar.swift
Sources/LiveActivity/LiveActivityManager.swift
Sources/LiveActivity/OpenClawActivityAttributes.swift
Sources/Location/LocationService.swift Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift Sources/Location/SignificantLocationMonitor.swift
Sources/Media/PhotoLibraryService.swift
Sources/Model/NodeAppModel+Canvas.swift Sources/Model/NodeAppModel+Canvas.swift
Sources/Model/NodeAppModel+WatchNotifyNormalization.swift
Sources/Model/NodeAppModel.swift
Sources/Model/WatchReplyCoordinator.swift Sources/Model/WatchReplyCoordinator.swift
Sources/Motion/MotionService.swift
Sources/Onboarding/GatewayOnboardingView.swift
Sources/Onboarding/OnboardingStateStore.swift
Sources/Onboarding/OnboardingWizardView.swift
Sources/Onboarding/QRScannerView.swift
Sources/OpenClawApp.swift
Sources/Push/ExecApprovalNotificationBridge.swift
Sources/Push/PushBuildConfig.swift
Sources/Push/PushRegistrationManager.swift
Sources/Push/PushRelayClient.swift
Sources/Push/PushRelayKeychainStore.swift
Sources/Reminders/RemindersService.swift
Sources/RootCanvas.swift Sources/RootCanvas.swift
Sources/RootTabs.swift Sources/RootTabs.swift
Sources/RootView.swift
Sources/Screen/ScreenController.swift Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift Sources/Screen/ScreenWebView.swift
Sources/Services/NodeServiceProtocols.swift
Sources/Services/NotificationService.swift
Sources/Services/WatchConnectivityTransport.swift
Sources/Services/WatchMessagingPayloadCodec.swift
Sources/Services/WatchMessagingService.swift
Sources/SessionKey.swift Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/GatewayActionsDialog.swift
Sources/Status/GatewayStatusBuilder.swift
Sources/Status/StatusActivityBuilder.swift
Sources/Status/StatusGlassCard.swift
Sources/Status/StatusPill.swift Sources/Status/StatusPill.swift
Sources/Status/VoiceWakeToast.swift Sources/Status/VoiceWakeToast.swift
Sources/Voice/TalkDefaults.swift
Sources/Voice/TalkModeGatewayConfig.swift
Sources/Voice/TalkModeManager.swift
Sources/Voice/TalkOrbOverlay.swift
Sources/Voice/TalkSpeechLocale.swift
Sources/Voice/VoiceTab.swift Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift Sources/Voice/VoiceWakePreferences.swift
ShareExtension/ShareViewController.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift
WatchExtension/Sources/OpenClawWatchApp.swift
WatchExtension/Sources/WatchConnectivityReceiver.swift
WatchExtension/Sources/WatchInboxStore.swift
WatchExtension/Sources/WatchInboxView.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift
@@ -61,9 +116,3 @@ Sources/Voice/VoiceWakePreferences.swift
../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift
../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift ../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift ../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
Sources/Voice/TalkModeManager.swift
Sources/Voice/TalkOrbOverlay.swift
Sources/LiveActivity/OpenClawActivityAttributes.swift
Sources/LiveActivity/LiveActivityManager.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
import WatchConnectivity import WatchConnectivity
struct WatchReplyDraft: Sendable { struct WatchReplyDraft {
var replyId: String var replyId: String
var promptId: String var promptId: String
var actionId: String var actionId: String
@@ -11,7 +11,7 @@ struct WatchReplyDraft: Sendable {
var sentAtMs: Int var sentAtMs: Int
} }
struct WatchReplySendResult: Sendable, Equatable { struct WatchReplySendResult: Equatable {
var deliveredImmediately: Bool var deliveredImmediately: Bool
var queuedForDelivery: Bool var queuedForDelivery: Bool
var transport: String var transport: String
@@ -61,14 +61,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let payload = Self.encodeSnapshotRequestPayload(request) let payload = Self.encodeSnapshotRequestPayload(request)
if session.isReachable { if session.isReachable {
do { do {
try await withCheckedThrowingContinuation(isolation: nil) { // swiftlint:disable multiline_arguments
(continuation: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<
Void,
Error,
>) in
session.sendMessage(payload, replyHandler: { _ in session.sendMessage(payload, replyHandler: { _ in
continuation.resume(returning: ()) continuation.resume(returning: ())
}, errorHandler: { error in }, errorHandler: { error in
continuation.resume(throwing: error) continuation.resume(throwing: error)
}) })
} }
// swiftlint:enable multiline_arguments
return return
} catch { } catch {
// Fall through to queued delivery. // Fall through to queued delivery.
@@ -136,14 +140,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult { private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
if session.isReachable { if session.isReachable {
do { do {
try await withCheckedThrowingContinuation(isolation: nil) { // swiftlint:disable multiline_arguments
(continuation: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<
Void,
Error,
>) in
session.sendMessage(payload, replyHandler: { _ in session.sendMessage(payload, replyHandler: { _ in
continuation.resume(returning: ()) continuation.resume(returning: ())
}, errorHandler: { error in }, errorHandler: { error in
continuation.resume(throwing: error) continuation.resume(throwing: error)
}) })
} }
// swiftlint:enable multiline_arguments
return WatchReplySendResult( return WatchReplySendResult(
deliveredImmediately: true, deliveredImmediately: true,
queuedForDelivery: false, queuedForDelivery: false,
@@ -254,7 +262,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
} }
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? { private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
guard let payload = value.flatMap(Self.normalizeObject) else { guard let payload = value.flatMap(normalizeObject) else {
return nil return nil
} }
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -291,7 +299,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
{ {
guard let type = payload["type"] as? String, guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalPrompt.rawValue, type == WatchPayloadType.execApprovalPrompt.rawValue,
let approval = Self.parseExecApprovalItem(payload["approval"]) let approval = parseExecApprovalItem(payload["approval"])
else { else {
return nil return nil
} }

View File

@@ -3,7 +3,7 @@ import Observation
import UserNotifications import UserNotifications
import WatchKit import WatchKit
enum WatchPayloadType: String, Codable, Sendable, Equatable { enum WatchPayloadType: String, Codable, Equatable {
case notify = "watch.notify" case notify = "watch.notify"
case reply = "watch.reply" case reply = "watch.reply"
case execApprovalPrompt = "watch.execApproval.prompt" case execApprovalPrompt = "watch.execApproval.prompt"
@@ -14,18 +14,18 @@ enum WatchPayloadType: String, Codable, Sendable, Equatable {
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest" case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
} }
enum WatchRiskLevel: String, Codable, Sendable, Equatable { enum WatchRiskLevel: String, Codable, Equatable {
case low case low
case medium case medium
case high case high
} }
enum WatchExecApprovalDecision: String, Codable, Sendable, Equatable { enum WatchExecApprovalDecision: String, Codable, Equatable {
case allowOnce = "allow-once" case allowOnce = "allow-once"
case deny case deny
} }
enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable { enum WatchExecApprovalCloseReason: String, Codable, Equatable {
case expired case expired
case notFound = "not-found" case notFound = "not-found"
case unavailable case unavailable
@@ -33,7 +33,7 @@ enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
case resolved case resolved
} }
struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable { struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
var id: String var id: String
var commandText: String var commandText: String
var commandPreview: String? var commandPreview: String?
@@ -45,51 +45,51 @@ struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
var risk: WatchRiskLevel? var risk: WatchRiskLevel?
} }
struct WatchExecApprovalPromptMessage: Codable, Sendable, Equatable { struct WatchExecApprovalPromptMessage: Codable, Equatable {
var approval: WatchExecApprovalItem var approval: WatchExecApprovalItem
var sentAtMs: Int? var sentAtMs: Int?
var deliveryId: String? var deliveryId: String?
var resetResolvingState: Bool? var resetResolvingState: Bool?
} }
struct WatchExecApprovalResolvedMessage: Codable, Sendable, Equatable { struct WatchExecApprovalResolvedMessage: Codable, Equatable {
var approvalId: String var approvalId: String
var decision: WatchExecApprovalDecision? var decision: WatchExecApprovalDecision?
var resolvedAtMs: Int? var resolvedAtMs: Int?
var source: String? var source: String?
} }
struct WatchExecApprovalExpiredMessage: Codable, Sendable, Equatable { struct WatchExecApprovalExpiredMessage: Codable, Equatable {
var approvalId: String var approvalId: String
var reason: WatchExecApprovalCloseReason var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int? var expiredAtMs: Int?
} }
struct WatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable { struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
var approvals: [WatchExecApprovalItem] var approvals: [WatchExecApprovalItem]
var sentAtMs: Int? var sentAtMs: Int?
var snapshotId: String? var snapshotId: String?
} }
struct WatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable { struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
var requestId: String var requestId: String
var sentAtMs: Int? var sentAtMs: Int?
} }
struct WatchExecApprovalResolveMessage: Codable, Sendable, Equatable { struct WatchExecApprovalResolveMessage: Codable, Equatable {
var approvalId: String var approvalId: String
var decision: WatchExecApprovalDecision var decision: WatchExecApprovalDecision
var replyId: String var replyId: String
var sentAtMs: Int? var sentAtMs: Int?
} }
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable { struct WatchPromptAction: Codable, Equatable, Identifiable {
var id: String var id: String
var label: String var label: String
var style: String? var style: String?
} }
struct WatchNotifyMessage: Sendable { struct WatchNotifyMessage {
var id: String? var id: String?
var title: String var title: String
var body: String var body: String
@@ -103,7 +103,7 @@ struct WatchNotifyMessage: Sendable {
var actions: [WatchPromptAction] var actions: [WatchPromptAction]
} }
struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable { struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var approval: WatchExecApprovalItem var approval: WatchExecApprovalItem
var transport: String var transport: String
var updatedAt: Date var updatedAt: Date
@@ -112,7 +112,9 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
var statusText: String? var statusText: String?
var statusAt: Date? var statusAt: Date?
var id: String { self.approval.id } var id: String {
self.approval.id
}
} }
@MainActor @Observable final class WatchInboxStore { @MainActor @Observable final class WatchInboxStore {
@@ -333,14 +335,13 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) { func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
self.removeExecApproval(id: message.approvalId) self.removeExecApproval(id: message.approvalId)
let statusText: String let statusText = switch message.decision {
switch message.decision {
case .allowOnce: case .allowOnce:
statusText = "Allowed once" "Allowed once"
case .deny: case .deny:
statusText = "Denied" "Denied"
case nil: case nil:
statusText = "Approval resolved" "Approval resolved"
} }
self.lastExecApprovalOutcomeText = statusText self.lastExecApprovalOutcomeText = statusText
self.lastExecApprovalOutcomeAt = Date() self.lastExecApprovalOutcomeAt = Date()
@@ -349,18 +350,17 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) { func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
self.removeExecApproval(id: message.approvalId) self.removeExecApproval(id: message.approvalId)
let statusText: String let statusText = switch message.reason {
switch message.reason {
case .expired: case .expired:
statusText = "Approval expired" "Approval expired"
case .notFound: case .notFound:
statusText = "Approval no longer available" "Approval no longer available"
case .resolved: case .resolved:
statusText = "Approval resolved elsewhere" "Approval resolved elsewhere"
case .replaced: case .replaced:
statusText = "Approval replaced" "Approval replaced"
case .unavailable: case .unavailable:
statusText = "Approval unavailable" "Approval unavailable"
} }
self.lastExecApprovalOutcomeText = statusText self.lastExecApprovalOutcomeText = statusText
self.lastExecApprovalOutcomeAt = Date() self.lastExecApprovalOutcomeAt = Date()
@@ -482,7 +482,7 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
private func restorePersistedState() { private func restorePersistedState() {
guard let data = self.defaults.data(forKey: Self.persistedStateKey), guard let data = self.defaults.data(forKey: Self.persistedStateKey),
let state = try? JSONDecoder().decode(PersistedState.self, from: data) let state = try? JSONDecoder().decode(PersistedState.self, from: data)
else { else {
return return
} }
@@ -555,11 +555,11 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
private func mapHapticRisk(_ risk: String?) -> WKHapticType { private func mapHapticRisk(_ risk: String?) -> WKHapticType {
switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "high": case "high":
return .failure .failure
case "medium": case "medium":
return .notification .notification
default: default:
return .click .click
} }
} }

View File

@@ -219,13 +219,13 @@ private struct WatchExecApprovalDetailView: View {
private func riskText(_ risk: WatchRiskLevel?) -> String? { private func riskText(_ risk: WatchRiskLevel?) -> String? {
switch risk { switch risk {
case .high: case .high:
return "High" "High"
case .medium: case .medium:
return "Medium" "Medium"
case .low: case .low:
return "Low" "Low"
case nil: case nil:
return nil nil
} }
} }
@@ -246,11 +246,11 @@ private struct WatchGenericInboxView: View {
private func role(for action: WatchPromptAction) -> ButtonRole? { private func role(for action: WatchPromptAction) -> ButtonRole? {
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "destructive": case "destructive":
return .destructive .destructive
case "cancel": case "cancel":
return .cancel .cancel
default: default:
return nil nil
} }
} }

View File

@@ -1 +1,3 @@
Maintenance update for the current OpenClaw development release. Maintenance update for the current OpenClaw development release.
- Refreshed build hygiene for the iOS app, Share extension, Activity widget, Watch app, and curated shared Swift sources; relay registration now uses StoreKit app transaction JWS data instead of deprecated receipt APIs.

View File

@@ -71,6 +71,7 @@ targets:
exit 1 exit 1
fi fi
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \ swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../../Swabble" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist" --filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint - name: SwiftLint
basedOnDependencyAnalysis: false basedOnDependencyAnalysis: false
@@ -344,6 +345,7 @@ targets:
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
ENABLE_APP_INTENTS_METADATA_GENERATION: NO ENABLE_APP_INTENTS_METADATA_GENERATION: NO
SWIFT_EMIT_CONST_VALUE_PROTOCOLS: ""
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete SWIFT_STRICT_CONCURRENCY: complete
info: info:

View File

@@ -281,9 +281,9 @@ struct OpenClawChatComposer: View {
onPasteImageAttachment: { data, fileName, mimeType in onPasteImageAttachment: { data, fileName, mimeType in
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType) self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
}) })
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4) .padding(.horizontal, 4)
.padding(.vertical, 3) .padding(.vertical, 3)
#else #else
TextEditor(text: self.$viewModel.input) TextEditor(text: self.$viewModel.input)
.font(.system(size: 15)) .font(.system(size: 15))
@@ -441,7 +441,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
var onSend: () -> Void var onSend: () -> Void
var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) } func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView { func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView() let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
@@ -495,7 +497,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
var parent: ChatComposerTextView var parent: ChatComposerTextView
var isProgrammaticUpdate = false var isProgrammaticUpdate = false
init(_ parent: ChatComposerTextView) { self.parent = parent } init(_ parent: ChatComposerTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) { func textDidChange(_ notification: Notification) {
guard !self.isProgrammaticUpdate else { return } guard !self.isProgrammaticUpdate else { return }
@@ -507,7 +511,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
} }
enum ChatComposerTextViewFactory { enum ChatComposerTextViewFactory {
// Internal for @testable import coverage of composer text view defaults. /// Internal for @testable import coverage of composer text view defaults.
@MainActor @MainActor
static func makeConfiguredTextView() -> NSTextView { static func makeConfiguredTextView() -> NSTextView {
let textView = ChatComposerNSTextView() let textView = ChatComposerNSTextView()
@@ -751,7 +755,10 @@ enum ChatComposerPasteSupport {
(NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"), (NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"),
] ]
private static func matches(_ preferredType: NSPasteboard.PasteboardType?, candidate: NSPasteboard.PasteboardType) -> Bool { private static func matches(
_ preferredType: NSPasteboard.PasteboardType?,
candidate: NSPasteboard.PasteboardType) -> Bool
{
guard let preferredType else { return true } guard let preferredType else { return true }
return preferredType == candidate return preferredType == candidate
} }

View File

@@ -1,9 +1,9 @@
import Foundation import Foundation
enum ChatMarkdownPreprocessor { enum ChatMarkdownPreprocessor {
// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts` /// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
// (`INBOUND_META_SENTINELS`), and extend parser expectations in /// (`INBOUND_META_SENTINELS`), and extend parser expectations in
// `ChatMarkdownPreprocessorTests` when sentinels change. /// `ChatMarkdownPreprocessorTests` when sentinels change.
private static let inboundContextHeaders = [ private static let inboundContextHeaders = [
"Conversation info (untrusted metadata):", "Conversation info (untrusted metadata):",
"Sender (untrusted metadata):", "Sender (untrusted metadata):",
@@ -152,11 +152,13 @@ enum ChatMarkdownPreprocessor {
for index in lines.indices { for index in lines.indices {
let currentLine = lines[index] let currentLine = lines[index]
if !inMetaBlock && self.shouldStripTrailingUntrustedContext(lines: lines, index: index) { if !inMetaBlock, self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
break break
} }
if !inMetaBlock && self.inboundContextHeaders.contains(currentLine.trimmingCharacters(in: .whitespacesAndNewlines)) { if !inMetaBlock,
self.inboundContextHeaders.contains(currentLine.trimmingCharacters(in: .whitespacesAndNewlines))
{
let nextLine = index + 1 < lines.count ? lines[index + 1] : nil let nextLine = index + 1 < lines.count ? lines[index + 1] : nil
if nextLine?.trimmingCharacters(in: .whitespacesAndNewlines) != "```json" { if nextLine?.trimmingCharacters(in: .whitespacesAndNewlines) != "```json" {
outputLines.append(currentLine) outputLines.append(currentLine)
@@ -168,7 +170,7 @@ enum ChatMarkdownPreprocessor {
} }
if inMetaBlock { if inMetaBlock {
if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" { if !inFencedJson, currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
inFencedJson = true inFencedJson = true
continue continue
} }

View File

@@ -70,7 +70,7 @@ private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage] let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View { var body: some View {
ForEach(images, id: \.id) { item in ForEach(self.images, id: \.id) { item in
if let img = item.image { if let img = item.image {
OpenClawPlatformImageFactory.image(img) OpenClawPlatformImageFactory.image(img)
.resizable() .resizable()

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Foundation import Foundation
import OpenClawKit
import SwiftUI import SwiftUI
private enum ChatUIConstants { private enum ChatUIConstants {
@@ -70,7 +70,12 @@ private struct ChatBubbleShape: InsettableShape {
to: baseBottom, to: baseBottom,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15), control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05)) control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r) self.addBottomEdge(
path: &path,
bubbleMinX: bubbleMinX,
bubbleMaxX: bubbleMaxX,
bubbleMaxY: bubbleMaxY,
radius: r)
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r)) path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve( path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY), to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
@@ -102,7 +107,12 @@ private struct ChatBubbleShape: InsettableShape {
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r), to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY)) control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r)) path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r) self.addBottomEdge(
path: &path,
bubbleMinX: bubbleMinX,
bubbleMaxX: bubbleMaxX,
bubbleMaxY: bubbleMaxY,
radius: r)
path.addLine(to: baseBottom) path.addLine(to: baseBottom)
path.addCurve( path.addCurve(
to: tip, to: tip,
@@ -158,7 +168,9 @@ struct ChatMessageBubble: View {
.padding(.horizontal, 2) .padding(.horizontal, 2)
} }
private var isUser: Bool { self.message.role.lowercased() == "user" } private var isUser: Bool {
self.message.role.lowercased() == "user"
}
} }
@MainActor @MainActor
@@ -498,8 +510,8 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
} }
} }
private extension View { extension View {
func assistantBubbleContainerStyle() -> some View { fileprivate func assistantBubbleContainerStyle() -> some View {
self self
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Foundation import Foundation
import OpenClawKit
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats. // NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
@@ -270,7 +270,10 @@ public struct OpenClawChatEventPayload: Codable, Sendable {
} }
public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable { public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
public var id: String { "\(self.runId)-\(self.seq ?? -1)" } public var id: String {
"\(self.runId)-\(self.seq ?? -1)"
}
public let runId: String public let runId: String
public let seq: Int? public let seq: Int?
public let stream: String public let stream: String
@@ -279,7 +282,10 @@ public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
} }
public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable { public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
public var id: String { self.toolCallId } public var id: String {
self.toolCallId
}
public let toolCallId: String public let toolCallId: String
public let name: String public let name: String
public let args: AnyCodable? public let args: AnyCodable?

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Foundation import Foundation
import OpenClawKit
enum ChatPayloadDecoding { enum ChatPayloadDecoding {
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T { static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {

View File

@@ -1,7 +1,9 @@
import Foundation import Foundation
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable { public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
public var id: String { self.selectionID } public var id: String {
self.selectionID
}
public let modelID: String public let modelID: String
public let name: String public let name: String
@@ -44,7 +46,9 @@ public struct OpenClawChatSessionsDefaults: Codable, Sendable {
} }
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable { public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key } public var id: String {
self.key
}
public let key: String public let key: String
public let kind: String? public let kind: String?

View File

@@ -128,7 +128,9 @@ enum OpenClawChatTheme {
#endif #endif
} }
static var userText: Color { .white } static var userText: Color {
.white
}
static var assistantText: Color { static var assistantText: Color {
#if os(macOS) #if os(macOS)

View File

@@ -86,8 +86,6 @@ public struct OpenClawChatView: View {
.sheet(isPresented: self.$showSessions) { .sheet(isPresented: self.$showSessions) {
if self.showsSessionSwitcher { if self.showsSessionSwitcher {
ChatSessionsSheet(viewModel: self.viewModel) ChatSessionsSheet(viewModel: self.viewModel)
} else {
EmptyView()
} }
} }
} }
@@ -99,11 +97,11 @@ public struct OpenClawChatView: View {
self.messageListRows self.messageListRows
Color.clear Color.clear
#if os(macOS) #if os(macOS)
.frame(height: Layout.messageListPaddingBottom) .frame(height: Layout.messageListPaddingBottom)
#else #else
.frame(height: Layout.messageListPaddingBottom + 1) .frame(height: Layout.messageListPaddingBottom + 1)
#endif #endif
.id(self.scrollerBottomID) .id(self.scrollerBottomID)
} }
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
@@ -115,11 +113,11 @@ public struct OpenClawChatView: View {
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
#endif #endif
// Keep the scroll pinned to the bottom for new messages. // Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom) .scrollPosition(id: self.$scrollPosition, anchor: .bottom)
.onChange(of: self.scrollPosition) { _, position in .onChange(of: self.scrollPosition) { _, position in
guard let position else { return } guard let position else { return }
self.isPinnedToBottom = position == self.scrollerBottomID self.isPinnedToBottom = position == self.scrollerBottomID
} }
if self.viewModel.isLoading { if self.viewModel.isLoading {
ProgressView() ProgressView()
@@ -158,7 +156,8 @@ public struct OpenClawChatView: View {
guard self.hasPerformedInitialScroll else { return } guard self.hasPerformedInitialScroll else { return }
if let lastMessage = self.viewModel.messages.last, if let lastMessage = self.viewModel.messages.last,
lastMessage.role.lowercased() == "user", lastMessage.role.lowercased() == "user",
lastMessage.id != self.lastUserMessageID { lastMessage.id != self.lastUserMessageID
{
self.lastUserMessageID = lastMessage.id self.lastUserMessageID = lastMessage.id
self.isPinnedToBottom = true self.isPinnedToBottom = true
withAnimation(.snappy(duration: 0.22)) { withAnimation(.snappy(duration: 0.22)) {

View File

@@ -1,6 +1,6 @@
import OpenClawKit
import Foundation import Foundation
import Observation import Observation
import OpenClawKit
import OSLog import OSLog
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -14,6 +14,7 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
@MainActor @MainActor
@Observable @Observable
// swiftlint:disable:next type_body_length
public final class OpenClawChatViewModel { public final class OpenClawChatViewModel {
public static let defaultModelSelectionID = "__default__" public static let defaultModelSelectionID = "__default__"
@@ -659,8 +660,8 @@ public final class OpenClawChatViewModel {
self.errorText = "Unable to compact the session. Please try again." self.errorText = "Unable to compact the session. Please try again."
let nsError = error as NSError let nsError = error as NSError
chatUILogger.error( chatUILogger.error(
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)" // swiftlint:disable:next line_length
) "session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)")
return return
} }
@@ -733,7 +734,10 @@ public final class OpenClawChatViewModel {
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey) self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
} }
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous { if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
self.applySuccessfulModelSelection(previous, sessionKey: sessionKey, syncSelection: sessionKey == self.sessionKey) self.applySuccessfulModelSelection(
previous,
sessionKey: sessionKey,
syncSelection: sessionKey == self.sessionKey)
} }
guard sessionKey == self.sessionKey else { return } guard sessionKey == self.sessionKey else { return }
self.modelSelectionID = previous self.modelSelectionID = previous
@@ -856,7 +860,8 @@ public final class OpenClawChatViewModel {
syncSelection: syncSelection) syncSelection: syncSelection)
} }
private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) { private func resolvedSessionModelIdentity(forSelectionID selectionID: String)
-> (modelID: String?, modelProvider: String?) {
guard let modelRef = self.modelRef(forSelectionID: selectionID) else { guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
return (nil, nil) return (nil, nil)
} }

View File

@@ -1,11 +1,11 @@
import Foundation import Foundation
public extension AnyCodable { extension AnyCodable {
var stringValue: String? { public var stringValue: String? {
self.value as? String self.value as? String
} }
var boolValue: Bool? { public var boolValue: Bool? {
if let value = self.value as? Bool { if let value = self.value as? Bool {
return value return value
} }
@@ -15,7 +15,7 @@ public extension AnyCodable {
return nil return nil
} }
var intValue: Int? { public var intValue: Int? {
if let value = self.value as? Int { if let value = self.value as? Int {
return value return value
} }
@@ -28,7 +28,7 @@ public extension AnyCodable {
return nil return nil
} }
var doubleValue: Double? { public var doubleValue: Double? {
if let value = self.value as? Double { if let value = self.value as? Double {
return value return value
} }
@@ -41,7 +41,7 @@ public extension AnyCodable {
return nil return nil
} }
var dictionaryValue: [String: AnyCodable]? { public var dictionaryValue: [String: AnyCodable]? {
if let value = self.value as? [String: AnyCodable] { if let value = self.value as? [String: AnyCodable] {
return value return value
} }
@@ -58,7 +58,7 @@ public extension AnyCodable {
return nil return nil
} }
var arrayValue: [AnyCodable]? { public var arrayValue: [AnyCodable]? {
if let value = self.value as? [AnyCodable] { if let value = self.value as? [AnyCodable] {
return value return value
} }
@@ -71,7 +71,7 @@ public extension AnyCodable {
return nil return nil
} }
var foundationValue: Any { public var foundationValue: Any {
switch self.value { switch self.value {
case let dict as [String: AnyCodable]: case let dict as [String: AnyCodable]:
dict.mapValues(\.foundationValue) dict.mapValues(\.foundationValue)

View File

@@ -1,4 +1,3 @@
import OpenClawProtocol import OpenClawProtocol
public typealias AnyCodable = OpenClawProtocol.AnyCodable public typealias AnyCodable = OpenClawProtocol.AnyCodable

View File

@@ -20,8 +20,8 @@ public enum OpenClawBonjour {
private static func resolveWideAreaDomain(_ raw: String?) -> String? { private static func resolveWideAreaDomain(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil } if trimmed.isEmpty { return nil }
let normalized = normalizeServiceDomain(trimmed) let normalized = self.normalizeServiceDomain(trimmed)
return normalized == gatewayServiceDomain ? nil : normalized return normalized == self.gatewayServiceDomain ? nil : normalized
} }
public static func normalizeServiceDomain(_ raw: String?) -> String { public static func normalizeServiceDomain(_ raw: String?) -> String {

View File

@@ -3,9 +3,9 @@ import Foundation
public enum CaptureRateLimits { public enum CaptureRateLimits {
public static func clampDurationMs( public static func clampDurationMs(
_ ms: Int?, _ ms: Int?,
defaultMs: Int = 10_000, defaultMs: Int = 10000,
minMs: Int = 250, minMs: Int = 250,
maxMs: Int = 60_000) -> Int maxMs: Int = 60000) -> Int
{ {
let value = ms ?? defaultMs let value = ms ?? defaultMs
return min(maxMs, max(minMs, value)) return min(maxMs, max(minMs, value))

View File

@@ -29,7 +29,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`). /// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? { public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil } guard let data = decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
guard let urlString = json["url"] as? String, guard let urlString = json["url"] as? String,
let parsed = URLComponents(string: urlString), let parsed = URLComponents(string: urlString),

View File

@@ -16,8 +16,8 @@ public enum GatewayDeviceAuthPayload {
{ {
let scopeString = scopes.joined(separator: ",") let scopeString = scopes.joined(separator: ",")
let authToken = token ?? "" let authToken = token ?? ""
let normalizedPlatform = normalizeMetadataField(platform) let normalizedPlatform = self.normalizeMetadataField(platform)
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) let normalizedDeviceFamily = self.normalizeMetadataField(deviceFamily)
return [ return [
"v3", "v3",
deviceId, deviceId,

View File

@@ -25,7 +25,7 @@ public enum DeviceAuthStore {
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? { public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil } guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = normalizeRole(role) let role = self.normalizeRole(role)
return store.tokens[role] return store.tokens[role]
} }
@@ -33,10 +33,10 @@ public enum DeviceAuthStore {
deviceId: String, deviceId: String,
role: String, role: String,
token: String, token: String,
scopes: [String] = [] scopes: [String] = []) -> DeviceAuthEntry
) -> DeviceAuthEntry { {
let normalizedRole = normalizeRole(role) let normalizedRole = self.normalizeRole(role)
var next = readStore() var next = self.readStore()
if next?.deviceId != deviceId { if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
} }
@@ -44,24 +44,23 @@ public enum DeviceAuthStore {
token: token, token: token,
role: normalizedRole, role: normalizedRole,
scopes: normalizeScopes(scopes), scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000) updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
)
if next == nil { if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
} }
next?.tokens[normalizedRole] = entry next?.tokens[normalizedRole] = entry
if let store = next { if let store = next {
writeStore(store) self.writeStore(store)
} }
return entry return entry
} }
public static func clearToken(deviceId: String, role: String) { public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return } guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = normalizeRole(role) let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return } guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole) store.tokens.removeValue(forKey: normalizedRole)
writeStore(store) self.writeStore(store)
} }
private static func normalizeRole(_ role: String) -> String { private static func normalizeRole(_ role: String) -> String {
@@ -78,11 +77,11 @@ public enum DeviceAuthStore {
private static func fileURL() -> URL { private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL() DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true) .appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false) .appendingPathComponent(self.fileName, isDirectory: false)
} }
private static func readStore() -> DeviceAuthStoreFile? { private static func readStore() -> DeviceAuthStoreFile? {
let url = fileURL() let url = self.fileURL()
guard let data = try? Data(contentsOf: url) else { return nil } guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else { guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil return nil
@@ -92,7 +91,7 @@ public enum DeviceAuthStore {
} }
private static func writeStore(_ store: DeviceAuthStoreFile) { private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = fileURL() let url = self.fileURL()
do { do {
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),

View File

@@ -45,7 +45,8 @@ public enum DeviceIdentityStore {
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data), let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
!decoded.deviceId.isEmpty, !decoded.deviceId.isEmpty,
!decoded.publicKey.isEmpty, !decoded.publicKey.isEmpty,
!decoded.privateKey.isEmpty { !decoded.privateKey.isEmpty
{
return decoded return decoded
} }
let identity = self.generate() let identity = self.generate()
@@ -107,6 +108,6 @@ public enum DeviceIdentityStore {
let base = DeviceIdentityPaths.stateDirURL() let base = DeviceIdentityPaths.stateDirURL()
return base return base
.appendingPathComponent("identity", isDirectory: true) .appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false) .appendingPathComponent(self.fileName, isDirectory: false)
} }
} }

View File

@@ -1,5 +1,5 @@
import OpenClawProtocol
import Foundation import Foundation
import OpenClawProtocol
import OSLog import OSLog
public protocol WebSocketTasking: AnyObject { public protocol WebSocketTasking: AnyObject {
@@ -20,9 +20,13 @@ public struct WebSocketTaskBox: @unchecked Sendable {
self.task = task self.task = task
} }
public var state: URLSessionTask.State { self.task.state } public var state: URLSessionTask.State {
self.task.state
}
public func resume() { self.task.resume() } public func resume() {
self.task.resume()
}
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
self.task.cancel(with: closeCode, reason: reason) self.task.cancel(with: closeCode, reason: reason)
@@ -81,9 +85,9 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String public var clientId: String
public var clientMode: String public var clientMode: String
public var clientDisplayName: String? public var clientDisplayName: String?
// When false, the connection omits the signed device identity payload and cannot use /// When false, the connection omits the signed device identity payload and cannot use
// device-scoped auth (role/scope upgrades will require pairing). Keep this true for /// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
// role/scoped sessions such as operator UI clients. /// role/scoped sessions such as operator UI clients.
public var includeDeviceIdentity: Bool public var includeDeviceIdentity: Bool
public init( public init(
@@ -113,11 +117,11 @@ public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token" case deviceToken = "device-token"
case sharedToken = "shared-token" case sharedToken = "shared-token"
case bootstrapToken = "bootstrap-token" case bootstrapToken = "bootstrap-token"
case password = "password" case password
case none = "none" case none
} }
// Avoid ambiguity with the app's own AnyCodable type. /// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
private enum ConnectChallengeError: Error { private enum ConnectChallengeError: Error {
@@ -132,13 +136,13 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing", "operator.pairing",
] ]
private extension String { extension String {
var nilIfEmpty: String? { fileprivate var nilIfEmpty: String? {
self.isEmpty ? nil : self self.isEmpty ? nil : self
} }
} }
private struct SelectedConnectAuth: Sendable { private struct SelectedConnectAuth {
let authToken: String? let authToken: String?
let authBootstrapToken: String? let authBootstrapToken: String?
let authDeviceToken: String? let authDeviceToken: String?
@@ -223,7 +227,9 @@ public actor GatewayChannelActor {
} }
} }
public func authSource() -> GatewayAuthSource { self.lastAuthSource } public func authSource() -> GatewayAuthSource {
self.lastAuthSource
}
public func shutdown() async { public func shutdown() async {
self.shouldReconnect = false self.shouldReconnect = false
@@ -277,8 +283,7 @@ public actor GatewayChannelActor {
if self.shouldPauseReconnectAfterAuthFailure(error) { if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true self.reconnectPausedForAuthFailure = true
self.logger.error( self.logger.error(
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)" "gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)")
)
continue continue
} }
let wrapped = self.wrap(error, context: "gateway watchdog reconnect") let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
@@ -312,11 +317,10 @@ public actor GatewayChannelActor {
}, },
operation: { try await self.sendConnect() }) operation: { try await self.sendConnect() })
} catch { } catch {
let wrapped: Error let wrapped: Error = if let authError = error as? GatewayConnectAuthError {
if let authError = error as? GatewayConnectAuthError { authError
wrapped = authError
} else { } else {
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
} }
self.connected = false self.connected = false
self.task?.cancel(with: .goingAway, reason: nil) self.task?.cancel(with: .goingAway, reason: nil)
@@ -422,7 +426,7 @@ public actor GatewayChannelActor {
role: role, role: role,
includeDeviceIdentity: includeDeviceIdentity, includeDeviceIdentity: includeDeviceIdentity,
deviceId: identity?.deviceId) deviceId: identity?.deviceId)
if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry { if selectedAuth.authDeviceToken != nil, self.pendingDeviceTokenRetry {
self.pendingDeviceTokenRetry = false self.pendingDeviceTokenRetry = false
} }
self.lastAuthSource = selectedAuth.authSource self.lastAuthSource = selectedAuth.authSource
@@ -485,8 +489,8 @@ public actor GatewayChannelActor {
self.deviceTokenRetryBudgetUsed = true self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250) self.backoffMs = min(self.backoffMs, 250)
} else if selectedAuth.authDeviceToken != nil, } else if selectedAuth.authDeviceToken != nil,
let identity, let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error) self.shouldClearStoredDeviceTokenAfterRetry(error)
{ {
// Retry failed with an explicit device-token mismatch; clear stale local token. // Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
@@ -498,39 +502,38 @@ public actor GatewayChannelActor {
private func selectConnectAuth( private func selectConnectAuth(
role: String, role: String,
includeDeviceIdentity: Bool, includeDeviceIdentity: Bool,
deviceId: String? deviceId: String?) -> SelectedConnectAuth
) -> SelectedConnectAuth { {
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitBootstrapToken = let explicitBootstrapToken =
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedToken = let storedToken =
(includeDeviceIdentity && deviceId != nil) (includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
: nil : nil
let shouldUseDeviceRetryToken = let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry && includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
let authToken = let authToken =
explicitToken ?? explicitToken ??
// A freshly scanned setup code should force the bootstrap pairing path instead of // A freshly scanned setup code should force the bootstrap pairing path instead of
// silently reusing an older stored device token. // silently reusing an older stored device token.
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil (includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken ? storedToken
: nil) : nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
if authDeviceToken != nil || (explicitToken == nil && authToken != nil) { .deviceToken
authSource = .deviceToken
} else if authToken != nil { } else if authToken != nil {
authSource = .sharedToken .sharedToken
} else if authBootstrapToken != nil { } else if authBootstrapToken != nil {
authSource = .bootstrapToken .bootstrapToken
} else if explicitPassword != nil { } else if explicitPassword != nil {
authSource = .password .password
} else { } else {
authSource = .none .none
} }
return SelectedConnectAuth( return SelectedConnectAuth(
authToken: authToken, authToken: authToken,
@@ -560,7 +563,7 @@ public actor GatewayChannelActor {
case "node": case "node":
return [] return []
case "operator": case "operator":
let allowedOperatorScopes: Set<String> = [ let allowedOperatorScopes: Set = [
"operator.approvals", "operator.approvals",
"operator.read", "operator.read",
"operator.talk.secrets", "operator.talk.secrets",
@@ -576,8 +579,8 @@ public actor GatewayChannelActor {
deviceId: String, deviceId: String,
role: String, role: String,
token: String, token: String,
scopes: [String] scopes: [String])
) { {
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else { guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return return
} }
@@ -593,8 +596,8 @@ public actor GatewayChannelActor {
deviceId: String, deviceId: String,
role: String, role: String,
token: String, token: String,
scopes: [String] scopes: [String])
) { {
if authSource == .bootstrapToken { if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else { guard self.shouldPersistBootstrapHandoffTokens() else {
return return
@@ -616,8 +619,8 @@ public actor GatewayChannelActor {
private func handleConnectResponse( private func handleConnectResponse(
_ res: ResponseFrame, _ res: ResponseFrame,
identity: DeviceIdentity?, identity: DeviceIdentity?,
role: String role: String) async throws
) async throws { {
if res.ok == false { if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable] let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
@@ -809,12 +812,11 @@ public actor GatewayChannelActor {
} }
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
let data: Data? = switch msg { return switch msg {
case let .data(data): data case let .data(data): data
case let .string(text): text.data(using: .utf8) case let .string(text): text.data(using: .utf8)
@unknown default: nil @unknown default: nil
} }
return data
} }
private func watchTicks() async { private func watchTicks() async {
@@ -853,8 +855,7 @@ public actor GatewayChannelActor {
if self.shouldPauseReconnectAfterAuthFailure(error) { if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true self.reconnectPausedForAuthFailure = true
self.logger.error( self.logger.error(
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)" "gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)")
)
return return
} }
let wrapped = self.wrap(error, context: "gateway reconnect") let wrapped = self.wrap(error, context: "gateway reconnect")
@@ -867,8 +868,8 @@ public actor GatewayChannelActor {
error: Error, error: Error,
explicitGatewayToken: String?, explicitGatewayToken: String?,
storedToken: String?, storedToken: String?,
attemptedDeviceTokenRetry: Bool attemptedDeviceTokenRetry: Bool) -> Bool
) -> Bool { {
if self.deviceTokenRetryBudgetUsed { if self.deviceTokenRetryBudgetUsed {
return false return false
} }
@@ -895,8 +896,8 @@ public actor GatewayChannelActor {
if authError.isNonRecoverable { if authError.isNonRecoverable {
return true return true
} }
if authError.detail == .authTokenMismatch && if authError.detail == .authTokenMismatch,
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry self.deviceTokenRetryBudgetUsed, !self.pendingDeviceTokenRetry
{ {
return true return true
} }
@@ -1007,7 +1008,7 @@ public actor GatewayChannelActor {
} }
} }
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them. /// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error { private func wrap(_ error: Error, context: String) -> Error {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError { if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
return error return error
@@ -1055,8 +1056,7 @@ public actor GatewayChannelActor {
return (id: id, data: data) return (id: id, data: data)
} catch { } catch {
self.logger.error( self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)" "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
)
throw error throw error
} }
} }

View File

@@ -9,9 +9,9 @@ public enum GatewayConnectChallengeSupport {
return trimmed return trimmed
} }
public static func waitForNonce<E: Error>( public static func waitForNonce(
timeoutSeconds: Double, timeoutSeconds: Double,
onTimeout: @escaping @Sendable () -> E, onTimeout: @escaping @Sendable () -> some Error,
receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String
{ {
try await AsyncTimeout.withTimeout( try await AsyncTimeout.withTimeout(

View File

@@ -81,32 +81,34 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
public var needsPairingApproval: Bool { public var needsPairingApproval: Bool {
switch self.kind { switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired: case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
return true .pairingMetadataUpgradeRequired:
true
default: default:
return false false
} }
} }
public var needsCredentialUpdate: Bool { public var needsCredentialUpdate: Bool {
switch self.kind { switch self.kind {
case .gatewayAuthTokenMissing, case .gatewayAuthTokenMissing,
.gatewayAuthTokenMismatch, .gatewayAuthTokenMismatch,
.gatewayAuthTokenNotConfigured, .gatewayAuthTokenNotConfigured,
.gatewayAuthPasswordMissing, .gatewayAuthPasswordMissing,
.gatewayAuthPasswordMismatch, .gatewayAuthPasswordMismatch,
.gatewayAuthPasswordNotConfigured, .gatewayAuthPasswordNotConfigured,
.bootstrapTokenInvalid, .bootstrapTokenInvalid,
.deviceTokenMismatch: .deviceTokenMismatch:
return true true
default: default:
return false false
} }
} }
public var statusText: String { public var statusText: String {
switch self.kind { switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired: case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
if let requestId { if let requestId {
return "\(self.title) (request ID: \(requestId))" return "\(self.title) (request ID: \(requestId))"
} }
@@ -123,7 +125,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
} }
public enum GatewayConnectionProblemMapper { public enum GatewayConnectionProblemMapper {
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? { public static func map(
error: Error,
preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem?
{
guard let nextProblem = self.rawMap(error) else { guard let nextProblem = self.rawMap(error) else {
return nil return nil
} }
@@ -136,14 +141,20 @@ public enum GatewayConnectionProblemMapper {
return nextProblem return nextProblem
} }
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool { public static func shouldPreserve(
previousProblem: GatewayConnectionProblem,
over nextProblem: GatewayConnectionProblem) -> Bool
{
if nextProblem.kind == .websocketCancelled { if nextProblem.kind == .websocketCancelled {
return previousProblem.pauseReconnect || previousProblem.requestId != nil return previousProblem.pauseReconnect || previousProblem.requestId != nil
} }
return false return false
} }
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool { public static func shouldPreserve(
previousProblem: GatewayConnectionProblem,
overDisconnectReason reason: String) -> Bool
{
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return false } guard !normalized.isEmpty else { return false }
if normalized.contains("cancelled") || normalized.contains("canceled") { if normalized.contains("cancelled") || normalized.contains("canceled") {
@@ -175,7 +186,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway requires an auth token, but this iPhone did not send one.", ?? "This gateway requires an auth token, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings", actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: false, retryable: false,
pauseReconnect: true, pauseReconnect: true,
@@ -187,9 +200,12 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "Gateway token is out of date", title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride message: authError.userMessageOverride
?? "The token on this iPhone does not match the gateway token.", ?? "The token on this iPhone does not match the gateway token.",
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"), actionLabel: authError
.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken, retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken, pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
@@ -203,7 +219,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.", ?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway", actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>", actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: false, retryable: false,
pauseReconnect: true, pauseReconnect: true,
@@ -217,7 +235,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway requires a password, but this iPhone did not send one.", ?? "This gateway requires a password, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings", actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: false, retryable: false,
pauseReconnect: true, pauseReconnect: true,
@@ -231,7 +251,9 @@ public enum GatewayConnectionProblemMapper {
?? "The saved password on this iPhone does not match the gateway password.", ?? "The saved password on this iPhone does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password", actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: false, retryable: false,
pauseReconnect: true, pauseReconnect: true,
@@ -245,7 +267,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.", ?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway", actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>", actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: false, retryable: false,
pauseReconnect: true, pauseReconnect: true,
@@ -286,7 +310,8 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone, owner: .iphone,
title: authError.titleOverride ?? "Secure device identity is required", title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride message: authError.userMessageOverride
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.", ??
"This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
actionLabel: authError.actionLabel ?? "Retry from the app", actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"), docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
@@ -302,7 +327,9 @@ public enum GatewayConnectionProblemMapper {
message: authError.userMessageOverride ?? "The device signature is too old to use.", message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check iPhone time", actionLabel: authError.actionLabel ?? "Check iPhone time",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: true, retryable: true,
pauseReconnect: true, pauseReconnect: true,
@@ -316,7 +343,9 @@ public enum GatewayConnectionProblemMapper {
?? "The gateway expected a one-time challenge response, but the nonce was missing.", ?? "The gateway expected a one-time challenge response, but the nonce was missing.",
actionLabel: authError.actionLabel ?? "Retry", actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: true, retryable: true,
pauseReconnect: true, pauseReconnect: true,
@@ -329,7 +358,9 @@ public enum GatewayConnectionProblemMapper {
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.", message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
actionLabel: authError.actionLabel ?? "Retry", actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: true, retryable: true,
pauseReconnect: true, pauseReconnect: true,
@@ -441,7 +472,9 @@ public enum GatewayConnectionProblemMapper {
?? "The gateway is temporarily refusing new auth attempts after repeated failures.", ?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
actionLabel: authError.actionLabel ?? "Wait and retry", actionLabel: authError.actionLabel ?? "Wait and retry",
actionCommand: authError.actionCommand, actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId, requestId: authError.requestId,
retryable: false, retryable: false,
pauseReconnect: true, pauseReconnect: true,
@@ -520,7 +553,8 @@ public enum GatewayConnectionProblemMapper {
retryable: true, retryable: true,
pauseReconnect: false, pauseReconnect: false,
technicalDetails: rawMessage) technicalDetails: rawMessage)
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed: case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost,
.internationalRoamingOff, .callIsActive, .dataNotAllowed:
return GatewayConnectionProblem( return GatewayConnectionProblem(
kind: .reachabilityFailed, kind: .reachabilityFailed,
owner: .network, owner: .network,
@@ -575,7 +609,9 @@ public enum GatewayConnectionProblemMapper {
pauseReconnect: false, pauseReconnect: false,
technicalDetails: rawMessage) technicalDetails: rawMessage)
} }
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") { if lower.contains("cannot find host") || lower.contains("could not connect") || lower
.contains("network is unreachable")
{
return GatewayConnectionProblem( return GatewayConnectionProblem(
kind: .reachabilityFailed, kind: .reachabilityFailed,
owner: .network, owner: .network,
@@ -615,7 +651,8 @@ public enum GatewayConnectionProblemMapper {
owner: .gateway, owner: .gateway,
title: authError.titleOverride ?? "Additional approval required", title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.", ??
"This iPhone is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway", actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand, actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -643,7 +680,8 @@ public enum GatewayConnectionProblemMapper {
owner: .gateway, owner: .gateway,
title: authError.titleOverride ?? "Device approval needs refresh", title: authError.titleOverride ?? "Device approval needs refresh",
message: authError.userMessageOverride message: authError.userMessageOverride
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.", ??
"The gateway detected a change in this device's approved identity metadata and requires re-approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway", actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand, actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -736,17 +774,17 @@ public enum GatewayConnectionProblemMapper {
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? { private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gateway": case "gateway":
return .gateway .gateway
case "iphone", "ios", "device": case "iphone", "ios", "device":
return .iphone .iphone
case "both": case "both":
return .both .both
case "network": case "network":
return .network .network
case "unknown", "": case "unknown", "":
return .unknown .unknown
default: default:
return nil nil
} }
} }

View File

@@ -36,4 +36,3 @@ public enum GatewayDiscoveryStatusText {
return "Searching…" return "Searching…"
} }
} }

View File

@@ -1,5 +1,5 @@
import OpenClawProtocol
import Foundation import Foundation
import OpenClawProtocol
public enum GatewayConnectAuthDetailCode: String, Sendable { public enum GatewayConnectAuthDetailCode: String, Sendable {
case authRequired = "AUTH_REQUIRED" case authRequired = "AUTH_REQUIRED"
@@ -129,9 +129,13 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
public var detailCode: String? { self.detailCodeRaw } public var detailCode: String? {
self.detailCodeRaw
}
public var recommendedNextStepCode: String? { self.recommendedNextStepRaw } public var recommendedNextStepCode: String? {
self.recommendedNextStepRaw
}
public var detail: GatewayConnectAuthDetailCode? { public var detail: GatewayConnectAuthDetailCode? {
guard let detailCodeRaw else { return nil } guard let detailCodeRaw else { return nil }
@@ -143,23 +147,25 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw) return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
} }
public var errorDescription: String? { self.message } public var errorDescription: String? {
self.message
}
public var isNonRecoverable: Bool { public var isNonRecoverable: Bool {
switch self.detail { switch self.detail {
case .authTokenMissing, case .authTokenMissing,
.authBootstrapTokenInvalid, .authBootstrapTokenInvalid,
.authTokenNotConfigured, .authTokenNotConfigured,
.authPasswordMissing, .authPasswordMissing,
.authPasswordMismatch, .authPasswordMismatch,
.authPasswordNotConfigured, .authPasswordNotConfigured,
.authRateLimited, .authRateLimited,
.pairingRequired, .pairingRequired,
.controlUiDeviceIdentityRequired, .controlUiDeviceIdentityRequired,
.deviceIdentityRequired: .deviceIdentityRequired:
return true true
default: default:
return false false
} }
} }
} }
@@ -203,5 +209,7 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
self.message = message self.message = message
} }
public var errorDescription: String? { "\(self.method): \(self.message)" } public var errorDescription: String? {
"\(self.method): \(self.message)"
}
} }

View File

@@ -1,8 +1,8 @@
import OpenClawProtocol
import Foundation import Foundation
import OpenClawProtocol
import OSLog import OSLog
private struct NodeInvokeRequestPayload: Codable, Sendable { private struct NodeInvokeRequestPayload: Codable {
var id: String var id: String
var nodeId: String var nodeId: String
var command: String var command: String
@@ -19,7 +19,7 @@ private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capabilit
let nextSlash = suffix.firstIndex(of: "/") let nextSlash = suffix.firstIndex(of: "/")
let nextQuery = suffix.firstIndex(of: "?") let nextQuery = suffix.firstIndex(of: "?")
let nextFragment = suffix.firstIndex(of: "#") let nextFragment = suffix.firstIndex(of: "#")
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap(\.self).min() ?? scopedUrl.endIndex
guard capabilityStart < capabilityEnd else { return nil } guard capabilityStart < capabilityEnd else { return nil }
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...]) return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
} }
@@ -55,12 +55,11 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
return parsed.string ?? trimmed return parsed.string ?? trimmed
} }
public actor GatewayNodeSession { public actor GatewayNodeSession {
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private static let defaultInvokeTimeoutMs = 30_000 private static let defaultInvokeTimeoutMs = 30000
private var channel: GatewayChannelActor? private var channel: GatewayChannelActor?
private var activeURL: URL? private var activeURL: URL?
private var activeToken: String? private var activeToken: String?
@@ -79,8 +78,8 @@ public actor GatewayNodeSession {
static func invokeWithTimeout( static func invokeWithTimeout(
request: BridgeInvokeRequest, request: BridgeInvokeRequest,
timeoutMs: Int?, timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse { {
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway") let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
let timeout: Int = { let timeout: Int = {
if let timeoutMs { return max(0, timeoutMs) } if let timeoutMs { return max(0, timeoutMs) }
@@ -144,13 +143,14 @@ public actor GatewayNodeSession {
ok: false, ok: false,
error: OpenClawNodeError( error: OpenClawNodeError(
code: .unavailable, code: .unavailable,
message: "node invoke timed out") message: "node invoke timed out")))
))
} }
} }
timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") timeoutLogger
.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
return response return response
} }
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String? private var canvasHostUrl: String?
@@ -201,8 +201,8 @@ public actor GatewayNodeSession {
sessionBox: WebSocketSessionBox?, sessionBox: WebSocketSessionBox?,
onConnected: @escaping @Sendable () async -> Void, onConnected: @escaping @Sendable () async -> Void,
onDisconnected: @escaping @Sendable (String) async -> Void, onDisconnected: @escaping @Sendable (String) async -> Void,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
) async throws { {
let nextOptionsKey = self.connectOptionsKey(connectOptions) let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url || let shouldReconnect = self.activeURL != url ||
self.activeToken != token || self.activeToken != token ||
@@ -273,7 +273,7 @@ public actor GatewayNodeSession {
self.canvasHostUrl self.canvasHostUrl
} }
public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool { public func refreshNodeCanvasCapability(timeoutMs: Int = 8000) async -> Bool {
guard let channel = self.channel else { return false } guard let channel = self.channel else { return false }
do { do {
let data = try await channel.request( let data = try await channel.request(
@@ -455,8 +455,7 @@ public actor GatewayNodeSession {
let response = await Self.invokeWithTimeout( let response = await Self.invokeWithTimeout(
request: req, request: req,
timeoutMs: request.timeoutMs, timeoutMs: request.timeoutMs,
onInvoke: onInvoke onInvoke: onInvoke)
)
self.logger.info( self.logger.info(
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") "node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
await self.sendInvokeResult(request: request, response: response) await self.sendInvokeResult(request: request, response: response)

View File

@@ -1,5 +1,5 @@
import OpenClawProtocol
import Foundation import Foundation
import OpenClawProtocol
public enum GatewayPayloadDecoding { public enum GatewayPayloadDecoding {
public static func decode<T: Decodable>( public static func decode<T: Decodable>(

View File

@@ -66,7 +66,8 @@ public enum GatewayTLSStore {
!existing.isEmpty !existing.isEmpty
else { return } else { return }
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil { if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID) else { guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID)
else {
return return
} }
} }
@@ -108,8 +109,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
public func urlSession( public func urlSession(
_ session: URLSession, _ session: URLSession,
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
) { {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust let trust = challenge.protectionSpace.serverTrust
else { else {
@@ -117,7 +118,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
return return
} }
let expected = params.expectedFingerprint.map(normalizeFingerprint) let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
if let fingerprint = certificateFingerprint(trust) { if let fingerprint = certificateFingerprint(trust) {
if let expected { if let expected {
if fingerprint == expected { if fingerprint == expected {
@@ -127,7 +128,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
} }
return return
} }
if params.allowTOFU { if self.params.allowTOFU {
if let storeKey = params.storeKey { if let storeKey = params.storeKey {
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
} }
@@ -137,7 +138,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
} }
let ok = SecTrustEvaluateWithError(trust, nil) let ok = SecTrustEvaluateWithError(trust, nil)
if ok || !params.required { if ok || !self.params.required {
completionHandler(.useCredential, URLCredential(trust: trust)) completionHandler(.useCredential, URLCredential(trust: trust))
} else { } else {
completionHandler(.cancelAuthenticationChallenge, nil) completionHandler(.cancelAuthenticationChallenge, nil)

View File

@@ -12,8 +12,8 @@ public enum GenericPasswordKeychainStore {
_ value: String, _ value: String,
service: String, service: String,
account: String, account: String,
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) -> Bool
) -> Bool { {
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible) self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
} }
@@ -40,8 +40,8 @@ public enum GenericPasswordKeychainStore {
_ data: Data, _ data: Data,
service: String, service: String,
account: String, account: String,
accessible: CFString accessible: CFString) -> Bool
) -> Bool { {
let query = self.baseQuery(service: service, account: account) let query = self.baseQuery(service: service, account: account)
let previousData = self.loadData(service: service, account: account) let previousData = self.loadData(service: service, account: account)

View File

@@ -12,7 +12,7 @@ public enum InstanceIdentity {
UserDefaults(suiteName: suiteName) ?? .standard UserDefaults(suiteName: suiteName) ?? .standard
} }
#if canImport(UIKit) #if canImport(UIKit)
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T { private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
if Thread.isMainThread { if Thread.isMainThread {
return MainActor.assumeIsolated { body() } return MainActor.assumeIsolated { body() }
@@ -21,7 +21,7 @@ public enum InstanceIdentity {
MainActor.assumeIsolated { body() } MainActor.assumeIsolated { body() }
} }
} }
#endif #endif
public static let instanceId: String = { public static let instanceId: String = {
let defaults = Self.defaults let defaults = Self.defaults
@@ -38,23 +38,23 @@ public enum InstanceIdentity {
}() }()
public static let displayName: String = { public static let displayName: String = {
#if canImport(UIKit) #if canImport(UIKit)
let name = Self.readMainActor { let name = Self.readMainActor {
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
} }
return name.isEmpty ? "openclaw" : name return name.isEmpty ? "openclaw" : name
#else #else
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty !name.isEmpty
{ {
return name return name
} }
return "openclaw" return "openclaw"
#endif #endif
}() }()
public static let modelIdentifier: String? = { public static let modelIdentifier: String? = {
#if canImport(UIKit) #if canImport(UIKit)
var systemInfo = utsname() var systemInfo = utsname()
uname(&systemInfo) uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
@@ -62,7 +62,7 @@ public enum InstanceIdentity {
} }
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
#else #else
var size = 0 var size = 0
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
@@ -73,36 +73,36 @@ public enum InstanceIdentity {
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil } guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
#endif #endif
}() }()
public static let deviceFamily: String = { public static let deviceFamily: String = {
#if canImport(UIKit) #if canImport(UIKit)
return Self.readMainActor { return Self.readMainActor {
switch UIDevice.current.userInterfaceIdiom { switch UIDevice.current.userInterfaceIdiom {
case .pad: return "iPad" case .pad: "iPad"
case .phone: return "iPhone" case .phone: "iPhone"
default: return "iOS" default: "iOS"
} }
} }
#else #else
return "Mac" return "Mac"
#endif #endif
}() }()
public static let platformString: String = { public static let platformString: String = {
let v = ProcessInfo.processInfo.operatingSystemVersion let v = ProcessInfo.processInfo.operatingSystemVersion
#if canImport(UIKit) #if canImport(UIKit)
let name = Self.readMainActor { let name = Self.readMainActor {
switch UIDevice.current.userInterfaceIdiom { switch UIDevice.current.userInterfaceIdiom {
case .pad: return "iPadOS" case .pad: "iPadOS"
case .phone: return "iOS" case .phone: "iOS"
default: return "iOS" default: "iOS"
} }
} }
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#else #else
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#endif #endif
}() }()
} }

View File

@@ -4,8 +4,7 @@ import Foundation
public enum LocationCurrentRequest { public enum LocationCurrentRequest {
public typealias TimeoutRunner = @Sendable ( public typealias TimeoutRunner = @Sendable (
_ timeoutMs: Int, _ timeoutMs: Int,
_ operation: @escaping @Sendable () async throws -> CLLocation _ operation: @escaping @Sendable () async throws -> CLLocation) async throws -> CLLocation
) async throws -> CLLocation
@MainActor @MainActor
public static func resolve( public static func resolve(

View File

@@ -7,21 +7,21 @@ public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate {
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { get set } var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { get set }
} }
public extension LocationServiceCommon { extension LocationServiceCommon {
func configureLocationManager() { public func configureLocationManager() {
self.locationManager.delegate = self self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
} }
func authorizationStatus() -> CLAuthorizationStatus { public func authorizationStatus() -> CLAuthorizationStatus {
self.locationManager.authorizationStatus self.locationManager.authorizationStatus
} }
func accuracyAuthorization() -> CLAccuracyAuthorization { public func accuracyAuthorization() -> CLAccuracyAuthorization {
LocationServiceSupport.accuracyAuthorization(manager: self.locationManager) LocationServiceSupport.accuracyAuthorization(manager: self.locationManager)
} }
func requestLocationOnce() async throws -> CLLocation { public func requestLocationOnce() async throws -> CLLocation {
try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in
self.locationRequestContinuation = continuation self.locationRequestContinuation = continuation
} }

View File

@@ -18,7 +18,7 @@ public enum OpenClawKitResources {
private static func locateBundle() -> Bundle { private static func locateBundle() -> Bundle {
// 1. Check inside Bundle.main (packaged apps copy resources here) // 1. Check inside Bundle.main (packaged apps copy resources here)
if let mainResourceURL = Bundle.main.resourceURL { if let mainResourceURL = Bundle.main.resourceURL {
let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle") let bundleURL = mainResourceURL.appendingPathComponent("\(self.bundleName).bundle")
if let bundle = Bundle(url: bundleURL) { if let bundle = Bundle(url: bundleURL) {
return bundle return bundle
} }
@@ -60,7 +60,7 @@ public enum OpenClawKitResources {
roots.append(baseURL.appendingPathComponent("Contents/Resources")) roots.append(baseURL.appendingPathComponent("Contents/Resources"))
var current = baseURL var current = baseURL
for _ in 0 ..< 5 { for _ in 0..<5 {
current = current.deletingLastPathComponent() current = current.deletingLastPathComponent()
roots.append(current) roots.append(current)
roots.append(current.appendingPathComponent("Resources")) roots.append(current.appendingPathComponent("Resources"))
@@ -68,7 +68,7 @@ public enum OpenClawKitResources {
} }
for root in roots { for root in roots {
let bundleURL = root.appendingPathComponent("\(bundleName).bundle") let bundleURL = root.appendingPathComponent("\(self.bundleName).bundle")
if let bundle = Bundle(url: bundleURL) { if let bundle = Bundle(url: bundleURL) {
return bundle return bundle
} }
@@ -79,5 +79,5 @@ public enum OpenClawKitResources {
} }
} }
// Helper class for bundle lookup via Bundle(for:) /// Helper class for bundle lookup via Bundle(for:)
private final class BundleLocator {} private final class BundleLocator {}

View File

@@ -5,8 +5,8 @@ public enum PhotoCapture {
rawData: Data, rawData: Data,
maxWidthPx: Int, maxWidthPx: Int,
quality: Double, quality: Double,
maxPayloadBytes: Int = 5 * 1024 * 1024 maxPayloadBytes: Int = 5 * 1024 * 1024) throws -> (data: Data, widthPx: Int, heightPx: Int)
) throws -> (data: Data, widthPx: Int, heightPx: Int) { {
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit). // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
let maxEncodedBytes = (maxPayloadBytes / 4) * 3 let maxEncodedBytes = (maxPayloadBytes / 4) * 3
return try JPEGTranscoder.transcodeToJPEG( return try JPEGTranscoder.transcodeToJPEG(
@@ -16,4 +16,3 @@ public enum PhotoCapture {
maxBytes: maxEncodedBytes) maxBytes: maxEncodedBytes)
} }
} }

View File

@@ -33,7 +33,7 @@ public enum ShareToAgentDeepLink {
let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction() let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
var lines: [String] = ["Shared from iOS."] var lines = ["Shared from iOS."]
if let title, !title.isEmpty { if let title, !title.isEmpty {
lines.append("Title: \(title)") lines.append("Title: \(title)")
} }

View File

@@ -20,8 +20,8 @@ public enum TalkConfigParsing {
public static func selectProviderConfig( public static func selectProviderConfig(
_ talk: [String: AnyCodable]?, _ talk: [String: AnyCodable]?,
defaultProvider: String, defaultProvider: String,
allowLegacyFallback: Bool = true, allowLegacyFallback: Bool = true) -> TalkProviderConfigSelection?
) -> TalkProviderConfigSelection? { {
guard let talk else { return nil } guard let talk else { return nil }
if let resolvedSelection = self.resolvedProviderConfig(talk) { if let resolvedSelection = self.resolvedProviderConfig(talk) {
return resolvedSelection return resolvedSelection
@@ -63,16 +63,16 @@ public enum TalkConfigParsing {
public static func resolvedSpeechLocaleID( public static func resolvedSpeechLocaleID(
_ talk: [String: AnyCodable]?, _ talk: [String: AnyCodable]?,
fallback: String? = nil fallback: String? = nil) -> String?
) -> String? { {
self.normalizedSpeechLocaleID(talk?["speechLocale"]?.stringValue) self.normalizedSpeechLocaleID(talk?["speechLocale"]?.stringValue)
?? self.normalizedSpeechLocaleID(fallback) ?? self.normalizedSpeechLocaleID(fallback)
} }
public static func normalizedExplicitSpeechLocaleID( public static func normalizedExplicitSpeechLocaleID(
_ value: String?, _ value: String?,
automaticID: String = "auto" automaticID: String = "auto") -> String?
) -> String? { {
let normalized = self.normalizedSpeechLocaleID(value) let normalized = self.normalizedSpeechLocaleID(value)
return normalized == automaticID ? nil : normalized return normalized == automaticID ? nil : normalized
} }
@@ -80,8 +80,8 @@ public enum TalkConfigParsing {
public static func resolvedSpeechRecognitionLocaleID( public static func resolvedSpeechRecognitionLocaleID(
preferredLocaleIDs: [String?], preferredLocaleIDs: [String?],
fallbackLocaleID: String = "en-US", fallbackLocaleID: String = "en-US",
supportedLocaleIDs: Set<String> supportedLocaleIDs: Set<String>) -> String?
) -> String? { {
let supported = Set(supportedLocaleIDs.compactMap(self.normalizedSpeechLocaleID)) let supported = Set(supportedLocaleIDs.compactMap(self.normalizedSpeechLocaleID))
var seen = Set<String>() var seen = Set<String>()
let candidates = (preferredLocaleIDs + [fallbackLocaleID]) let candidates = (preferredLocaleIDs + [fallbackLocaleID])
@@ -102,8 +102,8 @@ public enum TalkConfigParsing {
} }
private static func resolvedProviderConfig( private static func resolvedProviderConfig(
_ talk: [String: AnyCodable] _ talk: [String: AnyCodable]) -> TalkProviderConfigSelection?
) -> TalkProviderConfigSelection? { {
guard guard
let resolved = talk["resolved"]?.dictionaryValue, let resolved = talk["resolved"]?.dictionaryValue,
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue) let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)

View File

@@ -2,16 +2,15 @@ public enum TalkPromptBuilder: Sendable {
public static func build( public static func build(
transcript: String, transcript: String,
interruptedAtSeconds: Double?, interruptedAtSeconds: Double?,
includeVoiceDirectiveHint: Bool = true includeVoiceDirectiveHint: Bool = true) -> String
) -> String { {
var lines: [String] = [ var lines: [String] = [
"Talk Mode active. Reply in a concise, spoken tone.", "Talk Mode active. Reply in a concise, spoken tone.",
] ]
if includeVoiceDirectiveHint { if includeVoiceDirectiveHint {
lines.append( lines.append(
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}." "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}.")
)
} }
if let interruptedAtSeconds { if let interruptedAtSeconds {

View File

@@ -16,7 +16,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
private var currentToken = UUID() private var currentToken = UUID()
private var watchdog: Task<Void, Never>? private var watchdog: Task<Void, Never>?
public var isSpeaking: Bool { self.synth.isSpeaking } public var isSpeaking: Bool {
self.synth.isSpeaking
}
override private init() { override private init() {
super.init() super.init()
@@ -35,8 +37,8 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
public func speak( public func speak(
text: String, text: String,
language: String? = nil, language: String? = nil,
onStart: (() -> Void)? = nil onStart: (() -> Void)? = nil) async throws
) async throws { {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
@@ -51,7 +53,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
} }
self.currentUtterance = utterance self.currentUtterance = utterance
let watchdogTimeout = Self.watchdogTimeoutSeconds(text: trimmed, language: language ?? utterance.voice?.language) let watchdogTimeout = Self.watchdogTimeoutSeconds(
text: trimmed,
language: language ?? utterance.voice?.language)
self.watchdog?.cancel() self.watchdog?.cancel()
self.watchdog = Task { @MainActor [weak self] in self.watchdog = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }

View File

@@ -6,7 +6,9 @@ import Foundation
public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
public let value: Any public let value: Any
public init(_ value: Any) { self.value = Self.normalize(value) } public init(_ value: Any) {
self.value = Self.normalize(value)
}
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()

View File

@@ -74,11 +74,11 @@ public func anyCodableBool(_ value: AnyCodable?) -> Bool {
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
switch value?.value { switch value?.value {
case let arr as [AnyCodable]: case let arr as [AnyCodable]:
return arr arr
case let arr as [Any]: case let arr as [Any]:
return arr.map { AnyCodable($0) } arr.map { AnyCodable($0) }
default: default:
return [] []
} }
} }

View File

@@ -93,7 +93,7 @@ Gateway-side requirement:
How the flow works: How the flow works:
- The iOS app registers with the relay using App Attest and the app receipt. - The iOS app registers with the relay using App Attest and a StoreKit app transaction JWS.
- The relay returns an opaque relay handle plus a registration-scoped send grant. - The relay returns an opaque relay handle plus a registration-scoped send grant.
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway. - The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`. - The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
@@ -136,8 +136,8 @@ Hop by hop:
2. `iOS app -> relay` 2. `iOS app -> relay`
- The app calls the relay registration endpoints over HTTPS. - The app calls the relay registration endpoints over HTTPS.
- Registration includes App Attest proof plus the app receipt. - Registration includes App Attest proof plus a StoreKit app transaction JWS.
- The relay validates the bundle ID, App Attest proof, and Apple receipt, and requires the - The relay validates the bundle ID, App Attest proof, and Apple distribution proof, and requires the
official/production distribution path. official/production distribution path.
- This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be - This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be
signed, but it does not satisfy the official Apple distribution proof the relay expects. signed, but it does not satisfy the official Apple distribution proof the relay expects.
@@ -227,6 +227,18 @@ Notes:
- The iOS node auto-navigates to A2UI on connect when a canvas host URL is advertised. - The iOS node auto-navigates to A2UI on connect when a canvas host URL is advertised.
- Return to the built-in scaffold with `canvas.navigate` and `{"url":""}`. - Return to the built-in scaffold with `canvas.navigate` and `{"url":""}`.
## Computer Use relationship
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 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
commands, but those calls go through the gateway node protocol and follow iOS
foreground/background limits. Use [Codex Computer Use](/plugins/codex-computer-use)
for local desktop control and this page for iOS node capabilities.
### Canvas eval / snapshot ### Canvas eval / snapshot
```bash ```bash

View File

@@ -32,6 +32,18 @@ a permission-aware host for Peekaboo CLI automation. Use this page when a
Codex-mode OpenClaw agent should have Codex's native `computer-use` MCP plugin Codex-mode OpenClaw agent should have Codex's native `computer-use` MCP plugin
available before the turn starts. available before the turn starts.
## iOS app
The iOS app is separate from Codex Computer Use. It does not install or proxy
the Codex `computer-use` MCP server and it is not a desktop-control backend.
Instead, the iOS app connects as an OpenClaw node and exposes mobile
capabilities through node commands such as `canvas.*`, `camera.*`, `screen.*`,
`location.*`, and `talk.*`.
Use [iOS](/platforms/ios) when you want an agent to drive an iPhone node through
the gateway. Use this page when a Codex-mode agent should control the local
macOS desktop through Codex's native Computer Use plugin.
## Direct cua-driver MCP ## Direct cua-driver MCP
Codex Computer Use is not the only way to expose desktop control. If you want Codex Computer Use is not the only way to expose desktop control. If you want