mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 07:42:04 +00:00
fix: harden ios app build hygiene
This commit is contained in:
parent
2fe213ebf2
commit
b294f7c467
97 changed files with 1150 additions and 1044 deletions
|
|
@ -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/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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
|
||||
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
|
|
@ -10,24 +10,6 @@
|
|||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -45,24 +27,6 @@
|
|||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ public struct WakeWordSegment: Sendable, Equatable {
|
|||
self.range = range
|
||||
}
|
||||
|
||||
public var end: TimeInterval { start + duration }
|
||||
public var end: TimeInterval {
|
||||
self.start + self.duration
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
|
|
@ -24,7 +26,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
|||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1) {
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
|
|
@ -78,10 +81,10 @@ public enum WakeWordGate {
|
|||
segments: [WakeWordSegment],
|
||||
config: WakeWordGateConfig)
|
||||
-> WakeWordGateMatch? {
|
||||
let triggerTokens = normalizeTriggers(config.triggers)
|
||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||
guard !triggerTokens.isEmpty else { return nil }
|
||||
|
||||
let tokens = normalizeSegments(segments)
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: MatchCandidate?
|
||||
|
|
@ -115,7 +118,7 @@ public enum WakeWordGate {
|
|||
}
|
||||
|
||||
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)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(
|
||||
|
|
@ -145,7 +148,7 @@ public enum WakeWordGate {
|
|||
guard !text.isEmpty else { return false }
|
||||
let normalized = text.lowercased()
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if normalized.contains(token) { return true }
|
||||
}
|
||||
|
|
@ -155,11 +158,11 @@ public enum WakeWordGate {
|
|||
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||
var out = text
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
guard !token.isEmpty else { continue }
|
||||
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] {
|
||||
|
|
@ -167,7 +170,7 @@ public enum WakeWordGate {
|
|||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { normalizeToken(String($0)) }
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
|
||||
|
|
@ -177,7 +180,7 @@ public enum WakeWordGate {
|
|||
|
||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||
segments.compactMap { segment in
|
||||
let normalized = normalizeToken(segment.text)
|
||||
let normalized = self.normalizeToken(segment.text)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return Token(
|
||||
normalized: normalized,
|
||||
|
|
@ -190,7 +193,7 @@ public enum WakeWordGate {
|
|||
|
||||
private static func normalizeToken(_ token: String) -> String {
|
||||
token
|
||||
.trimmingCharacters(in: whitespaceAndPunctuation)
|
||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import WidgetKit
|
|||
struct OpenClawLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
|
||||
lockScreenView(context: context)
|
||||
self.lockScreenView(context: context)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
statusDot(state: context.state)
|
||||
self.statusDot(state: context.state)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.statusText)
|
||||
|
|
@ -17,25 +17,24 @@ struct OpenClawLiveActivity: Widget {
|
|||
.lineLimit(1)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
trailingView(state: context.state)
|
||||
self.trailingView(state: context.state)
|
||||
}
|
||||
} compactLeading: {
|
||||
statusDot(state: context.state)
|
||||
self.statusDot(state: context.state)
|
||||
} compactTrailing: {
|
||||
Text(context.state.statusText)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 64)
|
||||
} minimal: {
|
||||
statusDot(state: context.state)
|
||||
self.statusDot(state: context.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
statusDot(state: context.state)
|
||||
self.statusDot(state: context.state)
|
||||
.frame(width: 10, height: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
|
|
@ -45,7 +44,7 @@ struct OpenClawLiveActivity: Widget {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
trailingView(state: context.state)
|
||||
self.trailingView(state: context.state)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
|
|
@ -69,10 +68,9 @@ struct OpenClawLiveActivity: Widget {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
Circle()
|
||||
.fill(dotColor(state: state))
|
||||
.fill(self.dotColor(state: state))
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
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
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
- 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.
|
||||
|
||||
## 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 operator session is used to fetch `gateway.identity.get`.
|
||||
- `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
|
||||
Xcode/dev installs cannot use the hosted relay.
|
||||
- `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.
|
||||
- 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)
|
||||
|
||||
Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism.
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ final class ShareViewController: UIViewController {
|
|||
let payload = extracted.payload
|
||||
self.pendingAttachments = extracted.attachments
|
||||
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)
|
||||
await MainActor.run {
|
||||
self.draftTextView.text = message
|
||||
|
|
@ -287,7 +287,7 @@ final class ShareViewController: UIViewController {
|
|||
let isInvalidConnectParams =
|
||||
(code.contains("invalid") && code.contains("connect"))
|
||||
|| message.contains("invalid connect params")
|
||||
if isInvalidConnectParams && mentionsClientIdPath {
|
||||
if isInvalidConnectParams, mentionsClientIdPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -405,7 +405,6 @@ final class ShareViewController: UIViewController {
|
|||
} else {
|
||||
unknownCount += 1
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,7 +474,7 @@ final class ShareViewController: UIViewController {
|
|||
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
|
||||
if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier),
|
||||
let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
url.scheme != nil
|
||||
url.scheme != nil
|
||||
{
|
||||
return url
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import AVFoundation
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
|
||||
actor CameraController {
|
||||
struct CameraDeviceInfo: Codable, Sendable {
|
||||
struct CameraDeviceInfo: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var position: String
|
||||
var deviceType: String
|
||||
}
|
||||
|
||||
enum CameraError: LocalizedError, Sendable {
|
||||
enum CameraError: LocalizedError {
|
||||
case cameraUnavailable
|
||||
case microphoneUnavailable
|
||||
case permissionDenied(kind: String)
|
||||
|
|
@ -142,7 +142,7 @@ actor CameraController {
|
|||
}
|
||||
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
return Self.discoverVideoDevices().map { device in
|
||||
Self.discoverVideoDevices().map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
|
|
@ -152,7 +152,7 @@ actor CameraController {
|
|||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ actor CameraController {
|
|||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -270,8 +270,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
|
|
@ -303,8 +303,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard let error else { return }
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
|
|
@ -70,10 +70,9 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
|||
{
|
||||
let startLogMessage =
|
||||
"chat.send start sessionKey=\(sessionKey) "
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)"
|
||||
)
|
||||
"\(startLogMessage, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ final class ContactsService: ContactsServicing {
|
|||
contact.givenName = givenName ?? ""
|
||||
contact.familyName = familyName ?? ""
|
||||
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.phoneNumbers = phoneNumbers.map {
|
||||
|
|
@ -86,13 +86,12 @@ final class ContactsService: ContactsServicing {
|
|||
save.add(contact, toContainerWithIdentifier: nil)
|
||||
try store.execute(save)
|
||||
|
||||
let persisted: CNContact
|
||||
if !contact.identifier.isEmpty {
|
||||
persisted = try store.unifiedContact(
|
||||
let persisted: CNContact = if !contact.identifier.isEmpty {
|
||||
try store.unifiedContact(
|
||||
withIdentifier: contact.identifier,
|
||||
keysToFetch: Self.payloadKeys)
|
||||
} else {
|
||||
persisted = contact
|
||||
contact
|
||||
}
|
||||
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||
|
|
@ -137,7 +136,7 @@ final class ContactsService: ContactsServicing {
|
|||
phoneNumbers: [String],
|
||||
emails: [String]) throws -> CNContact?
|
||||
{
|
||||
if phoneNumbers.isEmpty && emails.isEmpty {
|
||||
if phoneNumbers.isEmpty, emails.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -163,13 +162,13 @@ final class ContactsService: ContactsServicing {
|
|||
phoneNumbers: [String],
|
||||
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 })
|
||||
var seen = Set<String>()
|
||||
|
||||
for contact in contacts {
|
||||
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() })
|
||||
|
||||
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
||||
|
|
@ -198,13 +197,13 @@ final class ContactsService: ContactsServicing {
|
|||
givenName: contact.givenName,
|
||||
familyName: contact.familyName,
|
||||
organizationName: contact.organizationName,
|
||||
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
|
||||
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import Darwin
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
import Darwin
|
||||
|
||||
/// Shared device and platform info for Settings, gateway node payloads, and device status.
|
||||
enum DeviceInfoHelper {
|
||||
/// 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.
|
||||
static func openClawVersionString() -> String {
|
||||
let version = appVersion()
|
||||
let build = appBuild()
|
||||
let version = self.appVersion()
|
||||
let build = self.appBuild()
|
||||
if build.isEmpty || build == version {
|
||||
return version
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,25 +5,25 @@ enum NodeDisplayName {
|
|||
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
||||
|
||||
static func isGeneric(_ name: String) -> Bool {
|
||||
Self.genericNames.contains(name)
|
||||
self.genericNames.contains(name)
|
||||
}
|
||||
|
||||
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
||||
switch interfaceIdiom {
|
||||
case .phone:
|
||||
return "iPhone Node"
|
||||
"iPhone Node"
|
||||
case .pad:
|
||||
return "iPad Node"
|
||||
"iPad Node"
|
||||
default:
|
||||
return "iOS Node"
|
||||
"iOS Node"
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
existing: String?,
|
||||
deviceName: String,
|
||||
interfaceIdiom: UIUserInterfaceIdiom
|
||||
) -> String {
|
||||
interfaceIdiom: UIUserInterfaceIdiom) -> String
|
||||
{
|
||||
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
||||
return trimmedExisting
|
||||
|
|
|
|||
|
|
@ -31,4 +31,3 @@ enum EventKitAuthorization {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import OpenClawKit
|
|||
///
|
||||
/// Both sessions should derive all connection inputs from this config so we
|
||||
/// don't accidentally persist gateway-scoped state under different keys.
|
||||
struct GatewayConnectConfig: Sendable {
|
||||
struct GatewayConnectConfig {
|
||||
let url: URL
|
||||
let stableID: String
|
||||
let tls: GatewayTLSParams?
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import Contacts
|
|||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import EventKit
|
||||
import Foundation
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import Photos
|
||||
import ReplayKit
|
||||
|
|
@ -28,7 +28,9 @@ final class GatewayConnectionController {
|
|||
let fingerprintSha256: String
|
||||
let isManual: Bool
|
||||
|
||||
var id: String { self.stableID }
|
||||
var id: String {
|
||||
self.stableID
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||
|
|
@ -86,7 +88,6 @@ final class GatewayConnectionController {
|
|||
self.updateFromDiscovery()
|
||||
}
|
||||
|
||||
|
||||
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
||||
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
|
|
@ -177,7 +178,7 @@ final class GatewayConnectionController {
|
|||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||
self.appModel?.gatewayStatusText =
|
||||
"TLS handshake failed for \(host):\(resolvedPort). "
|
||||
+ "Remote gateways must use HTTPS/WSS."
|
||||
+ "Remote gateways must use HTTPS/WSS."
|
||||
return
|
||||
}
|
||||
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)? {
|
||||
switch endpoint {
|
||||
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, _):
|
||||
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -569,8 +570,8 @@ final class GatewayConnectionController {
|
|||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0
|
||||
) async -> (host: String, port: Int)? {
|
||||
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
|
||||
{
|
||||
// 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
|
||||
// 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 }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
|
|
@ -764,7 +765,8 @@ final class GatewayConnectionController {
|
|||
|
||||
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||
if let stableID,
|
||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID)
|
||||
{
|
||||
return override
|
||||
}
|
||||
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||
|
|
@ -781,7 +783,7 @@ final class GatewayConnectionController {
|
|||
}
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return nil }
|
||||
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
||||
if useTLS, self.shouldForceTLS(host: trimmedHost) {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
|
|
@ -929,9 +931,9 @@ final class GatewayConnectionController {
|
|||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1045,8 +1047,8 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
|||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
|
||||
{
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ enum GatewayConnectionIssue: Equatable {
|
|||
var needsAuthToken: Bool {
|
||||
switch self {
|
||||
case .tokenMissing, .unauthorized:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,17 +40,17 @@ enum GatewayConnectionIssue: Equatable {
|
|||
}
|
||||
switch problem.kind {
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch,
|
||||
.tailscaleIdentityMissing,
|
||||
.tailscaleProxyMissing,
|
||||
.tailscaleWhoisFailed,
|
||||
.tailscaleIdentityMismatch,
|
||||
.authRateLimited:
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch,
|
||||
.tailscaleIdentityMissing,
|
||||
.tailscaleProxyMissing,
|
||||
.tailscaleWhoisFailed,
|
||||
.tailscaleIdentityMismatch,
|
||||
.authRateLimited:
|
||||
return .unauthorized
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .network
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import OpenClawKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
|
|
@ -13,7 +13,10 @@ final class GatewayDiscoveryModel {
|
|||
}
|
||||
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var id: String {
|
||||
self.stableID
|
||||
}
|
||||
|
||||
var name: String
|
||||
var endpoint: NWEndpoint
|
||||
var stableID: String
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import OpenClawKit
|
|||
|
||||
@MainActor
|
||||
final class GatewayHealthMonitor {
|
||||
struct Config: Sendable {
|
||||
struct Config {
|
||||
var intervalSeconds: Double
|
||||
var timeoutSeconds: Double
|
||||
var maxFailures: Int
|
||||
|
|
@ -17,8 +17,8 @@ final class GatewayHealthMonitor {
|
|||
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
||||
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
) {
|
||||
})
|
||||
{
|
||||
self.config = config
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ final class GatewayHealthMonitor {
|
|||
{
|
||||
let timeout = max(0.0, timeoutSeconds)
|
||||
if timeout == 0 {
|
||||
return (try? await check()) ?? false
|
||||
return await (try? check()) ?? false
|
||||
}
|
||||
do {
|
||||
let timeoutError = NSError(
|
||||
|
|
|
|||
|
|
@ -59,58 +59,57 @@ struct GatewayProblemBanner: View {
|
|||
.padding(14)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return "person.crop.circle.badge.clock"
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
"person.crop.circle.badge.clock"
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return "wifi.exclamationmark"
|
||||
"wifi.exclamationmark"
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch:
|
||||
return "lock.shield"
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch:
|
||||
"lock.shield"
|
||||
default:
|
||||
return "exclamationmark.triangle.fill"
|
||||
"exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return .orange
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
.orange
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .yellow
|
||||
.yellow
|
||||
default:
|
||||
return .red
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private var ownerLabel: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Fix on gateway"
|
||||
"Fix on gateway"
|
||||
case .iphone:
|
||||
return "Fix on iPhone"
|
||||
"Fix on iPhone"
|
||||
case .both:
|
||||
return "Check both"
|
||||
"Check both"
|
||||
case .network:
|
||||
return "Check network"
|
||||
"Check network"
|
||||
case .unknown:
|
||||
return "Needs attention"
|
||||
"Needs attention"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -218,15 +217,15 @@ struct GatewayProblemDetailsSheet: View {
|
|||
private var ownerSummary: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Primary fix: gateway"
|
||||
"Primary fix: gateway"
|
||||
case .iphone:
|
||||
return "Primary fix: this iPhone"
|
||||
"Primary fix: this iPhone"
|
||||
case .both:
|
||||
return "Primary fix: check both this iPhone and the gateway"
|
||||
"Primary fix: check both this iPhone and the gateway"
|
||||
case .network:
|
||||
return "Primary fix: network or remote access"
|
||||
"Primary fix: network or remote access"
|
||||
case .unknown:
|
||||
return "Primary fix: review details and retry"
|
||||
"Primary fix: review details and retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// NetService-based resolver for Bonjour services.
|
||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
/// NetService-based resolver for Bonjour services.
|
||||
/// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private let service: NetService
|
||||
private let completion: ((host: String, port: Int)?) -> Void
|
||||
|
|
@ -38,7 +38,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
|||
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 }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ enum GatewaySettingsStore {
|
|||
static func loadPreferredGatewayStableID() -> String? {
|
||||
if let value = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.preferredGatewayStableIDAccount
|
||||
)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
account: self.preferredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
{
|
||||
return value
|
||||
|
|
@ -79,8 +78,7 @@ enum GatewaySettingsStore {
|
|||
static func loadLastDiscoveredGatewayStableID() -> String? {
|
||||
if let value = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.lastDiscoveredGatewayStableIDAccount
|
||||
)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
account: self.lastDiscoveredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
{
|
||||
return value
|
||||
|
|
@ -160,18 +158,18 @@ enum GatewaySettingsStore {
|
|||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
return stableID
|
||||
stableID
|
||||
case let .discovered(stableID, _):
|
||||
return stableID
|
||||
stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
return useTLS
|
||||
useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
return useTLS
|
||||
useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -446,7 +444,6 @@ enum GatewaySettingsStore {
|
|||
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
|
|
@ -518,7 +515,7 @@ enum GatewayDiagnostics {
|
|||
|
||||
static func bootstrap() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.queue.async {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
let timestamp = self.isoTimestamp()
|
||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||
|
|
@ -532,10 +529,10 @@ enum GatewayDiagnostics {
|
|||
static func log(_ message: String) {
|
||||
let timestamp = self.isoTimestamp()
|
||||
let line = "[\(timestamp)] \(message)"
|
||||
logger.info("\(line, privacy: .public)")
|
||||
self.logger.info("\(line, privacy: .public)")
|
||||
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.queue.async {
|
||||
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
||||
count += 1
|
||||
if count >= self.logSizeCheckEveryWrites {
|
||||
|
|
@ -556,7 +553,7 @@ enum GatewayDiagnostics {
|
|||
|
||||
static func reset() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.queue.async {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,4 +40,3 @@ enum TCPProbe {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,8 +98,7 @@ private struct HomeToolbarStatusButton: View {
|
|||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
: 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
|
|
@ -214,8 +213,7 @@ private struct HomeToolbarActionButton: View {
|
|||
(self.tint ?? .white).opacity(
|
||||
self.isActive
|
||||
? 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import OpenClawKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ enum SignificantLocationMonitor {
|
|||
locationService: any LocationServicing,
|
||||
locationMode: OpenClawLocationMode,
|
||||
gateway: GatewayNodeSession,
|
||||
beforeSend: (@MainActor @Sendable () async -> Void)? = nil
|
||||
) {
|
||||
beforeSend: (@MainActor @Sendable () async -> Void)? = nil)
|
||||
{
|
||||
guard locationMode == .always else { return }
|
||||
let status = locationService.authorizationStatus()
|
||||
guard status == .authorizedAlways else { return }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
import Photos
|
||||
import OpenClawKit
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
final class PhotoLibraryService: PhotosServicing {
|
||||
|
|
@ -139,7 +139,7 @@ final class PhotoLibraryService: PhotosServicing {
|
|||
if newWidth >= currentImage.size.width {
|
||||
break
|
||||
}
|
||||
currentImage = resize(image: currentImage, targetWidth: newWidth)
|
||||
currentImage = self.resize(image: currentImage, targetWidth: newWidth)
|
||||
}
|
||||
|
||||
throw NSError(domain: "Photos", code: 4, userInfo: [
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Observation
|
||||
import os
|
||||
import Security
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||
private struct NotificationCallError: Error, Sendable {
|
||||
/// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||
private struct NotificationCallError: Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ private struct GatewayRelayIdentityResponse: Decodable {
|
|||
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 let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||
|
|
@ -61,7 +61,7 @@ final class NodeAppModel {
|
|||
let request: AgentDeepLink
|
||||
}
|
||||
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable {
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable, Codable {
|
||||
let id: String
|
||||
let commandText: String
|
||||
let commandPreview: String?
|
||||
|
|
@ -124,6 +124,7 @@ final class NodeAppModel {
|
|||
var gatewayDisplayStatusText: String {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
var selectedAgentId: String?
|
||||
|
|
@ -141,7 +142,7 @@ final class NodeAppModel {
|
|||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
@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()
|
||||
// Secondary "operator" connection: used for chat/talk/config/voicewake requests.
|
||||
private let operatorGateway = GatewayNodeSession()
|
||||
|
|
@ -188,8 +189,14 @@ final class NodeAppModel {
|
|||
private var apnsDeviceTokenHex: String?
|
||||
private var apnsLastRegisteredTokenHex: String?
|
||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
||||
var gatewaySession: GatewayNodeSession {
|
||||
self.nodeGateway
|
||||
}
|
||||
|
||||
var operatorSession: GatewayNodeSession {
|
||||
self.operatorGateway
|
||||
}
|
||||
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
|
|
@ -377,7 +384,6 @@ final class NodeAppModel {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
||||
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
|
||||
|
|
@ -429,7 +435,7 @@ final class NodeAppModel {
|
|||
let operatorWasConnected = await MainActor.run { self.operatorConnected }
|
||||
if operatorWasConnected {
|
||||
// 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",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 2)) != nil
|
||||
|
|
@ -512,7 +518,7 @@ final class NodeAppModel {
|
|||
self.backgroundReconnectSuppressed = false
|
||||
let leaseLogMessage =
|
||||
"Background reconnect lease reason=\(reason) "
|
||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
||||
}
|
||||
|
||||
|
|
@ -525,7 +531,7 @@ final class NodeAppModel {
|
|||
guard changed else { return }
|
||||
let suppressLogMessage =
|
||||
"Background reconnect suppressed reason=\(reason) "
|
||||
+ "disconnect=\(disconnectIfNeeded)"
|
||||
+ "disconnect=\(disconnectIfNeeded)"
|
||||
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
||||
guard disconnectIfNeeded else { return }
|
||||
Task { [weak self] in
|
||||
|
|
@ -646,7 +652,7 @@ final class NodeAppModel {
|
|||
self.applyMainSessionKey(decoded.mainkey)
|
||||
|
||||
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.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
|
|
@ -769,8 +775,7 @@ final class NodeAppModel {
|
|||
let data = try await self.operatorGateway.request(
|
||||
method: "health",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 6
|
||||
)
|
||||
timeoutSeconds: 6)
|
||||
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -1057,6 +1062,7 @@ final class NodeAppModel {
|
|||
"""
|
||||
let resultJSON = try await self.screen.eval(javaScript: js)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
|
|
@ -1294,8 +1300,8 @@ final class NodeAppModel {
|
|||
}
|
||||
|
||||
private static func isNotificationAuthorizationAllowed(
|
||||
_ status: NotificationAuthorizationStatus
|
||||
) -> Bool {
|
||||
_ status: NotificationAuthorizationStatus) -> Bool
|
||||
{
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
true
|
||||
|
|
@ -1306,8 +1312,8 @@ final class NodeAppModel {
|
|||
|
||||
private func runNotificationCall<T: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async -> Result<T, NotificationCallError> {
|
||||
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
|
||||
{
|
||||
let latch = NotificationInvokeLatch<T>()
|
||||
var opTask: Task<Void, Never>?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
|
|
@ -1481,12 +1487,11 @@ final class NodeAppModel {
|
|||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension NodeAppModel {
|
||||
// Central registry for node invoke routing to keep commands in one place.
|
||||
func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||
extension NodeAppModel {
|
||||
/// Central registry for node invoke routing to keep commands in one place.
|
||||
private func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
||||
|
||||
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
||||
|
|
@ -1610,7 +1615,7 @@ private extension NodeAppModel {
|
|||
return NodeCapabilityRouter(handlers: handlers)
|
||||
}
|
||||
|
||||
func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawWatchCommand.status.rawValue:
|
||||
let status = await self.watchMessagingService.status()
|
||||
|
|
@ -1627,7 +1632,7 @@ private extension NodeAppModel {
|
|||
let normalizedParams = Self.normalizeWatchNotifyParams(params)
|
||||
let title = normalizedParams.title
|
||||
let body = normalizedParams.body
|
||||
if title.isEmpty && body.isEmpty {
|
||||
if title.isEmpty, body.isEmpty {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
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"
|
||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||
}
|
||||
|
||||
func isLocationPreciseEnabled() -> Bool {
|
||||
private func isLocationPreciseEnabled() -> Bool {
|
||||
// iOS settings now expose a single location mode control.
|
||||
// Default location tool precision stays high unless a command explicitly requests balanced.
|
||||
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 {
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
|
|
@ -1690,7 +1695,7 @@ private extension NodeAppModel {
|
|||
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)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
||||
|
|
@ -1700,17 +1705,17 @@ private extension NodeAppModel {
|
|||
return json
|
||||
}
|
||||
|
||||
func isCameraEnabled() -> Bool {
|
||||
private func isCameraEnabled() -> Bool {
|
||||
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
||||
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||
}
|
||||
|
||||
func triggerCameraFlash() {
|
||||
private func triggerCameraFlash() {
|
||||
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()
|
||||
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
|
|
@ -1854,8 +1859,8 @@ extension NodeAppModel {
|
|||
}
|
||||
}
|
||||
|
||||
private extension NodeAppModel {
|
||||
func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
|
|
@ -1878,13 +1883,13 @@ private extension NodeAppModel {
|
|||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
func clearGatewayConnectionProblem() {
|
||||
private func clearGatewayConnectionProblem() {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
|
||||
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
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 }
|
||||
return GatewayConnectionProblemMapper.shouldPreserve(
|
||||
previousProblem: lastGatewayProblem,
|
||||
overDisconnectReason: reason)
|
||||
}
|
||||
|
||||
func shouldStartOperatorGatewayLoop(
|
||||
private func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
|
|
@ -1923,12 +1928,12 @@ private extension NodeAppModel {
|
|||
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
|
||||
}
|
||||
|
||||
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
||||
private func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
||||
}
|
||||
|
||||
nonisolated static func shouldStartOperatorGatewayLoop(
|
||||
fileprivate nonisolated static func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
|
|
@ -1949,7 +1954,8 @@ private extension NodeAppModel {
|
|||
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 }
|
||||
let trimmedBootstrapToken = config.bootstrapToken?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
|
@ -1964,7 +1970,7 @@ private extension NodeAppModel {
|
|||
nodeOptions: config.nodeOptions)
|
||||
}
|
||||
|
||||
func currentGatewayReconnectAuth(
|
||||
private func currentGatewayReconnectAuth(
|
||||
fallbackToken: String?,
|
||||
fallbackBootstrapToken: String?,
|
||||
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
|
||||
|
|
@ -1975,7 +1981,7 @@ private extension NodeAppModel {
|
|||
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
|
||||
}
|
||||
|
||||
func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
||||
private func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
||||
// Always drop the in-memory bootstrap token after the first successful
|
||||
// bootstrap connect so reconnect loops cannot reuse a spent token.
|
||||
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
|
||||
|
|
@ -1999,7 +2005,7 @@ private extension NodeAppModel {
|
|||
sessionBox: WebSocketSessionBox?) async
|
||||
{
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
||||
if self.operatorGatewayTask == nil, self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
|
|
@ -2020,7 +2026,7 @@ private extension NodeAppModel {
|
|||
_ = await self.requestNotificationAuthorizationIfNeeded()
|
||||
}
|
||||
|
||||
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
guard self.isBackgrounded else { return }
|
||||
guard !self.backgroundReconnectSuppressed else { return }
|
||||
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)
|
||||
return self.isBackgrounded && self.backgroundReconnectSuppressed
|
||||
}
|
||||
|
||||
func startOperatorGatewayLoop(
|
||||
private func startOperatorGatewayLoop(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
token: String?,
|
||||
|
|
@ -2141,7 +2147,7 @@ private extension NodeAppModel {
|
|||
|
||||
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
|
||||
// swiftlint:disable:next function_body_length
|
||||
func startNodeGatewayLoop(
|
||||
private func startNodeGatewayLoop(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
token: String?,
|
||||
|
|
@ -2216,7 +2222,7 @@ private extension NodeAppModel {
|
|||
let usedBootstrapToken =
|
||||
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
||||
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty == false
|
||||
.isEmpty == false
|
||||
if usedBootstrapToken {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: url,
|
||||
|
|
@ -2230,8 +2236,7 @@ private extension NodeAppModel {
|
|||
(
|
||||
sessionKey: self.mainSessionKey,
|
||||
deliveryChannel: self.shareDeliveryChannel,
|
||||
deliveryTo: self.shareDeliveryTo
|
||||
)
|
||||
deliveryTo: self.shareDeliveryTo)
|
||||
}
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
|
|
@ -2243,8 +2248,7 @@ private extension NodeAppModel {
|
|||
deliveryTo: relayData.deliveryTo))
|
||||
GatewayDiagnostics.log(
|
||||
"gateway connected host=\(url.host ?? "?") "
|
||||
+ "scheme=\(url.scheme ?? "?")"
|
||||
)
|
||||
+ "scheme=\(url.scheme ?? "?")")
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
}
|
||||
|
|
@ -2295,8 +2299,8 @@ private extension NodeAppModel {
|
|||
if Task.isCancelled { break }
|
||||
if !didFallbackClientId,
|
||||
let fallbackClientId = self.legacyClientIdFallback(
|
||||
currentClientId: currentOptions.clientId,
|
||||
error: error)
|
||||
currentClientId: currentOptions.clientId,
|
||||
error: error)
|
||||
{
|
||||
didFallbackClientId = true
|
||||
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 storedOperatorScopes = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
|
|
@ -2379,11 +2383,11 @@ private extension NodeAppModel {
|
|||
storedOperatorScopes: storedOperatorScopes)
|
||||
}
|
||||
|
||||
nonisolated static func shouldRequestOperatorApprovalScope(
|
||||
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]
|
||||
) -> Bool {
|
||||
storedOperatorScopes: [String]) -> Bool
|
||||
{
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedToken.isEmpty {
|
||||
return true
|
||||
|
|
@ -2395,11 +2399,11 @@ private extension NodeAppModel {
|
|||
return storedOperatorScopes.contains("operator.approvals")
|
||||
}
|
||||
|
||||
func makeOperatorConnectOptions(
|
||||
private func makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool
|
||||
) -> GatewayConnectOptions {
|
||||
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||
{
|
||||
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
||||
// Preserve reconnect compatibility for older paired operator tokens that were
|
||||
// approved before iOS requested operator.approvals by default.
|
||||
|
|
@ -2418,7 +2422,7 @@ private extension NodeAppModel {
|
|||
includeDeviceIdentity: true)
|
||||
}
|
||||
|
||||
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard normalizedClientId == "openclaw-ios" else { return nil }
|
||||
let message = error.localizedDescription.lowercased()
|
||||
|
|
@ -2428,7 +2432,7 @@ private extension NodeAppModel {
|
|||
return "moltbot-ios"
|
||||
}
|
||||
|
||||
func isOperatorConnected() async -> Bool {
|
||||
private func isOperatorConnected() async -> Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
}
|
||||
|
|
@ -2568,8 +2572,10 @@ extension NodeAppModel {
|
|||
PendingForegroundNodeActionsResponse.self,
|
||||
from: payload)
|
||||
guard !decoded.actions.isEmpty else { return }
|
||||
// swiftlint:disable:next line_length
|
||||
self.pendingActionLogger.info("Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
|
||||
self.pendingActionLogger
|
||||
.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)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
|
|
@ -2591,8 +2597,10 @@ extension NodeAppModel {
|
|||
command: action.command,
|
||||
paramsJSON: action.paramsJSON)
|
||||
let result = await self.handleInvoke(req)
|
||||
// swiftlint:disable:next line_length
|
||||
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)")
|
||||
self.pendingActionLogger
|
||||
.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 }
|
||||
let acked = await self.ackPendingForegroundNodeAction(
|
||||
id: action.id,
|
||||
|
|
@ -2616,17 +2624,19 @@ extension NodeAppModel {
|
|||
timeoutSeconds: 6)
|
||||
return true
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
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)")
|
||||
self.pendingActionLogger
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
case .deduped(let replyId):
|
||||
case let .deduped(replyId):
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
case let .queue(replyId, actionId):
|
||||
|
|
@ -2638,7 +2648,7 @@ extension NodeAppModel {
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2660,13 +2670,13 @@ extension NodeAppModel {
|
|||
try await self.sendAgentRequest(link: link)
|
||||
let forwardedMessage =
|
||||
"watch reply forwarded replyId=\(event.replyId) "
|
||||
+ "action=\(event.actionId)"
|
||||
+ "action=\(event.actionId)"
|
||||
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
let failedMessage =
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.watchReplyCoordinator.requeueFront(event)
|
||||
}
|
||||
|
|
@ -2811,7 +2821,7 @@ extension NodeAppModel {
|
|||
risk: nil)
|
||||
}
|
||||
|
||||
nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||
private nonisolated static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||
reason: String) -> Bool
|
||||
{
|
||||
reason == "resolve_retry"
|
||||
|
|
@ -2828,8 +2838,10 @@ extension NodeAppModel {
|
|||
self.watchExecApprovalLogger.debug(
|
||||
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.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")
|
||||
}
|
||||
|
|
@ -2850,8 +2862,10 @@ extension NodeAppModel {
|
|||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.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")
|
||||
}
|
||||
|
|
@ -2870,8 +2884,10 @@ extension NodeAppModel {
|
|||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.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)")
|
||||
}
|
||||
|
|
@ -2900,13 +2916,17 @@ extension NodeAppModel {
|
|||
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.debug("watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.debug(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
||||
} catch {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.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)")
|
||||
}
|
||||
|
||||
nonisolated private static func watchExecApprovalIDsNeedingFetch(
|
||||
private nonisolated static func watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: [String],
|
||||
cachedApprovalIDs: [String]) -> [String]
|
||||
{
|
||||
|
|
@ -2972,8 +2992,10 @@ extension NodeAppModel {
|
|||
forApprovalID: approvalId,
|
||||
notificationCenter: self.notificationCenter)
|
||||
case let .failed(message):
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.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)
|
||||
return true
|
||||
case let .failed(message):
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -3084,9 +3108,9 @@ extension NodeAppModel {
|
|||
let pushKind = Self.openclawPushKind(userInfo)
|
||||
let receivedMessage =
|
||||
"Silent push received wakeId=\(wakeId) "
|
||||
+ "kind=\(pushKind) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
+ "kind=\(pushKind) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
|
|
@ -3108,8 +3132,10 @@ extension NodeAppModel {
|
|||
{
|
||||
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
||||
if handled {
|
||||
// swiftlint:disable:next line_length
|
||||
self.execApprovalNotificationLogger.info("Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
||||
self.execApprovalNotificationLogger
|
||||
.info(
|
||||
// swiftlint:disable:next line_length
|
||||
"Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
|
@ -3117,9 +3143,9 @@ extension NodeAppModel {
|
|||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
|
|
@ -3128,16 +3154,16 @@ extension NodeAppModel {
|
|||
let wakeId = Self.makePushWakeAttemptID()
|
||||
let receivedMessage =
|
||||
"Background refresh wake received wakeId=\(wakeId) "
|
||||
+ "trigger=\(trigger) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
+ "trigger=\(trigger) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Background refresh wake outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
|
|
@ -3157,7 +3183,7 @@ extension NodeAppModel {
|
|||
{
|
||||
let throttledMessage =
|
||||
"Location wake throttled wakeId=\(wakeId) "
|
||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
|
@ -3165,15 +3191,15 @@ extension NodeAppModel {
|
|||
|
||||
let beginMessage =
|
||||
"Location wake begin wakeId=\(wakeId) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let triggerMessage =
|
||||
"Location wake trigger wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
||||
|
||||
guard result.applied else { return }
|
||||
|
|
@ -3201,7 +3227,7 @@ extension NodeAppModel {
|
|||
return
|
||||
}
|
||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
|
||||
if !usesRelayTransport, token == self.apnsLastRegisteredTokenHex {
|
||||
return
|
||||
}
|
||||
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
|
|
@ -3330,8 +3356,10 @@ extension NodeAppModel {
|
|||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
|
||||
case let .failed(message):
|
||||
// swiftlint:disable:next line_length
|
||||
self.execApprovalNotificationLogger.error("Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||
self.execApprovalNotificationLogger
|
||||
.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)
|
||||
}
|
||||
|
||||
nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: String,
|
||||
isBackgrounded: Bool) -> Bool
|
||||
{
|
||||
|
|
@ -3387,24 +3415,22 @@ extension NodeAppModel {
|
|||
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
|
||||
{
|
||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fetchReason: String
|
||||
if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
||||
fetchReason = normalizedSourceReason
|
||||
let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
||||
normalizedSourceReason
|
||||
} else {
|
||||
fetchReason = "direct"
|
||||
"direct"
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
|
||||
let connected: Bool
|
||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: fetchReason,
|
||||
isBackgrounded: self.isBackgrounded)
|
||||
{
|
||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12_000,
|
||||
await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12000,
|
||||
reason: fetchReason)
|
||||
} else {
|
||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
||||
}
|
||||
guard connected else {
|
||||
GatewayDiagnostics.log(
|
||||
|
|
@ -3472,8 +3498,8 @@ extension NodeAppModel {
|
|||
|
||||
func handleExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String
|
||||
) async {
|
||||
decision: String) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
|
|
@ -3499,8 +3525,8 @@ extension NodeAppModel {
|
|||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String,
|
||||
sourceReason: String? = nil
|
||||
) async -> ExecApprovalResolutionOutcome {
|
||||
sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -3509,16 +3535,15 @@ extension NodeAppModel {
|
|||
return .failed(message: "Invalid approval request.")
|
||||
}
|
||||
|
||||
let connected: Bool
|
||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: resolutionReason,
|
||||
isBackgrounded: self.isBackgrounded)
|
||||
{
|
||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12_000,
|
||||
await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12000,
|
||||
reason: resolutionReason)
|
||||
} else {
|
||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
||||
}
|
||||
guard connected else {
|
||||
self.execApprovalNotificationLogger.error(
|
||||
|
|
@ -3573,7 +3598,7 @@ extension NodeAppModel {
|
|||
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 }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
|
|
@ -3584,7 +3609,7 @@ extension NodeAppModel {
|
|||
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 }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
|
|
@ -3698,7 +3723,7 @@ extension NodeAppModel {
|
|||
|
||||
GatewayDiagnostics.log(
|
||||
"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)")
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_lease_granted "
|
||||
|
|
@ -3722,7 +3747,7 @@ extension NodeAppModel {
|
|||
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
|
||||
+ "reason=\(reconnectReason)")
|
||||
|
||||
let initialWaitMs = min(2_500, max(750, timeoutMs / 4))
|
||||
let initialWaitMs = min(2500, max(750, timeoutMs / 4))
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_wait "
|
||||
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
||||
|
|
@ -3772,8 +3797,8 @@ extension NodeAppModel {
|
|||
}
|
||||
|
||||
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
||||
wakeId: String
|
||||
) async -> SilentPushWakeAttemptResult {
|
||||
wakeId: String) async -> SilentPushWakeAttemptResult
|
||||
{
|
||||
let startedAt = Date()
|
||||
let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in
|
||||
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||
|
|
@ -3817,8 +3842,7 @@ extension NodeAppModel {
|
|||
let data = try await self.operatorGateway.request(
|
||||
method: "voicewake.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
|
|
@ -3876,8 +3900,8 @@ extension NodeAppModel {
|
|||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
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 {
|
||||
self.screen.errorText = "Deep link too large (message exceeds "
|
||||
|
|
@ -4173,8 +4197,8 @@ extension NodeAppModel {
|
|||
func _test_makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool
|
||||
) -> GatewayConnectOptions {
|
||||
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||
{
|
||||
self.makeOperatorConnectOptions(
|
||||
clientId: clientId,
|
||||
displayName: displayName,
|
||||
|
|
@ -4244,8 +4268,8 @@ extension NodeAppModel {
|
|||
host: String?,
|
||||
nodeId: String?,
|
||||
agentId: String?,
|
||||
expiresAtMs: Int?
|
||||
) -> ExecApprovalPrompt? {
|
||||
expiresAtMs: Int?) -> ExecApprovalPrompt?
|
||||
{
|
||||
self.makeExecApprovalPrompt(
|
||||
from: ExecApprovalGetResponse(
|
||||
id: id,
|
||||
|
|
@ -4282,8 +4306,8 @@ extension NodeAppModel {
|
|||
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]
|
||||
) -> Bool {
|
||||
storedOperatorScopes: [String]) -> Bool
|
||||
{
|
||||
self.shouldRequestOperatorApprovalScope(
|
||||
token: token,
|
||||
password: password,
|
||||
|
|
@ -4291,8 +4315,8 @@ extension NodeAppModel {
|
|||
}
|
||||
|
||||
nonisolated static func _test_clearingBootstrapToken(
|
||||
in config: GatewayConnectConfig?
|
||||
) -> GatewayConnectConfig? {
|
||||
in config: GatewayConnectConfig?) -> GatewayConnectConfig?
|
||||
{
|
||||
self.clearingBootstrapToken(in: config)
|
||||
}
|
||||
|
||||
|
|
@ -4313,7 +4337,6 @@ extension NodeAppModel {
|
|||
clientDisplayName: nil),
|
||||
sessionBox: nil)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ final class MotionService: MotionServicing {
|
|||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
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
|
||||
if let 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) {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ private struct AutoDetectStep: View {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
|
|
@ -229,7 +228,7 @@ private struct ManualEntryStep: View {
|
|||
private func manualPortValue() -> Int? {
|
||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return Int(trimmed.filter { $0.isNumber })
|
||||
return Int(trimmed.filter(\.isNumber))
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
|
|
@ -334,7 +333,6 @@ private func resetGatewayConnectionState(
|
|||
}
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
|
|
@ -373,8 +371,8 @@ private struct ConnectionStatusBox: View {
|
|||
|
||||
static func defaultLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController
|
||||
) -> [String] {
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ enum OnboardingStateStore {
|
|||
|
||||
@MainActor
|
||||
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
|
||||
// should handle reconnecting, and users can always open onboarding manually if needed.
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
||||
|
|
@ -33,28 +33,28 @@ enum OnboardingStateStore {
|
|||
}
|
||||
|
||||
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(true, forKey: self.completedDefaultsKey)
|
||||
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)
|
||||
}
|
||||
|
||||
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
|
||||
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
!defaults.bool(forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import CoreImage
|
||||
import Combine
|
||||
import CoreImage
|
||||
import OpenClawKit
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
|
@ -151,8 +151,7 @@ struct OnboardingWizardView: View {
|
|||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -160,137 +159,136 @@ struct OnboardingWizardView: View {
|
|||
.gatewayTrustPromptAlert()
|
||||
.alert("QR Scanner Unavailable", isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }
|
||||
)) {
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
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.scannerError = "Could not load the selected image."
|
||||
return
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
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) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: currentProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
})
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: currentProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
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
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
guard let parsed = Int(digits), parsed > 0 else {
|
||||
self.manualPort = 0
|
||||
return
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = nil
|
||||
}
|
||||
self.manualPort = min(parsed, 65535)
|
||||
}
|
||||
.onChange(of: self.manualPort) { _, newValue in
|
||||
let normalized = newValue > 0 ? String(newValue) : ""
|
||||
if self.manualPortText != normalized {
|
||||
self.manualPortText = normalized
|
||||
.onChange(of: self.discoveryDomain) { _, _ in
|
||||
self.scheduleDiscoveryRestart()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
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
|
||||
.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 {
|
||||
self.manualPort = 0
|
||||
return
|
||||
}
|
||||
self.manualPort = min(parsed, 65535)
|
||||
}
|
||||
.onChange(of: self.manualPort) { _, newValue in
|
||||
let normalized = newValue > 0 ? String(newValue) : ""
|
||||
if self.manualPortText != normalized {
|
||||
self.manualPortText = normalized
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
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 {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
|
@ -369,7 +367,6 @@ struct OnboardingWizardView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
|
@ -712,7 +709,6 @@ struct OnboardingWizardView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section(title) {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
|
|
@ -868,8 +864,7 @@ struct OnboardingWizardView: View {
|
|||
let detector = CIDetector(
|
||||
ofType: CIDetectorTypeQRCode,
|
||||
context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
|
||||
)
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||
let features = detector?.features(in: ciImage) ?? []
|
||||
for feature in features {
|
||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||
|
|
@ -891,6 +886,7 @@ struct OnboardingWizardView: View {
|
|||
self.connectMessage = nil
|
||||
self.step = target
|
||||
}
|
||||
|
||||
private var canConnectManual: Bool {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
||||
|
|
@ -919,7 +915,7 @@ struct OnboardingWizardView: View {
|
|||
if self.selectedMode == nil {
|
||||
self.selectedMode = OnboardingStateStore.lastMode()
|
||||
}
|
||||
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
|
||||
if self.selectedMode == .developerLocal, self.manualHost == "openclaw.local" {
|
||||
self.manualHost = "localhost"
|
||||
self.manualTLS = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import SwiftUI
|
||||
import BackgroundTasks
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import BackgroundTasks
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
private struct PendingWatchPromptAction {
|
||||
|
|
@ -88,16 +88,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||
self.appModel ?? OpenClawAppModelRegistry.appModel
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
{
|
||||
GatewayDiagnostics.log("app delegate: didFinishLaunching")
|
||||
if self.appModel == nil {
|
||||
|
|
@ -151,7 +150,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||
guard let appModel = self.resolvedAppModel() else {
|
||||
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
|
||||
== ExecApprovalNotificationBridge.requestedKind,
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
{
|
||||
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
|
||||
}
|
||||
|
|
@ -179,8 +178,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||
private func registerBackgroundWakeRefreshTask() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier,
|
||||
using: nil
|
||||
) { [weak self] task in
|
||||
using: nil)
|
||||
{ [weak self] task in
|
||||
guard let refreshTask = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
|
|
@ -196,17 +195,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||
try BGTaskScheduler.shared.submit(request)
|
||||
let scheduledLogMessage =
|
||||
"Scheduled background wake refresh reason=\(reason) "
|
||||
+ "delaySeconds=\(max(60, delay))"
|
||||
+ "delaySeconds=\(max(60, delay))"
|
||||
self.backgroundWakeLogger.info(
|
||||
"\(scheduledLogMessage, privacy: .public)"
|
||||
)
|
||||
"\(scheduledLogMessage, privacy: .public)")
|
||||
} catch {
|
||||
let failedLogMessage =
|
||||
"Failed scheduling background wake refresh reason=\(reason) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.backgroundWakeLogger.error(
|
||||
"\(failedLogMessage, privacy: .public)"
|
||||
)
|
||||
"\(failedLogMessage, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,14 +472,13 @@ enum WatchPromptNotificationBridge {
|
|||
|
||||
private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] {
|
||||
actions.enumerated().map { index, action in
|
||||
let identifier: String
|
||||
switch index {
|
||||
let identifier: String = switch index {
|
||||
case 0:
|
||||
identifier = self.actionPrimaryIdentifier
|
||||
self.actionPrimaryIdentifier
|
||||
case 1:
|
||||
identifier = self.actionSecondaryIdentifier
|
||||
self.actionSecondaryIdentifier
|
||||
default:
|
||||
identifier = "\(self.actionIdentifierPrefix)\(index)"
|
||||
"\(self.actionIdentifierPrefix)\(index)"
|
||||
}
|
||||
return UNNotificationAction(
|
||||
identifier: identifier,
|
||||
|
|
@ -494,12 +490,12 @@ enum WatchPromptNotificationBridge {
|
|||
private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions {
|
||||
switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "destructive":
|
||||
return [.destructive]
|
||||
[.destructive]
|
||||
case "foreground":
|
||||
// For mirrored watch actions, keep handling in background when possible.
|
||||
return []
|
||||
[]
|
||||
default:
|
||||
return []
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -510,7 +506,7 @@ enum WatchPromptNotificationBridge {
|
|||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
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 }
|
||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
||||
|
|
@ -540,8 +536,8 @@ enum WatchPromptNotificationBridge {
|
|||
}
|
||||
|
||||
private static func notificationAuthorizationStatus(
|
||||
center: UNUserNotificationCenter
|
||||
) async -> UNAuthorizationStatus {
|
||||
center: UNUserNotificationCenter) async -> UNAuthorizationStatus
|
||||
{
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
|
|
@ -565,8 +561,8 @@ enum WatchPromptNotificationBridge {
|
|||
|
||||
private static func addNotificationRequest(
|
||||
_ request: UNNotificationRequest,
|
||||
center: UNUserNotificationCenter
|
||||
) async throws {
|
||||
center: UNUserNotificationCenter) async throws
|
||||
{
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
import UserNotifications
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
|
||||
struct ExecApprovalNotificationPrompt: Equatable {
|
||||
let approvalId: String
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +38,7 @@ enum ExecApprovalNotificationBridge {
|
|||
|
||||
static func parsePrompt(
|
||||
actionIdentifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) -> ExecApprovalNotificationPrompt?
|
||||
userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
||||
|| actionIdentifier == self.reviewActionIdentifier
|
||||
|
|
@ -54,8 +53,7 @@ enum ExecApprovalNotificationBridge {
|
|||
@MainActor
|
||||
static func handleResolvedPushIfNeeded(
|
||||
userInfo: [AnyHashable: Any],
|
||||
notificationCenter: NotificationCentering
|
||||
) async -> Bool
|
||||
notificationCenter: NotificationCentering) async -> Bool
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||
let approvalId = self.approvalID(from: userInfo)
|
||||
|
|
@ -70,8 +68,8 @@ enum ExecApprovalNotificationBridge {
|
|||
@MainActor
|
||||
static func removeNotifications(
|
||||
forApprovalID approvalId: String,
|
||||
notificationCenter: NotificationCentering
|
||||
) async {
|
||||
notificationCenter: NotificationCentering) async
|
||||
{
|
||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ actor PushRegistrationManager {
|
|||
}
|
||||
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!installationId.isEmpty
|
||||
!installationId.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ actor PushRegistrationManager {
|
|||
guard let expiresAtMs else { return true }
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ enum PushRelayError: LocalizedError {
|
|||
case .unsupportedAppAttest:
|
||||
"App Attest unavailable on this device"
|
||||
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?
|
||||
}
|
||||
|
||||
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 {
|
||||
var keyId: String
|
||||
var attestationObject: String?
|
||||
|
|
@ -197,25 +170,27 @@ private final class PushRelayAppAttestService {
|
|||
|
||||
private final class PushRelayReceiptProvider {
|
||||
func loadReceiptBase64() async throws -> String {
|
||||
if let receipt = self.readReceiptData() {
|
||||
return receipt.base64EncodedString()
|
||||
do {
|
||||
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? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
||||
guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
|
||||
return data
|
||||
private static func appTransactionBase64(
|
||||
_ result: StoreKit.VerificationResult<AppTransaction>) throws -> String
|
||||
{
|
||||
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 {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
|
|
@ -294,8 +269,7 @@ final class PushRelayClient: @unchecked Sendable {
|
|||
status: status,
|
||||
message: Self.decodeErrorMessage(data: data))
|
||||
}
|
||||
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
return decoded
|
||||
return try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ final class RemindersService: RemindersServicing {
|
|||
let filtered = (items ?? []).filter { reminder in
|
||||
switch statusFilter {
|
||||
case .all:
|
||||
return true
|
||||
true
|
||||
case .completed:
|
||||
return reminder.isCompleted
|
||||
reminder.isCompleted
|
||||
case .incomplete:
|
||||
return !reminder.isCompleted
|
||||
!reminder.isCompleted
|
||||
}
|
||||
}
|
||||
let selected = Array(filtered.prefix(limit))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OpenClawProtocol
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
|
@ -262,7 +262,7 @@ struct RootCanvas: View {
|
|||
eyebrow: "Connected to \(gatewayLabel)",
|
||||
title: "Your agents are ready",
|
||||
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,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||
|
|
@ -276,7 +276,7 @@ struct RootCanvas: View {
|
|||
eyebrow: "Reconnecting",
|
||||
title: "OpenClaw is syncing back up",
|
||||
subtitle:
|
||||
"The gateway session is coming back online. "
|
||||
"The gateway session is coming back online. "
|
||||
+ "Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
|
|
@ -291,7 +291,7 @@ struct RootCanvas: View {
|
|||
eyebrow: "Welcome to OpenClaw",
|
||||
title: "Your phone stays quiet until it is needed",
|
||||
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.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: "Main",
|
||||
|
|
@ -300,7 +300,7 @@ struct RootCanvas: View {
|
|||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -352,7 +352,7 @@ struct RootCanvas: View {
|
|||
let words = self.homeCanvasName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap { $0.first }.map(String.init).joined()
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
|
|
@ -468,8 +468,13 @@ private struct CanvasContent: View {
|
|||
var openSettings: () -> Void
|
||||
var retryGatewayConnection: () -> Void
|
||||
|
||||
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
||||
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
|
||||
private var brightenButtons: Bool {
|
||||
self.systemColorScheme == .light
|
||||
}
|
||||
|
||||
private var talkActive: Bool {
|
||||
self.appModel.talkMode.isEnabled || self.talkEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawKit
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
|
|
@ -194,7 +194,7 @@ final class ScreenController {
|
|||
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
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
|
|
@ -209,7 +209,6 @@ final class ScreenController {
|
|||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
func attachWebView(_ webView: WKWebView) {
|
||||
|
|
|
|||
|
|
@ -319,7 +319,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ protocol MotionServicing: Sendable {
|
|||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||
}
|
||||
|
||||
struct WatchMessagingStatus: Sendable, Equatable {
|
||||
struct WatchMessagingStatus: Equatable {
|
||||
var supported: Bool
|
||||
var paired: Bool
|
||||
var appInstalled: Bool
|
||||
|
|
@ -77,7 +77,7 @@ struct WatchMessagingStatus: Sendable, Equatable {
|
|||
var activationState: String
|
||||
}
|
||||
|
||||
struct WatchQuickReplyEvent: Sendable, Equatable {
|
||||
struct WatchQuickReplyEvent: Equatable {
|
||||
var replyId: String
|
||||
var promptId: String
|
||||
var actionId: String
|
||||
|
|
@ -88,7 +88,7 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
|
|||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
||||
struct WatchExecApprovalResolveEvent: Equatable {
|
||||
var replyId: String
|
||||
var approvalId: String
|
||||
var decision: OpenClawWatchExecApprovalDecision
|
||||
|
|
@ -96,13 +96,13 @@ struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
|||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable {
|
||||
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchNotificationSendResult: Sendable, Equatable {
|
||||
struct WatchNotificationSendResult: Equatable {
|
||||
var deliveredImmediately: Bool
|
||||
var queuedForDelivery: Bool
|
||||
var transport: String
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ struct NotificationSnapshot: @unchecked Sendable {
|
|||
let userInfo: [AnyHashable: Any]
|
||||
}
|
||||
|
||||
enum NotificationAuthorizationStatus: Sendable {
|
||||
enum NotificationAuthorizationStatus {
|
||||
case notDetermined
|
||||
case denied
|
||||
case authorized
|
||||
|
|
|
|||
|
|
@ -21,13 +21,12 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
|
|||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 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(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
|
|
@ -237,7 +236,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
|||
activationState: self.activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
private nonisolated static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ enum WatchMessagingPayloadCodec {
|
|||
"title": params.title,
|
||||
"body": params.body,
|
||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"sentAtMs": nowMs(),
|
||||
"sentAtMs": self.nowMs(),
|
||||
]
|
||||
if let promptId = nonEmpty(params.promptId) {
|
||||
payload["promptId"] = promptId
|
||||
|
|
@ -88,7 +88,7 @@ enum WatchMessagingPayloadCodec {
|
|||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
||||
"approval": encodeExecApprovalItem(message.approval),
|
||||
"approval": self.encodeExecApprovalItem(message.approval),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
|
|
@ -140,7 +140,7 @@ enum WatchMessagingPayloadCodec {
|
|||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||
"approvals": message.approvals.map(encodeExecApprovalItem),
|
||||
"approvals": message.approvals.map(self.encodeExecApprovalItem),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
|
|
@ -161,11 +161,11 @@ enum WatchMessagingPayloadCodec {
|
|||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = nonEmpty(payload["note"] as? String)
|
||||
let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = self.nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
return WatchQuickReplyEvent(
|
||||
|
|
@ -192,7 +192,7 @@ enum WatchMessagingPayloadCodec {
|
|||
else {
|
||||
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
|
||||
return WatchExecApprovalResolveEvent(
|
||||
replyId: replyId,
|
||||
|
|
@ -209,7 +209,7 @@ enum WatchMessagingPayloadCodec {
|
|||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
||||
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
|
||||
return WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: requestId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
|
@ -247,8 +247,7 @@ struct SettingsTab: View {
|
|||
.padding(10)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
|
|
@ -270,15 +269,17 @@ struct SettingsTab: View {
|
|||
self.featureToggle(
|
||||
"Voice Wake",
|
||||
isOn: self.$voiceWakeEnabled,
|
||||
help: "Enables wake-word activation to start a hands-free session.") { newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
help: "Enables wake-word activation to start a hands-free session.")
|
||||
{ newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
self.featureToggle(
|
||||
"Talk Mode",
|
||||
isOn: self.$talkEnabled,
|
||||
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
help: "Enables voice conversation mode with your connected OpenClaw agent.")
|
||||
{ newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).tag(option.id)
|
||||
|
|
@ -301,8 +302,7 @@ struct SettingsTab: View {
|
|||
"Allow Camera",
|
||||
isOn: self.$cameraEnabled,
|
||||
help: "Allows the gateway to request photos or short video clips "
|
||||
+ "while OpenClaw is foregrounded."
|
||||
)
|
||||
+ "while OpenClaw is foregrounded.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Location Access")
|
||||
|
|
@ -313,8 +313,7 @@ struct SettingsTab: View {
|
|||
message: "Controls location permissions for OpenClaw. "
|
||||
+ "Off disables location tools, While Using enables "
|
||||
+ "foreground location, and Always enables "
|
||||
+ "background location."
|
||||
)
|
||||
+ "background location.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
@ -347,8 +346,7 @@ struct SettingsTab: View {
|
|||
? (
|
||||
self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
||||
? "Configured"
|
||||
: "Not configured"
|
||||
)
|
||||
: "Not configured")
|
||||
: "Not loaded")
|
||||
LabeledContent(
|
||||
"Default Model",
|
||||
|
|
@ -365,7 +363,7 @@ struct SettingsTab: View {
|
|||
isOn: self.$talkButtonEnabled,
|
||||
help: "Shows the Talk control in the main toolbar.")
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.lineLimit(2 ... 6)
|
||||
.lineLimit(2...6)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
HStack(spacing: 8) {
|
||||
Text("Default Share Instruction")
|
||||
|
|
@ -376,8 +374,7 @@ struct SettingsTab: View {
|
|||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Default Share Instruction",
|
||||
message: "Appends this instruction when sharing content "
|
||||
+ "into OpenClaw from iOS."
|
||||
)
|
||||
+ "into OpenClaw from iOS.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
@ -441,8 +438,7 @@ struct SettingsTab: View {
|
|||
} message: {
|
||||
Text(
|
||||
"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(
|
||||
|
|
@ -635,8 +631,8 @@ struct SettingsTab: View {
|
|||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
help: String,
|
||||
onChange: ((Bool) -> Void)? = nil
|
||||
) -> some View {
|
||||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Toggle(title, isOn: isOn)
|
||||
Button {
|
||||
|
|
@ -754,8 +750,7 @@ struct SettingsTab: View {
|
|||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
GatewayDiagnostics.log(
|
||||
"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 {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
|
|
@ -858,7 +853,7 @@ struct SettingsTab: View {
|
|||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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 18789
|
||||
|
|
@ -868,7 +863,7 @@ struct SettingsTab: View {
|
|||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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."
|
||||
self.setupStatusText = msg
|
||||
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
||||
|
|
@ -1095,4 +1090,5 @@ struct SettingsTab: View {
|
|||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeWordsSettingsView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ enum StatusActivityBuilder {
|
|||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
cameraHUDText: String?,
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
) -> StatusPill.Activity? {
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?) -> StatusPill.Activity?
|
||||
{
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
|
|
@ -19,9 +19,9 @@ enum StatusActivityBuilder {
|
|||
if let gatewayProblem = appModel.lastGatewayProblem {
|
||||
switch gatewayProblem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return StatusPill.Activity(
|
||||
title: "Approval pending",
|
||||
systemImage: "person.crop.circle.badge.clock",
|
||||
|
|
@ -93,4 +93,3 @@ enum StatusActivityBuilder {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ private struct StatusGlassCardModifier: ViewModifier {
|
|||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.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)
|
||||
}
|
||||
|
|
@ -32,8 +31,6 @@ extension View {
|
|||
StatusGlassCardModifier(
|
||||
brighten: brighten,
|
||||
verticalPadding: verticalPadding,
|
||||
horizontalPadding: horizontalPadding
|
||||
)
|
||||
)
|
||||
horizontalPadding: horizontalPadding))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ struct StatusPill: View {
|
|||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
: 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ enum TalkModeGatewayConfigParser {
|
|||
config: [String: Any],
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int
|
||||
) -> TalkModeGatewayConfigState {
|
||||
defaultSilenceTimeoutMs: Int) -> TalkModeGatewayConfigState
|
||||
{
|
||||
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import AVFAudio
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ final class TalkModeManager: NSObject {
|
|||
|
||||
private var gateway: GatewayNodeSession?
|
||||
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 noiseFloorSamples: [Double] = []
|
||||
private var noiseFloor: Double?
|
||||
|
|
@ -488,16 +488,16 @@ final class TalkModeManager: NSObject {
|
|||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
if self.allowSimulatorCapture {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
if !self.allowSimulatorCapture {
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
}
|
||||
if self.allowSimulatorCapture {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
if !self.allowSimulatorCapture {
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
}
|
||||
#endif
|
||||
|
||||
self.stopRecognition()
|
||||
|
|
@ -550,8 +550,7 @@ final class TalkModeManager: NSObject {
|
|||
let threshold = min(0.35, max(0.12, avg + 0.10))
|
||||
GatewayDiagnostics.log(
|
||||
"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(
|
||||
"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
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
|
|
@ -722,7 +720,7 @@ final class TalkModeManager: NSObject {
|
|||
guard self.isListening, !self.isSpeechOutputActive else { return }
|
||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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 }
|
||||
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
||||
await self.processTranscript(transcript, restartAfter: true)
|
||||
|
|
@ -733,13 +731,13 @@ final class TalkModeManager: NSObject {
|
|||
guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return }
|
||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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 }
|
||||
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
||||
_ = 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) {
|
||||
guard seconds > 0 else { return }
|
||||
let nanos = UInt64(seconds * 1_000_000_000)
|
||||
|
|
@ -1103,7 +1101,10 @@ final class TalkModeManager: NSObject {
|
|||
result = await self.mp3Player.play(stream: rawStream)
|
||||
}
|
||||
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 {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
|
|
@ -1186,9 +1187,9 @@ final class TalkModeManager: NSObject {
|
|||
return !route.outputs.contains { output in
|
||||
switch output.portType {
|
||||
case .builtInSpeaker, .builtInReceiver:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1392,8 +1393,7 @@ final class TalkModeManager: NSObject {
|
|||
|
||||
private func consumeIncrementalPrefetchedAudioIfAvailable(
|
||||
for segment: String,
|
||||
context: IncrementalSpeechContext?
|
||||
) async -> IncrementalPrefetchedAudio?
|
||||
context: IncrementalSpeechContext?) async -> IncrementalPrefetchedAudio?
|
||||
{
|
||||
guard let context else {
|
||||
self.cancelIncrementalPrefetch()
|
||||
|
|
@ -1467,8 +1467,8 @@ final class TalkModeManager: NSObject {
|
|||
guard evt.event == "agent", let payload = evt.payload else { continue }
|
||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: OpenClawAgentEventPayload.self
|
||||
) else {
|
||||
as: OpenClawAgentEventPayload.self)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
|
||||
|
|
@ -1550,8 +1550,7 @@ final class TalkModeManager: NSObject {
|
|||
private func makeIncrementalTTSRequest(
|
||||
text: String,
|
||||
context: IncrementalSpeechContext,
|
||||
outputFormat: String?
|
||||
) -> ElevenLabsTTSRequest
|
||||
outputFormat: String?) -> ElevenLabsTTSRequest
|
||||
{
|
||||
ElevenLabsTTSRequest(
|
||||
text: text,
|
||||
|
|
@ -1579,8 +1578,7 @@ final class TalkModeManager: NSObject {
|
|||
|
||||
private static func monitorStreamFailures(
|
||||
_ stream: AsyncThrowingStream<Data, Error>,
|
||||
failureBox: StreamFailureBox
|
||||
) -> AsyncThrowingStream<Data, Error>
|
||||
failureBox: StreamFailureBox) -> AsyncThrowingStream<Data, Error>
|
||||
{
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
|
|
@ -1622,8 +1620,7 @@ final class TalkModeManager: NSObject {
|
|||
private func speakIncrementalSegment(
|
||||
_ text: String,
|
||||
context preferredContext: IncrementalSpeechContext? = nil,
|
||||
prefetchedAudio: IncrementalPrefetchedAudio? = nil
|
||||
) async
|
||||
prefetchedAudio: IncrementalPrefetchedAudio? = nil) async
|
||||
{
|
||||
let context: IncrementalSpeechContext
|
||||
if let preferredContext {
|
||||
|
|
@ -1651,11 +1648,10 @@ final class TalkModeManager: NSObject {
|
|||
text: text,
|
||||
context: context,
|
||||
outputFormat: context.outputFormat)
|
||||
let rawStream: AsyncThrowingStream<Data, Error>
|
||||
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
rawStream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
let rawStream: AsyncThrowingStream<Data, Error> = if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
} else {
|
||||
rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
}
|
||||
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
|
||||
|
|
@ -1689,7 +1685,6 @@ final class TalkModeManager: NSObject {
|
|||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct IncrementalSpeechBuffer {
|
||||
|
|
@ -1818,7 +1813,7 @@ private struct IncrementalSpeechBuffer {
|
|||
}
|
||||
|
||||
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(
|
||||
method: "talk.config",
|
||||
paramsJSON: "{\"includeSecrets\":true}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
|
|
@ -2060,7 +2054,7 @@ extension TalkModeManager {
|
|||
.allowBluetoothHFP,
|
||||
.defaultToSpeaker,
|
||||
])
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
try? session.setPreferredSampleRate(48000)
|
||||
try? session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setActive(true, options: [])
|
||||
}
|
||||
|
|
@ -2101,19 +2095,19 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
|||
var shouldLog = false
|
||||
var shouldEmitLevel = false
|
||||
var count = 0
|
||||
lock.lock()
|
||||
bufferCount += 1
|
||||
count = bufferCount
|
||||
self.lock.lock()
|
||||
self.bufferCount += 1
|
||||
count = self.bufferCount
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastLoggedAt) >= 1.0 {
|
||||
lastLoggedAt = now
|
||||
if now.timeIntervalSince(self.lastLoggedAt) >= 1.0 {
|
||||
self.lastLoggedAt = now
|
||||
shouldLog = true
|
||||
}
|
||||
if now.timeIntervalSince(lastLevelEmitAt) >= 0.12 {
|
||||
lastLevelEmitAt = now
|
||||
if now.timeIntervalSince(self.lastLevelEmitAt) >= 0.12 {
|
||||
self.lastLevelEmitAt = now
|
||||
shouldEmitLevel = true
|
||||
}
|
||||
lock.unlock()
|
||||
self.lock.unlock()
|
||||
|
||||
let rate = buffer.format.sampleRate
|
||||
let ch = buffer.format.channelCount
|
||||
|
|
@ -2133,12 +2127,12 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
|||
}
|
||||
|
||||
let resolvedRms = rms ?? 0
|
||||
lock.lock()
|
||||
lastRms = resolvedRms
|
||||
if resolvedRms > maxRmsWindow { maxRmsWindow = resolvedRms }
|
||||
let maxRms = maxRmsWindow
|
||||
if shouldLog { maxRmsWindow = 0 }
|
||||
lock.unlock()
|
||||
self.lock.lock()
|
||||
self.lastRms = resolvedRms
|
||||
if resolvedRms > self.maxRmsWindow { self.maxRmsWindow = resolvedRms }
|
||||
let maxRms = self.maxRmsWindow
|
||||
if shouldLog { self.maxRmsWindow = 0 }
|
||||
self.lock.unlock()
|
||||
|
||||
if shouldEmitLevel, let onLevel {
|
||||
onLevel(resolvedRms)
|
||||
|
|
@ -2146,9 +2140,8 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
|||
|
||||
guard shouldLog else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))"
|
||||
)
|
||||
"\(self.label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ enum TalkSpeechLocale {
|
|||
}
|
||||
|
||||
static func supportedOptions(
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
||||
) -> [Option] {
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> [Option]
|
||||
{
|
||||
var seen = Set<String>()
|
||||
let dynamic: [Option] = supportedLocales
|
||||
.compactMap { locale in
|
||||
|
|
@ -33,8 +33,8 @@ enum TalkSpeechLocale {
|
|||
gatewaySelection: String?,
|
||||
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
|
||||
fallbackLocaleID: String = Self.fallbackLocaleID,
|
||||
supportedLocaleIDs: Set<String>
|
||||
) -> String? {
|
||||
supportedLocaleIDs: Set<String>) -> String?
|
||||
{
|
||||
TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
|
||||
preferredLocaleIDs: [
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
|
||||
|
|
@ -48,8 +48,10 @@ enum TalkSpeechLocale {
|
|||
static func makeRecognizer(
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
||||
) -> (recognizer: SFSpeechRecognizer?, localeID: String?) {
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> (
|
||||
recognizer: SFSpeechRecognizer?,
|
||||
localeID: String?)
|
||||
{
|
||||
let supportedIDs = Set(supportedLocales.map(\.identifier))
|
||||
guard let localeID = self.resolvedLocaleID(
|
||||
localSelection: localSelection,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,89 @@
|
|||
Sources/Gateway/GatewayConnectionController.swift
|
||||
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||
Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/KeychainStore.swift
|
||||
Sources/Calendar/CalendarService.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/DeviceStatusService.swift
|
||||
Sources/Device/NetworkStatusService.swift
|
||||
Sources/Chat/ChatSheet.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/OpenClawApp.swift
|
||||
Sources/Device/NodeDisplayName.swift
|
||||
Sources/EventKit/EventKitAuthorization.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/Model/NodeAppModel.swift
|
||||
Sources/Location/SignificantLocationMonitor.swift
|
||||
Sources/Media/PhotoLibraryService.swift
|
||||
Sources/Model/NodeAppModel+Canvas.swift
|
||||
Sources/Model/NodeAppModel+WatchNotifyNormalization.swift
|
||||
Sources/Model/NodeAppModel.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/RootTabs.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenTab.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/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.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/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/VoiceWakeManager.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/ChatMarkdownRenderer.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/TalkDirective.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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
struct WatchReplyDraft: Sendable {
|
||||
struct WatchReplyDraft {
|
||||
var replyId: String
|
||||
var promptId: String
|
||||
var actionId: String
|
||||
|
|
@ -11,7 +11,7 @@ struct WatchReplyDraft: Sendable {
|
|||
var sentAtMs: Int
|
||||
}
|
||||
|
||||
struct WatchReplySendResult: Sendable, Equatable {
|
||||
struct WatchReplySendResult: Equatable {
|
||||
var deliveredImmediately: Bool
|
||||
var queuedForDelivery: Bool
|
||||
var transport: String
|
||||
|
|
@ -61,14 +61,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|||
let payload = Self.encodeSnapshotRequestPayload(request)
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
// swiftlint:disable multiline_arguments
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<
|
||||
Void,
|
||||
Error,
|
||||
>) in
|
||||
session.sendMessage(payload, replyHandler: { _ in
|
||||
continuation.resume(returning: ())
|
||||
}, errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
}
|
||||
// swiftlint:enable multiline_arguments
|
||||
return
|
||||
} catch {
|
||||
// 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 {
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
// swiftlint:disable multiline_arguments
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<
|
||||
Void,
|
||||
Error,
|
||||
>) in
|
||||
session.sendMessage(payload, replyHandler: { _ in
|
||||
continuation.resume(returning: ())
|
||||
}, errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
}
|
||||
// swiftlint:enable multiline_arguments
|
||||
return WatchReplySendResult(
|
||||
deliveredImmediately: true,
|
||||
queuedForDelivery: false,
|
||||
|
|
@ -254,7 +262,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|||
}
|
||||
|
||||
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
|
||||
guard let payload = value.flatMap(Self.normalizeObject) else {
|
||||
guard let payload = value.flatMap(normalizeObject) else {
|
||||
return nil
|
||||
}
|
||||
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,
|
||||
type == WatchPayloadType.execApprovalPrompt.rawValue,
|
||||
let approval = Self.parseExecApprovalItem(payload["approval"])
|
||||
let approval = parseExecApprovalItem(payload["approval"])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Observation
|
|||
import UserNotifications
|
||||
import WatchKit
|
||||
|
||||
enum WatchPayloadType: String, Codable, Sendable, Equatable {
|
||||
enum WatchPayloadType: String, Codable, Equatable {
|
||||
case notify = "watch.notify"
|
||||
case reply = "watch.reply"
|
||||
case execApprovalPrompt = "watch.execApproval.prompt"
|
||||
|
|
@ -14,18 +14,18 @@ enum WatchPayloadType: String, Codable, Sendable, Equatable {
|
|||
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
|
||||
}
|
||||
|
||||
enum WatchRiskLevel: String, Codable, Sendable, Equatable {
|
||||
enum WatchRiskLevel: String, Codable, Equatable {
|
||||
case low
|
||||
case medium
|
||||
case high
|
||||
}
|
||||
|
||||
enum WatchExecApprovalDecision: String, Codable, Sendable, Equatable {
|
||||
enum WatchExecApprovalDecision: String, Codable, Equatable {
|
||||
case allowOnce = "allow-once"
|
||||
case deny
|
||||
}
|
||||
|
||||
enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
|
||||
enum WatchExecApprovalCloseReason: String, Codable, Equatable {
|
||||
case expired
|
||||
case notFound = "not-found"
|
||||
case unavailable
|
||||
|
|
@ -33,7 +33,7 @@ enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
|
|||
case resolved
|
||||
}
|
||||
|
||||
struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
||||
struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var commandText: String
|
||||
var commandPreview: String?
|
||||
|
|
@ -45,51 +45,51 @@ struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
|||
var risk: WatchRiskLevel?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
|
||||
struct WatchExecApprovalPromptMessage: Codable, Equatable {
|
||||
var approval: WatchExecApprovalItem
|
||||
var sentAtMs: Int?
|
||||
var deliveryId: String?
|
||||
var resetResolvingState: Bool?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
|
||||
struct WatchExecApprovalResolvedMessage: Codable, Equatable {
|
||||
var approvalId: String
|
||||
var decision: WatchExecApprovalDecision?
|
||||
var resolvedAtMs: Int?
|
||||
var source: String?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
|
||||
struct WatchExecApprovalExpiredMessage: Codable, Equatable {
|
||||
var approvalId: String
|
||||
var reason: WatchExecApprovalCloseReason
|
||||
var expiredAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
|
||||
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
|
||||
var approvals: [WatchExecApprovalItem]
|
||||
var sentAtMs: Int?
|
||||
var snapshotId: String?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
|
||||
struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
|
||||
struct WatchExecApprovalResolveMessage: Codable, Equatable {
|
||||
var approvalId: String
|
||||
var decision: WatchExecApprovalDecision
|
||||
var replyId: String
|
||||
var sentAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable {
|
||||
struct WatchPromptAction: Codable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
var style: String?
|
||||
}
|
||||
|
||||
struct WatchNotifyMessage: Sendable {
|
||||
struct WatchNotifyMessage {
|
||||
var id: String?
|
||||
var title: String
|
||||
var body: String
|
||||
|
|
@ -103,7 +103,7 @@ struct WatchNotifyMessage: Sendable {
|
|||
var actions: [WatchPromptAction]
|
||||
}
|
||||
|
||||
struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
var approval: WatchExecApprovalItem
|
||||
var transport: String
|
||||
var updatedAt: Date
|
||||
|
|
@ -112,7 +112,9 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||
var statusText: String?
|
||||
var statusAt: Date?
|
||||
|
||||
var id: String { self.approval.id }
|
||||
var id: String {
|
||||
self.approval.id
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Observable final class WatchInboxStore {
|
||||
|
|
@ -333,14 +335,13 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||
|
||||
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
|
||||
self.removeExecApproval(id: message.approvalId)
|
||||
let statusText: String
|
||||
switch message.decision {
|
||||
let statusText = switch message.decision {
|
||||
case .allowOnce:
|
||||
statusText = "Allowed once"
|
||||
"Allowed once"
|
||||
case .deny:
|
||||
statusText = "Denied"
|
||||
"Denied"
|
||||
case nil:
|
||||
statusText = "Approval resolved"
|
||||
"Approval resolved"
|
||||
}
|
||||
self.lastExecApprovalOutcomeText = statusText
|
||||
self.lastExecApprovalOutcomeAt = Date()
|
||||
|
|
@ -349,18 +350,17 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||
|
||||
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
|
||||
self.removeExecApproval(id: message.approvalId)
|
||||
let statusText: String
|
||||
switch message.reason {
|
||||
let statusText = switch message.reason {
|
||||
case .expired:
|
||||
statusText = "Approval expired"
|
||||
"Approval expired"
|
||||
case .notFound:
|
||||
statusText = "Approval no longer available"
|
||||
"Approval no longer available"
|
||||
case .resolved:
|
||||
statusText = "Approval resolved elsewhere"
|
||||
"Approval resolved elsewhere"
|
||||
case .replaced:
|
||||
statusText = "Approval replaced"
|
||||
"Approval replaced"
|
||||
case .unavailable:
|
||||
statusText = "Approval unavailable"
|
||||
"Approval unavailable"
|
||||
}
|
||||
self.lastExecApprovalOutcomeText = statusText
|
||||
self.lastExecApprovalOutcomeAt = Date()
|
||||
|
|
@ -482,7 +482,7 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||
|
||||
private func restorePersistedState() {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
|
@ -555,11 +555,11 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||
private func mapHapticRisk(_ risk: String?) -> WKHapticType {
|
||||
switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "high":
|
||||
return .failure
|
||||
.failure
|
||||
case "medium":
|
||||
return .notification
|
||||
.notification
|
||||
default:
|
||||
return .click
|
||||
.click
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,13 +219,13 @@ private struct WatchExecApprovalDetailView: View {
|
|||
private func riskText(_ risk: WatchRiskLevel?) -> String? {
|
||||
switch risk {
|
||||
case .high:
|
||||
return "High"
|
||||
"High"
|
||||
case .medium:
|
||||
return "Medium"
|
||||
"Medium"
|
||||
case .low:
|
||||
return "Low"
|
||||
"Low"
|
||||
case nil:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,11 +246,11 @@ private struct WatchGenericInboxView: View {
|
|||
private func role(for action: WatchPromptAction) -> ButtonRole? {
|
||||
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "destructive":
|
||||
return .destructive
|
||||
.destructive
|
||||
case "cancel":
|
||||
return .cancel
|
||||
.cancel
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ targets:
|
|||
exit 1
|
||||
fi
|
||||
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
||||
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../../Swabble" \
|
||||
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
||||
- name: SwiftLint
|
||||
basedOnDependencyAnalysis: false
|
||||
|
|
@ -344,6 +345,7 @@ targets:
|
|||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_EMIT_CONST_VALUE_PROTOCOLS: ""
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
|
|
|
|||
|
|
@ -281,9 +281,9 @@ struct OpenClawChatComposer: View {
|
|||
onPasteImageAttachment: { data, fileName, mimeType in
|
||||
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
|
||||
})
|
||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 3)
|
||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 3)
|
||||
#else
|
||||
TextEditor(text: self.$viewModel.input)
|
||||
.font(.system(size: 15))
|
||||
|
|
@ -441,7 +441,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||
var onSend: () -> 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 {
|
||||
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
|
||||
|
|
@ -495,7 +497,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||
var parent: ChatComposerTextView
|
||||
var isProgrammaticUpdate = false
|
||||
|
||||
init(_ parent: ChatComposerTextView) { self.parent = parent }
|
||||
init(_ parent: ChatComposerTextView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !self.isProgrammaticUpdate else { return }
|
||||
|
|
@ -507,7 +511,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
enum ChatComposerTextViewFactory {
|
||||
// Internal for @testable import coverage of composer text view defaults.
|
||||
/// Internal for @testable import coverage of composer text view defaults.
|
||||
@MainActor
|
||||
static func makeConfiguredTextView() -> NSTextView {
|
||||
let textView = ChatComposerNSTextView()
|
||||
|
|
@ -751,7 +755,10 @@ enum ChatComposerPasteSupport {
|
|||
(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 }
|
||||
return preferredType == candidate
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import Foundation
|
||||
|
||||
enum ChatMarkdownPreprocessor {
|
||||
// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
|
||||
// (`INBOUND_META_SENTINELS`), and extend parser expectations in
|
||||
// `ChatMarkdownPreprocessorTests` when sentinels change.
|
||||
/// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
|
||||
/// (`INBOUND_META_SENTINELS`), and extend parser expectations in
|
||||
/// `ChatMarkdownPreprocessorTests` when sentinels change.
|
||||
private static let inboundContextHeaders = [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"Sender (untrusted metadata):",
|
||||
|
|
@ -152,11 +152,13 @@ enum ChatMarkdownPreprocessor {
|
|||
for index in lines.indices {
|
||||
let currentLine = lines[index]
|
||||
|
||||
if !inMetaBlock && self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
|
||||
if !inMetaBlock, self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
|
||||
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
|
||||
if nextLine?.trimmingCharacters(in: .whitespacesAndNewlines) != "```json" {
|
||||
outputLines.append(currentLine)
|
||||
|
|
@ -168,7 +170,7 @@ enum ChatMarkdownPreprocessor {
|
|||
}
|
||||
|
||||
if inMetaBlock {
|
||||
if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
|
||||
if !inFencedJson, currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
|
||||
inFencedJson = true
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ private struct InlineImageList: View {
|
|||
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||
|
||||
var body: some View {
|
||||
ForEach(images, id: \.id) { item in
|
||||
ForEach(self.images, id: \.id) { item in
|
||||
if let img = item.image {
|
||||
OpenClawPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
private enum ChatUIConstants {
|
||||
|
|
@ -70,7 +70,12 @@ private struct ChatBubbleShape: InsettableShape {
|
|||
to: baseBottom,
|
||||
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))
|
||||
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.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
||||
|
|
@ -102,7 +107,12 @@ private struct ChatBubbleShape: InsettableShape {
|
|||
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
||||
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
||||
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.addCurve(
|
||||
to: tip,
|
||||
|
|
@ -158,7 +168,9 @@ struct ChatMessageBubble: View {
|
|||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
private var isUser: Bool { self.message.role.lowercased() == "user" }
|
||||
private var isUser: Bool {
|
||||
self.message.role.lowercased() == "user"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -498,8 +510,8 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func assistantBubbleContainerStyle() -> some View {
|
||||
extension View {
|
||||
fileprivate func assistantBubbleContainerStyle() -> some View {
|
||||
self
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// 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 var id: String { "\(self.runId)-\(self.seq ?? -1)" }
|
||||
public var id: String {
|
||||
"\(self.runId)-\(self.seq ?? -1)"
|
||||
}
|
||||
|
||||
public let runId: String
|
||||
public let seq: Int?
|
||||
public let stream: String
|
||||
|
|
@ -279,7 +282,10 @@ public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
|
|||
}
|
||||
|
||||
public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
|
||||
public var id: String { self.toolCallId }
|
||||
public var id: String {
|
||||
self.toolCallId
|
||||
}
|
||||
|
||||
public let toolCallId: String
|
||||
public let name: String
|
||||
public let args: AnyCodable?
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum ChatPayloadDecoding {
|
||||
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import Foundation
|
||||
|
||||
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 name: String
|
||||
|
|
@ -44,7 +46,9 @@ public struct OpenClawChatSessionsDefaults: Codable, Sendable {
|
|||
}
|
||||
|
||||
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 kind: String?
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ enum OpenClawChatTheme {
|
|||
#endif
|
||||
}
|
||||
|
||||
static var userText: Color { .white }
|
||||
static var userText: Color {
|
||||
.white
|
||||
}
|
||||
|
||||
static var assistantText: Color {
|
||||
#if os(macOS)
|
||||
|
|
|
|||
|
|
@ -86,8 +86,6 @@ public struct OpenClawChatView: View {
|
|||
.sheet(isPresented: self.$showSessions) {
|
||||
if self.showsSessionSwitcher {
|
||||
ChatSessionsSheet(viewModel: self.viewModel)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,11 +97,11 @@ public struct OpenClawChatView: View {
|
|||
self.messageListRows
|
||||
|
||||
Color.clear
|
||||
#if os(macOS)
|
||||
#if os(macOS)
|
||||
.frame(height: Layout.messageListPaddingBottom)
|
||||
#else
|
||||
#else
|
||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||
#endif
|
||||
#endif
|
||||
.id(self.scrollerBottomID)
|
||||
}
|
||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||
|
|
@ -115,11 +113,11 @@ public struct OpenClawChatView: View {
|
|||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
// Keep the scroll pinned to the bottom for new messages.
|
||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||
.onChange(of: self.scrollPosition) { _, position in
|
||||
guard let position else { return }
|
||||
self.isPinnedToBottom = position == self.scrollerBottomID
|
||||
}
|
||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||
.onChange(of: self.scrollPosition) { _, position in
|
||||
guard let position else { return }
|
||||
self.isPinnedToBottom = position == self.scrollerBottomID
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading {
|
||||
ProgressView()
|
||||
|
|
@ -158,7 +156,8 @@ public struct OpenClawChatView: View {
|
|||
guard self.hasPerformedInitialScroll else { return }
|
||||
if let lastMessage = self.viewModel.messages.last,
|
||||
lastMessage.role.lowercased() == "user",
|
||||
lastMessage.id != self.lastUserMessageID {
|
||||
lastMessage.id != self.lastUserMessageID
|
||||
{
|
||||
self.lastUserMessageID = lastMessage.id
|
||||
self.isPinnedToBottom = true
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import OpenClawKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
|
|||
|
||||
@MainActor
|
||||
@Observable
|
||||
// swiftlint:disable:next type_body_length
|
||||
public final class OpenClawChatViewModel {
|
||||
public static let defaultModelSelectionID = "__default__"
|
||||
|
||||
|
|
@ -659,8 +660,8 @@ public final class OpenClawChatViewModel {
|
|||
self.errorText = "Unable to compact the session. Please try again."
|
||||
let nsError = error as NSError
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -733,7 +734,10 @@ public final class OpenClawChatViewModel {
|
|||
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
|
||||
}
|
||||
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 }
|
||||
self.modelSelectionID = previous
|
||||
|
|
@ -856,7 +860,8 @@ public final class OpenClawChatViewModel {
|
|||
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 {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import Foundation
|
||||
|
||||
public extension AnyCodable {
|
||||
var stringValue: String? {
|
||||
extension AnyCodable {
|
||||
public var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
public var boolValue: Bool? {
|
||||
if let value = self.value as? Bool {
|
||||
return value
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ public extension AnyCodable {
|
|||
return nil
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
public var intValue: Int? {
|
||||
if let value = self.value as? Int {
|
||||
return value
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public extension AnyCodable {
|
|||
return nil
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
public var doubleValue: Double? {
|
||||
if let value = self.value as? Double {
|
||||
return value
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ public extension AnyCodable {
|
|||
return nil
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
public var dictionaryValue: [String: AnyCodable]? {
|
||||
if let value = self.value as? [String: AnyCodable] {
|
||||
return value
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ public extension AnyCodable {
|
|||
return nil
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
public var arrayValue: [AnyCodable]? {
|
||||
if let value = self.value as? [AnyCodable] {
|
||||
return value
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ public extension AnyCodable {
|
|||
return nil
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
public var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: AnyCodable]:
|
||||
dict.mapValues(\.foundationValue)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import OpenClawProtocol
|
||||
|
||||
public typealias AnyCodable = OpenClawProtocol.AnyCodable
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ public enum OpenClawBonjour {
|
|||
private static func resolveWideAreaDomain(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let normalized = normalizeServiceDomain(trimmed)
|
||||
return normalized == gatewayServiceDomain ? nil : normalized
|
||||
let normalized = self.normalizeServiceDomain(trimmed)
|
||||
return normalized == self.gatewayServiceDomain ? nil : normalized
|
||||
}
|
||||
|
||||
public static func normalizeServiceDomain(_ raw: String?) -> String {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import Foundation
|
|||
public enum CaptureRateLimits {
|
||||
public static func clampDurationMs(
|
||||
_ ms: Int?,
|
||||
defaultMs: Int = 10_000,
|
||||
defaultMs: Int = 10000,
|
||||
minMs: Int = 250,
|
||||
maxMs: Int = 60_000) -> Int
|
||||
maxMs: Int = 60000) -> Int
|
||||
{
|
||||
let value = ms ?? defaultMs
|
||||
return min(maxMs, max(minMs, value))
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|||
|
||||
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
|
||||
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 urlString = json["url"] as? String,
|
||||
let parsed = URLComponents(string: urlString),
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ public enum GatewayDeviceAuthPayload {
|
|||
{
|
||||
let scopeString = scopes.joined(separator: ",")
|
||||
let authToken = token ?? ""
|
||||
let normalizedPlatform = normalizeMetadataField(platform)
|
||||
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
|
||||
let normalizedPlatform = self.normalizeMetadataField(platform)
|
||||
let normalizedDeviceFamily = self.normalizeMetadataField(deviceFamily)
|
||||
return [
|
||||
"v3",
|
||||
deviceId,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public enum DeviceAuthStore {
|
|||
|
||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||
let role = normalizeRole(role)
|
||||
let role = self.normalizeRole(role)
|
||||
return store.tokens[role]
|
||||
}
|
||||
|
||||
|
|
@ -33,10 +33,10 @@ public enum DeviceAuthStore {
|
|||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String] = []
|
||||
) -> DeviceAuthEntry {
|
||||
let normalizedRole = normalizeRole(role)
|
||||
var next = readStore()
|
||||
scopes: [String] = []) -> DeviceAuthEntry
|
||||
{
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
var next = self.readStore()
|
||||
if next?.deviceId != deviceId {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
|
|
@ -44,24 +44,23 @@ public enum DeviceAuthStore {
|
|||
token: token,
|
||||
role: normalizedRole,
|
||||
scopes: normalizeScopes(scopes),
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
writeStore(store)
|
||||
self.writeStore(store)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
public static func clearToken(deviceId: String, role: String) {
|
||||
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 }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
writeStore(store)
|
||||
self.writeStore(store)
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
|
|
@ -78,11 +77,11 @@ public enum DeviceAuthStore {
|
|||
private static func fileURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readStore() -> DeviceAuthStoreFile? {
|
||||
let url = fileURL()
|
||||
let url = self.fileURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||
return nil
|
||||
|
|
@ -92,7 +91,7 @@ public enum DeviceAuthStore {
|
|||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = fileURL()
|
||||
let url = self.fileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ public enum DeviceIdentityStore {
|
|||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||
!decoded.deviceId.isEmpty,
|
||||
!decoded.publicKey.isEmpty,
|
||||
!decoded.privateKey.isEmpty {
|
||||
!decoded.privateKey.isEmpty
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
let identity = self.generate()
|
||||
|
|
@ -107,6 +108,6 @@ public enum DeviceIdentityStore {
|
|||
let base = DeviceIdentityPaths.stateDirURL()
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
public protocol WebSocketTasking: AnyObject {
|
||||
|
|
@ -20,9 +20,13 @@ public struct WebSocketTaskBox: @unchecked Sendable {
|
|||
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?) {
|
||||
self.task.cancel(with: closeCode, reason: reason)
|
||||
|
|
@ -81,9 +85,9 @@ public struct GatewayConnectOptions: Sendable {
|
|||
public var clientId: String
|
||||
public var clientMode: String
|
||||
public var clientDisplayName: String?
|
||||
// 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
|
||||
// role/scoped sessions such as operator UI clients.
|
||||
/// 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
|
||||
/// role/scoped sessions such as operator UI clients.
|
||||
public var includeDeviceIdentity: Bool
|
||||
|
||||
public init(
|
||||
|
|
@ -113,11 +117,11 @@ public enum GatewayAuthSource: String, Sendable {
|
|||
case deviceToken = "device-token"
|
||||
case sharedToken = "shared-token"
|
||||
case bootstrapToken = "bootstrap-token"
|
||||
case password = "password"
|
||||
case none = "none"
|
||||
case password
|
||||
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 enum ConnectChallengeError: Error {
|
||||
|
|
@ -132,13 +136,13 @@ private let defaultOperatorConnectScopes: [String] = [
|
|||
"operator.pairing",
|
||||
]
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
extension String {
|
||||
fileprivate var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectedConnectAuth: Sendable {
|
||||
private struct SelectedConnectAuth {
|
||||
let authToken: String?
|
||||
let authBootstrapToken: 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 {
|
||||
self.shouldReconnect = false
|
||||
|
|
@ -277,8 +283,7 @@ public actor GatewayChannelActor {
|
|||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||
self.reconnectPausedForAuthFailure = true
|
||||
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
|
||||
}
|
||||
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
||||
|
|
@ -312,11 +317,10 @@ public actor GatewayChannelActor {
|
|||
},
|
||||
operation: { try await self.sendConnect() })
|
||||
} catch {
|
||||
let wrapped: Error
|
||||
if let authError = error as? GatewayConnectAuthError {
|
||||
wrapped = authError
|
||||
let wrapped: Error = if let authError = error as? GatewayConnectAuthError {
|
||||
authError
|
||||
} 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.task?.cancel(with: .goingAway, reason: nil)
|
||||
|
|
@ -422,7 +426,7 @@ public actor GatewayChannelActor {
|
|||
role: role,
|
||||
includeDeviceIdentity: includeDeviceIdentity,
|
||||
deviceId: identity?.deviceId)
|
||||
if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry {
|
||||
if selectedAuth.authDeviceToken != nil, self.pendingDeviceTokenRetry {
|
||||
self.pendingDeviceTokenRetry = false
|
||||
}
|
||||
self.lastAuthSource = selectedAuth.authSource
|
||||
|
|
@ -485,8 +489,8 @@ public actor GatewayChannelActor {
|
|||
self.deviceTokenRetryBudgetUsed = true
|
||||
self.backoffMs = min(self.backoffMs, 250)
|
||||
} else if selectedAuth.authDeviceToken != nil,
|
||||
let identity,
|
||||
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
||||
let identity,
|
||||
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
||||
{
|
||||
// Retry failed with an explicit device-token mismatch; clear stale local token.
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
|
|
@ -498,39 +502,38 @@ public actor GatewayChannelActor {
|
|||
private func selectConnectAuth(
|
||||
role: String,
|
||||
includeDeviceIdentity: Bool,
|
||||
deviceId: String?
|
||||
) -> SelectedConnectAuth {
|
||||
deviceId: String?) -> SelectedConnectAuth
|
||||
{
|
||||
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
let explicitBootstrapToken =
|
||||
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
let storedToken =
|
||||
(includeDeviceIdentity && deviceId != nil)
|
||||
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
|
||||
: nil
|
||||
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
|
||||
: nil
|
||||
let shouldUseDeviceRetryToken =
|
||||
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
||||
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||
let authToken =
|
||||
explicitToken ??
|
||||
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
||||
// silently reusing an older stored device token.
|
||||
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
||||
? storedToken
|
||||
: nil)
|
||||
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
||||
// silently reusing an older stored device token.
|
||||
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
||||
? storedToken
|
||||
: nil)
|
||||
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||
let authSource: GatewayAuthSource
|
||||
if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
|
||||
authSource = .deviceToken
|
||||
let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
|
||||
.deviceToken
|
||||
} else if authToken != nil {
|
||||
authSource = .sharedToken
|
||||
.sharedToken
|
||||
} else if authBootstrapToken != nil {
|
||||
authSource = .bootstrapToken
|
||||
.bootstrapToken
|
||||
} else if explicitPassword != nil {
|
||||
authSource = .password
|
||||
.password
|
||||
} else {
|
||||
authSource = .none
|
||||
.none
|
||||
}
|
||||
return SelectedConnectAuth(
|
||||
authToken: authToken,
|
||||
|
|
@ -560,7 +563,7 @@ public actor GatewayChannelActor {
|
|||
case "node":
|
||||
return []
|
||||
case "operator":
|
||||
let allowedOperatorScopes: Set<String> = [
|
||||
let allowedOperatorScopes: Set = [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
|
|
@ -576,8 +579,8 @@ public actor GatewayChannelActor {
|
|||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String]
|
||||
) {
|
||||
scopes: [String])
|
||||
{
|
||||
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
||||
return
|
||||
}
|
||||
|
|
@ -593,8 +596,8 @@ public actor GatewayChannelActor {
|
|||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String]
|
||||
) {
|
||||
scopes: [String])
|
||||
{
|
||||
if authSource == .bootstrapToken {
|
||||
guard self.shouldPersistBootstrapHandoffTokens() else {
|
||||
return
|
||||
|
|
@ -616,8 +619,8 @@ public actor GatewayChannelActor {
|
|||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity?,
|
||||
role: String
|
||||
) async throws {
|
||||
role: String) async throws
|
||||
{
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
||||
|
|
@ -809,12 +812,11 @@ public actor GatewayChannelActor {
|
|||
}
|
||||
|
||||
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
||||
let data: Data? = switch msg {
|
||||
return switch msg {
|
||||
case let .data(data): data
|
||||
case let .string(text): text.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func watchTicks() async {
|
||||
|
|
@ -853,8 +855,7 @@ public actor GatewayChannelActor {
|
|||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||
self.reconnectPausedForAuthFailure = true
|
||||
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
|
||||
}
|
||||
let wrapped = self.wrap(error, context: "gateway reconnect")
|
||||
|
|
@ -867,8 +868,8 @@ public actor GatewayChannelActor {
|
|||
error: Error,
|
||||
explicitGatewayToken: String?,
|
||||
storedToken: String?,
|
||||
attemptedDeviceTokenRetry: Bool
|
||||
) -> Bool {
|
||||
attemptedDeviceTokenRetry: Bool) -> Bool
|
||||
{
|
||||
if self.deviceTokenRetryBudgetUsed {
|
||||
return false
|
||||
}
|
||||
|
|
@ -895,8 +896,8 @@ public actor GatewayChannelActor {
|
|||
if authError.isNonRecoverable {
|
||||
return true
|
||||
}
|
||||
if authError.detail == .authTokenMismatch &&
|
||||
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
|
||||
if authError.detail == .authTokenMismatch,
|
||||
self.deviceTokenRetryBudgetUsed, !self.pendingDeviceTokenRetry
|
||||
{
|
||||
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 {
|
||||
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
|
||||
return error
|
||||
|
|
@ -1055,8 +1056,7 @@ public actor GatewayChannelActor {
|
|||
return (id: id, data: data)
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ public enum GatewayConnectChallengeSupport {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
public static func waitForNonce<E: Error>(
|
||||
public static func waitForNonce(
|
||||
timeoutSeconds: Double,
|
||||
onTimeout: @escaping @Sendable () -> E,
|
||||
onTimeout: @escaping @Sendable () -> some Error,
|
||||
receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(
|
||||
|
|
|
|||
|
|
@ -81,32 +81,34 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
|
|||
|
||||
public var needsPairingApproval: Bool {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
||||
return true
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
public var needsCredentialUpdate: Bool {
|
||||
switch self.kind {
|
||||
case .gatewayAuthTokenMissing,
|
||||
.gatewayAuthTokenMismatch,
|
||||
.gatewayAuthTokenNotConfigured,
|
||||
.gatewayAuthPasswordMissing,
|
||||
.gatewayAuthPasswordMismatch,
|
||||
.gatewayAuthPasswordNotConfigured,
|
||||
.bootstrapTokenInvalid,
|
||||
.deviceTokenMismatch:
|
||||
return true
|
||||
.gatewayAuthTokenMismatch,
|
||||
.gatewayAuthTokenNotConfigured,
|
||||
.gatewayAuthPasswordMissing,
|
||||
.gatewayAuthPasswordMismatch,
|
||||
.gatewayAuthPasswordNotConfigured,
|
||||
.bootstrapTokenInvalid,
|
||||
.deviceTokenMismatch:
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
public var statusText: String {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
if let requestId {
|
||||
return "\(self.title) (request ID: \(requestId))"
|
||||
}
|
||||
|
|
@ -123,7 +125,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
|
|||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -136,14 +141,20 @@ public enum GatewayConnectionProblemMapper {
|
|||
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 {
|
||||
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
||||
}
|
||||
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()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
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.",
|
||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||
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,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -187,9 +200,12 @@ public enum GatewayConnectionProblemMapper {
|
|||
title: authError.titleOverride ?? "Gateway token is out of date",
|
||||
message: authError.userMessageOverride
|
||||
?? "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,
|
||||
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,
|
||||
retryable: authError.retryableOverride ?? 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.",
|
||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||
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,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -217,7 +235,9 @@ public enum GatewayConnectionProblemMapper {
|
|||
?? "This gateway requires a password, but this iPhone did not send one.",
|
||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||
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,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -231,7 +251,9 @@ public enum GatewayConnectionProblemMapper {
|
|||
?? "The saved password on this iPhone does not match the gateway password.",
|
||||
actionLabel: authError.actionLabel ?? "Update password",
|
||||
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,
|
||||
retryable: false,
|
||||
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.",
|
||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||
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,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -286,7 +310,8 @@ public enum GatewayConnectionProblemMapper {
|
|||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure device identity is required",
|
||||
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",
|
||||
actionCommand: authError.actionCommand,
|
||||
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.",
|
||||
actionLabel: authError.actionLabel ?? "Check iPhone time",
|
||||
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,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -316,7 +343,9 @@ public enum GatewayConnectionProblemMapper {
|
|||
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
|
||||
actionLabel: authError.actionLabel ?? "Retry",
|
||||
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,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -329,7 +358,9 @@ public enum GatewayConnectionProblemMapper {
|
|||
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
|
||||
actionLabel: authError.actionLabel ?? "Retry",
|
||||
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,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -441,7 +472,9 @@ public enum GatewayConnectionProblemMapper {
|
|||
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
|
||||
actionLabel: authError.actionLabel ?? "Wait and retry",
|
||||
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,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
|
|
@ -520,7 +553,8 @@ public enum GatewayConnectionProblemMapper {
|
|||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
|
||||
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost,
|
||||
.internationalRoamingOff, .callIsActive, .dataNotAllowed:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .reachabilityFailed,
|
||||
owner: .network,
|
||||
|
|
@ -575,7 +609,9 @@ public enum GatewayConnectionProblemMapper {
|
|||
pauseReconnect: false,
|
||||
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(
|
||||
kind: .reachabilityFailed,
|
||||
owner: .network,
|
||||
|
|
@ -615,7 +651,8 @@ public enum GatewayConnectionProblemMapper {
|
|||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Additional approval required",
|
||||
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",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
|
|
@ -643,7 +680,8 @@ public enum GatewayConnectionProblemMapper {
|
|||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Device approval needs refresh",
|
||||
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",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
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? {
|
||||
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "gateway":
|
||||
return .gateway
|
||||
.gateway
|
||||
case "iphone", "ios", "device":
|
||||
return .iphone
|
||||
.iphone
|
||||
case "both":
|
||||
return .both
|
||||
.both
|
||||
case "network":
|
||||
return .network
|
||||
.network
|
||||
case "unknown", "":
|
||||
return .unknown
|
||||
.unknown
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,4 +36,3 @@ public enum GatewayDiscoveryStatusText {
|
|||
return "Searching…"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
public enum GatewayConnectAuthDetailCode: String, Sendable {
|
||||
case authRequired = "AUTH_REQUIRED"
|
||||
|
|
@ -129,9 +129,13 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
|||
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? {
|
||||
guard let detailCodeRaw else { return nil }
|
||||
|
|
@ -143,23 +147,25 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
|||
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
|
||||
}
|
||||
|
||||
public var errorDescription: String? { self.message }
|
||||
public var errorDescription: String? {
|
||||
self.message
|
||||
}
|
||||
|
||||
public var isNonRecoverable: Bool {
|
||||
switch self.detail {
|
||||
case .authTokenMissing,
|
||||
.authBootstrapTokenInvalid,
|
||||
.authTokenNotConfigured,
|
||||
.authPasswordMissing,
|
||||
.authPasswordMismatch,
|
||||
.authPasswordNotConfigured,
|
||||
.authRateLimited,
|
||||
.pairingRequired,
|
||||
.controlUiDeviceIdentityRequired,
|
||||
.deviceIdentityRequired:
|
||||
return true
|
||||
.authBootstrapTokenInvalid,
|
||||
.authTokenNotConfigured,
|
||||
.authPasswordMissing,
|
||||
.authPasswordMismatch,
|
||||
.authPasswordNotConfigured,
|
||||
.authRateLimited,
|
||||
.pairingRequired,
|
||||
.controlUiDeviceIdentityRequired,
|
||||
.deviceIdentityRequired:
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -203,5 +209,7 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
|
|||
self.message = message
|
||||
}
|
||||
|
||||
public var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||
public var errorDescription: String? {
|
||||
"\(self.method): \(self.message)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
private struct NodeInvokeRequestPayload: Codable {
|
||||
var id: String
|
||||
var nodeId: String
|
||||
var command: String
|
||||
|
|
@ -19,7 +19,7 @@ private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capabilit
|
|||
let nextSlash = suffix.firstIndex(of: "/")
|
||||
let nextQuery = 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 }
|
||||
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
|
||||
}
|
||||
|
|
@ -55,12 +55,11 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
|
|||
return parsed.string ?? trimmed
|
||||
}
|
||||
|
||||
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private static let defaultInvokeTimeoutMs = 30_000
|
||||
private static let defaultInvokeTimeoutMs = 30000
|
||||
private var channel: GatewayChannelActor?
|
||||
private var activeURL: URL?
|
||||
private var activeToken: String?
|
||||
|
|
@ -79,8 +78,8 @@ public actor GatewayNodeSession {
|
|||
static func invokeWithTimeout(
|
||||
request: BridgeInvokeRequest,
|
||||
timeoutMs: Int?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async -> BridgeInvokeResponse {
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async -> BridgeInvokeResponse
|
||||
{
|
||||
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
let timeout: Int = {
|
||||
if let timeoutMs { return max(0, timeoutMs) }
|
||||
|
|
@ -144,13 +143,14 @@ public actor GatewayNodeSession {
|
|||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
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
|
||||
}
|
||||
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
|
|
@ -201,8 +201,8 @@ public actor GatewayNodeSession {
|
|||
sessionBox: WebSocketSessionBox?,
|
||||
onConnected: @escaping @Sendable () async -> Void,
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async throws {
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||
let shouldReconnect = self.activeURL != url ||
|
||||
self.activeToken != token ||
|
||||
|
|
@ -273,7 +273,7 @@ public actor GatewayNodeSession {
|
|||
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 }
|
||||
do {
|
||||
let data = try await channel.request(
|
||||
|
|
@ -455,8 +455,7 @@ public actor GatewayNodeSession {
|
|||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
onInvoke: onInvoke)
|
||||
self.logger.info(
|
||||
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
public enum GatewayPayloadDecoding {
|
||||
public static func decode<T: Decodable>(
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ public enum GatewayTLSStore {
|
|||
!existing.isEmpty
|
||||
else { return }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -108,8 +109,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
|
||||
{
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
|
|
@ -117,7 +118,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||
return
|
||||
}
|
||||
|
||||
let expected = params.expectedFingerprint.map(normalizeFingerprint)
|
||||
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
|
||||
if let fingerprint = certificateFingerprint(trust) {
|
||||
if let expected {
|
||||
if fingerprint == expected {
|
||||
|
|
@ -127,7 +128,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||
}
|
||||
return
|
||||
}
|
||||
if params.allowTOFU {
|
||||
if self.params.allowTOFU {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
|
|
@ -137,7 +138,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||
}
|
||||
|
||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||
if ok || !params.required {
|
||||
if ok || !self.params.required {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ public enum GenericPasswordKeychainStore {
|
|||
_ value: String,
|
||||
service: String,
|
||||
account: String,
|
||||
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
) -> Bool {
|
||||
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) -> Bool
|
||||
{
|
||||
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +40,8 @@ public enum GenericPasswordKeychainStore {
|
|||
_ data: Data,
|
||||
service: String,
|
||||
account: String,
|
||||
accessible: CFString
|
||||
) -> Bool {
|
||||
accessible: CFString) -> Bool
|
||||
{
|
||||
let query = self.baseQuery(service: service, account: account)
|
||||
let previousData = self.loadData(service: service, account: account)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ public enum InstanceIdentity {
|
|||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
#if canImport(UIKit)
|
||||
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
|
||||
if Thread.isMainThread {
|
||||
return MainActor.assumeIsolated { body() }
|
||||
|
|
@ -21,7 +21,7 @@ public enum InstanceIdentity {
|
|||
MainActor.assumeIsolated { body() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
public static let instanceId: String = {
|
||||
let defaults = Self.defaults
|
||||
|
|
@ -38,23 +38,23 @@ public enum InstanceIdentity {
|
|||
}()
|
||||
|
||||
public static let displayName: String = {
|
||||
#if canImport(UIKit)
|
||||
#if canImport(UIKit)
|
||||
let name = Self.readMainActor {
|
||||
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return name.isEmpty ? "openclaw" : name
|
||||
#else
|
||||
#else
|
||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty
|
||||
{
|
||||
return name
|
||||
}
|
||||
return "openclaw"
|
||||
#endif
|
||||
#endif
|
||||
}()
|
||||
|
||||
public static let modelIdentifier: String? = {
|
||||
#if canImport(UIKit)
|
||||
#if canImport(UIKit)
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||
|
|
@ -62,7 +62,7 @@ public enum InstanceIdentity {
|
|||
}
|
||||
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
#else
|
||||
#else
|
||||
var size = 0
|
||||
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 }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
#endif
|
||||
#endif
|
||||
}()
|
||||
|
||||
public static let deviceFamily: String = {
|
||||
#if canImport(UIKit)
|
||||
#if canImport(UIKit)
|
||||
return Self.readMainActor {
|
||||
switch UIDevice.current.userInterfaceIdiom {
|
||||
case .pad: return "iPad"
|
||||
case .phone: return "iPhone"
|
||||
default: return "iOS"
|
||||
case .pad: "iPad"
|
||||
case .phone: "iPhone"
|
||||
default: "iOS"
|
||||
}
|
||||
}
|
||||
#else
|
||||
#else
|
||||
return "Mac"
|
||||
#endif
|
||||
#endif
|
||||
}()
|
||||
|
||||
public static let platformString: String = {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
#if canImport(UIKit)
|
||||
#if canImport(UIKit)
|
||||
let name = Self.readMainActor {
|
||||
switch UIDevice.current.userInterfaceIdiom {
|
||||
case .pad: return "iPadOS"
|
||||
case .phone: return "iOS"
|
||||
default: return "iOS"
|
||||
case .pad: "iPadOS"
|
||||
case .phone: "iOS"
|
||||
default: "iOS"
|
||||
}
|
||||
}
|
||||
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
#else
|
||||
#else
|
||||
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
#endif
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import Foundation
|
|||
public enum LocationCurrentRequest {
|
||||
public typealias TimeoutRunner = @Sendable (
|
||||
_ timeoutMs: Int,
|
||||
_ operation: @escaping @Sendable () async throws -> CLLocation
|
||||
) async throws -> CLLocation
|
||||
_ operation: @escaping @Sendable () async throws -> CLLocation) async throws -> CLLocation
|
||||
|
||||
@MainActor
|
||||
public static func resolve(
|
||||
|
|
|
|||
|
|
@ -7,21 +7,21 @@ public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate {
|
|||
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { get set }
|
||||
}
|
||||
|
||||
public extension LocationServiceCommon {
|
||||
func configureLocationManager() {
|
||||
extension LocationServiceCommon {
|
||||
public func configureLocationManager() {
|
||||
self.locationManager.delegate = self
|
||||
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func authorizationStatus() -> CLAuthorizationStatus {
|
||||
public func authorizationStatus() -> CLAuthorizationStatus {
|
||||
self.locationManager.authorizationStatus
|
||||
}
|
||||
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
public func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
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
|
||||
self.locationRequestContinuation = continuation
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public enum OpenClawKitResources {
|
|||
private static func locateBundle() -> Bundle {
|
||||
// 1. Check inside Bundle.main (packaged apps copy resources here)
|
||||
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) {
|
||||
return bundle
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ public enum OpenClawKitResources {
|
|||
roots.append(baseURL.appendingPathComponent("Contents/Resources"))
|
||||
|
||||
var current = baseURL
|
||||
for _ in 0 ..< 5 {
|
||||
for _ in 0..<5 {
|
||||
current = current.deletingLastPathComponent()
|
||||
roots.append(current)
|
||||
roots.append(current.appendingPathComponent("Resources"))
|
||||
|
|
@ -68,7 +68,7 @@ public enum OpenClawKitResources {
|
|||
}
|
||||
|
||||
for root in roots {
|
||||
let bundleURL = root.appendingPathComponent("\(bundleName).bundle")
|
||||
let bundleURL = root.appendingPathComponent("\(self.bundleName).bundle")
|
||||
if let bundle = Bundle(url: bundleURL) {
|
||||
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 {}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ public enum PhotoCapture {
|
|||
rawData: Data,
|
||||
maxWidthPx: Int,
|
||||
quality: Double,
|
||||
maxPayloadBytes: Int = 5 * 1024 * 1024
|
||||
) throws -> (data: Data, widthPx: Int, heightPx: Int) {
|
||||
maxPayloadBytes: Int = 5 * 1024 * 1024) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
{
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
return try JPEGTranscoder.transcodeToJPEG(
|
||||
|
|
@ -16,4 +16,3 @@ public enum PhotoCapture {
|
|||
maxBytes: maxEncodedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public enum ShareToAgentDeepLink {
|
|||
let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
|
||||
|
||||
var lines: [String] = ["Shared from iOS."]
|
||||
var lines = ["Shared from iOS."]
|
||||
if let title, !title.isEmpty {
|
||||
lines.append("Title: \(title)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ public enum TalkConfigParsing {
|
|||
public static func selectProviderConfig(
|
||||
_ talk: [String: AnyCodable]?,
|
||||
defaultProvider: String,
|
||||
allowLegacyFallback: Bool = true,
|
||||
) -> TalkProviderConfigSelection? {
|
||||
allowLegacyFallback: Bool = true) -> TalkProviderConfigSelection?
|
||||
{
|
||||
guard let talk else { return nil }
|
||||
if let resolvedSelection = self.resolvedProviderConfig(talk) {
|
||||
return resolvedSelection
|
||||
|
|
@ -63,16 +63,16 @@ public enum TalkConfigParsing {
|
|||
|
||||
public static func resolvedSpeechLocaleID(
|
||||
_ talk: [String: AnyCodable]?,
|
||||
fallback: String? = nil
|
||||
) -> String? {
|
||||
fallback: String? = nil) -> String?
|
||||
{
|
||||
self.normalizedSpeechLocaleID(talk?["speechLocale"]?.stringValue)
|
||||
?? self.normalizedSpeechLocaleID(fallback)
|
||||
}
|
||||
|
||||
public static func normalizedExplicitSpeechLocaleID(
|
||||
_ value: String?,
|
||||
automaticID: String = "auto"
|
||||
) -> String? {
|
||||
automaticID: String = "auto") -> String?
|
||||
{
|
||||
let normalized = self.normalizedSpeechLocaleID(value)
|
||||
return normalized == automaticID ? nil : normalized
|
||||
}
|
||||
|
|
@ -80,8 +80,8 @@ public enum TalkConfigParsing {
|
|||
public static func resolvedSpeechRecognitionLocaleID(
|
||||
preferredLocaleIDs: [String?],
|
||||
fallbackLocaleID: String = "en-US",
|
||||
supportedLocaleIDs: Set<String>
|
||||
) -> String? {
|
||||
supportedLocaleIDs: Set<String>) -> String?
|
||||
{
|
||||
let supported = Set(supportedLocaleIDs.compactMap(self.normalizedSpeechLocaleID))
|
||||
var seen = Set<String>()
|
||||
let candidates = (preferredLocaleIDs + [fallbackLocaleID])
|
||||
|
|
@ -102,8 +102,8 @@ public enum TalkConfigParsing {
|
|||
}
|
||||
|
||||
private static func resolvedProviderConfig(
|
||||
_ talk: [String: AnyCodable]
|
||||
) -> TalkProviderConfigSelection? {
|
||||
_ talk: [String: AnyCodable]) -> TalkProviderConfigSelection?
|
||||
{
|
||||
guard
|
||||
let resolved = talk["resolved"]?.dictionaryValue,
|
||||
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@ public enum TalkPromptBuilder: Sendable {
|
|||
public static func build(
|
||||
transcript: String,
|
||||
interruptedAtSeconds: Double?,
|
||||
includeVoiceDirectiveHint: Bool = true
|
||||
) -> String {
|
||||
includeVoiceDirectiveHint: Bool = true) -> String
|
||||
{
|
||||
var lines: [String] = [
|
||||
"Talk Mode active. Reply in a concise, spoken tone.",
|
||||
]
|
||||
|
||||
if includeVoiceDirectiveHint {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
|||
private var currentToken = UUID()
|
||||
private var watchdog: Task<Void, Never>?
|
||||
|
||||
public var isSpeaking: Bool { self.synth.isSpeaking }
|
||||
public var isSpeaking: Bool {
|
||||
self.synth.isSpeaking
|
||||
}
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
|
|
@ -35,8 +37,8 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
|||
public func speak(
|
||||
text: String,
|
||||
language: String? = nil,
|
||||
onStart: (() -> Void)? = nil
|
||||
) async throws {
|
||||
onStart: (() -> Void)? = nil) async throws
|
||||
{
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
|
|
@ -51,7 +53,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
|||
}
|
||||
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 = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import Foundation
|
|||
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||
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 {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@ public func anyCodableBool(_ value: AnyCodable?) -> Bool {
|
|||
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
|
||||
switch value?.value {
|
||||
case let arr as [AnyCodable]:
|
||||
return arr
|
||||
arr
|
||||
case let arr as [Any]:
|
||||
return arr.map { AnyCodable($0) }
|
||||
arr.map { AnyCodable($0) }
|
||||
default:
|
||||
return []
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ Gateway-side requirement:
|
|||
|
||||
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 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`.
|
||||
|
|
@ -136,8 +136,8 @@ Hop by hop:
|
|||
|
||||
2. `iOS app -> relay`
|
||||
- The app calls the relay registration endpoints over HTTPS.
|
||||
- Registration includes App Attest proof plus the app receipt.
|
||||
- The relay validates the bundle ID, App Attest proof, and Apple receipt, and requires the
|
||||
- Registration includes App Attest proof plus a StoreKit app transaction JWS.
|
||||
- The relay validates the bundle ID, App Attest proof, and Apple distribution proof, and requires the
|
||||
official/production distribution path.
|
||||
- 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.
|
||||
|
|
@ -227,6 +227,18 @@ Notes:
|
|||
- 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":""}`.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
Codex Computer Use is not the only way to expose desktop control. If you want
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue