mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix: harden ios app build hygiene
This commit is contained in:
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Memory Core: stream fallback vector search scoring with a bounded top-K result set so large indexes do not materialize every chunk embedding when sqlite-vec is unavailable. (#73069) Thanks @parkertoddbrooks.
|
- Memory Core: stream fallback vector search scoring with a bounded top-K result set so large indexes do not materialize every chunk embedding when sqlite-vec is unavailable. (#73069) Thanks @parkertoddbrooks.
|
||||||
- Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys.
|
- Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys.
|
||||||
- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy.
|
- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy.
|
||||||
|
- iOS app: refresh SwiftPM/XcodeGen source hygiene, make app, extension, watch, and curated shared Swift files pass the prebuild SwiftFormat and SwiftLint checks, move relay registration off deprecated StoreKit receipt APIs, and keep simulator builds and logic tests warning-free. Thanks @ngutman.
|
||||||
- Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit.
|
- Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit.
|
||||||
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
|
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
|
||||||
- Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev.
|
- Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
|
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "commander",
|
"identity" : "commander",
|
||||||
@@ -10,24 +10,6 @@
|
|||||||
"version" : "0.2.1"
|
"version" : "0.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "elevenlabskit",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
|
||||||
"version" : "0.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-concurrency-extras",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
|
||||||
"version" : "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-syntax",
|
"identity" : "swift-syntax",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -45,24 +27,6 @@
|
|||||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||||
"version" : "0.99.0"
|
"version" : "0.99.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swiftui-math",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
|
||||||
"version" : "0.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "textual",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/textual",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
|
||||||
"version" : "0.3.1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ public struct WakeWordSegment: Sendable, Equatable {
|
|||||||
self.range = range
|
self.range = range
|
||||||
}
|
}
|
||||||
|
|
||||||
public var end: TimeInterval { start + duration }
|
public var end: TimeInterval {
|
||||||
|
self.start + self.duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||||
@@ -24,7 +26,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
|||||||
public init(
|
public init(
|
||||||
triggers: [String],
|
triggers: [String],
|
||||||
minPostTriggerGap: TimeInterval = 0.45,
|
minPostTriggerGap: TimeInterval = 0.45,
|
||||||
minCommandLength: Int = 1) {
|
minCommandLength: Int = 1)
|
||||||
|
{
|
||||||
self.triggers = triggers
|
self.triggers = triggers
|
||||||
self.minPostTriggerGap = minPostTriggerGap
|
self.minPostTriggerGap = minPostTriggerGap
|
||||||
self.minCommandLength = minCommandLength
|
self.minCommandLength = minCommandLength
|
||||||
@@ -78,10 +81,10 @@ public enum WakeWordGate {
|
|||||||
segments: [WakeWordSegment],
|
segments: [WakeWordSegment],
|
||||||
config: WakeWordGateConfig)
|
config: WakeWordGateConfig)
|
||||||
-> WakeWordGateMatch? {
|
-> WakeWordGateMatch? {
|
||||||
let triggerTokens = normalizeTriggers(config.triggers)
|
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||||
guard !triggerTokens.isEmpty else { return nil }
|
guard !triggerTokens.isEmpty else { return nil }
|
||||||
|
|
||||||
let tokens = normalizeSegments(segments)
|
let tokens = self.normalizeSegments(segments)
|
||||||
guard !tokens.isEmpty else { return nil }
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
|
||||||
var best: MatchCandidate?
|
var best: MatchCandidate?
|
||||||
@@ -115,7 +118,7 @@ public enum WakeWordGate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let best else { return nil }
|
guard let best else { return nil }
|
||||||
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||||
guard command.count >= config.minCommandLength else { return nil }
|
guard command.count >= config.minCommandLength else { return nil }
|
||||||
return WakeWordGateMatch(
|
return WakeWordGateMatch(
|
||||||
@@ -145,7 +148,7 @@ public enum WakeWordGate {
|
|||||||
guard !text.isEmpty else { return false }
|
guard !text.isEmpty else { return false }
|
||||||
let normalized = text.lowercased()
|
let normalized = text.lowercased()
|
||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
|
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||||
if token.isEmpty { continue }
|
if token.isEmpty { continue }
|
||||||
if normalized.contains(token) { return true }
|
if normalized.contains(token) { return true }
|
||||||
}
|
}
|
||||||
@@ -155,11 +158,11 @@ public enum WakeWordGate {
|
|||||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||||
var out = text
|
var out = text
|
||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
|
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
guard !token.isEmpty else { continue }
|
guard !token.isEmpty else { continue }
|
||||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||||
}
|
}
|
||||||
return out.trimmingCharacters(in: whitespaceAndPunctuation)
|
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||||
@@ -167,7 +170,7 @@ public enum WakeWordGate {
|
|||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let tokens = trigger
|
let tokens = trigger
|
||||||
.split(whereSeparator: { $0.isWhitespace })
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
.map { normalizeToken(String($0)) }
|
.map { self.normalizeToken(String($0)) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
if tokens.isEmpty { continue }
|
if tokens.isEmpty { continue }
|
||||||
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
|
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
|
||||||
@@ -177,7 +180,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||||
segments.compactMap { segment in
|
segments.compactMap { segment in
|
||||||
let normalized = normalizeToken(segment.text)
|
let normalized = self.normalizeToken(segment.text)
|
||||||
guard !normalized.isEmpty else { return nil }
|
guard !normalized.isEmpty else { return nil }
|
||||||
return Token(
|
return Token(
|
||||||
normalized: normalized,
|
normalized: normalized,
|
||||||
@@ -190,7 +193,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
private static func normalizeToken(_ token: String) -> String {
|
private static func normalizeToken(_ token: String) -> String {
|
||||||
token
|
token
|
||||||
.trimmingCharacters(in: whitespaceAndPunctuation)
|
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
.lowercased()
|
.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import WidgetKit
|
|||||||
struct OpenClawLiveActivity: Widget {
|
struct OpenClawLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
|
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
|
||||||
lockScreenView(context: context)
|
self.lockScreenView(context: context)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
statusDot(state: context.state)
|
self.statusDot(state: context.state)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.center) {
|
DynamicIslandExpandedRegion(.center) {
|
||||||
Text(context.state.statusText)
|
Text(context.state.statusText)
|
||||||
@@ -17,25 +17,24 @@ struct OpenClawLiveActivity: Widget {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
trailingView(state: context.state)
|
self.trailingView(state: context.state)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
statusDot(state: context.state)
|
self.statusDot(state: context.state)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
Text(context.state.statusText)
|
Text(context.state.statusText)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: 64)
|
.frame(maxWidth: 64)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
statusDot(state: context.state)
|
self.statusDot(state: context.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
|
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
statusDot(state: context.state)
|
self.statusDot(state: context.state)
|
||||||
.frame(width: 10, height: 10)
|
.frame(width: 10, height: 10)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("OpenClaw")
|
Text("OpenClaw")
|
||||||
@@ -45,7 +44,7 @@ struct OpenClawLiveActivity: Widget {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
trailingView(state: context.state)
|
self.trailingView(state: context.state)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -69,10 +68,9 @@ struct OpenClawLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
|
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(dotColor(state: state))
|
.fill(self.dotColor(state: state))
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
Maintenance update for the current OpenClaw development release.
|
Maintenance update for the current OpenClaw development release.
|
||||||
|
|
||||||
|
- Refreshed build hygiene for the iOS app, Share extension, Activity widget, Watch app, and curated shared Swift sources; relay registration now uses StoreKit app transaction JWS data instead of deprecated receipt APIs.
|
||||||
|
|
||||||
## 2026.4.25 - 2026-04-25
|
## 2026.4.25 - 2026-04-25
|
||||||
|
|
||||||
Maintenance update for the current OpenClaw development release.
|
Maintenance update for the current OpenClaw development release.
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
|||||||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||||
- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration.
|
- Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
|
||||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||||
|
|
||||||
## Official Build Relay Trust Model
|
## Official Build Relay Trust Model
|
||||||
@@ -222,7 +222,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
|||||||
- The app must pair with the gateway and establish both node and operator sessions.
|
- The app must pair with the gateway and establish both node and operator sessions.
|
||||||
- The operator session is used to fetch `gateway.identity.get`.
|
- The operator session is used to fetch `gateway.identity.get`.
|
||||||
- `iOS -> relay`
|
- `iOS -> relay`
|
||||||
- The app registers with the relay over HTTPS using App Attest plus the app receipt.
|
- The app registers with the relay over HTTPS using App Attest plus a StoreKit app transaction JWS.
|
||||||
- The relay requires the official production/TestFlight distribution path, which is why local
|
- The relay requires the official production/TestFlight distribution path, which is why local
|
||||||
Xcode/dev installs cannot use the hosted relay.
|
Xcode/dev installs cannot use the hosted relay.
|
||||||
- `gateway delegation`
|
- `gateway delegation`
|
||||||
@@ -247,6 +247,10 @@ gateway can only send pushes for iOS devices that paired with that gateway.
|
|||||||
- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
|
- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
|
||||||
- Share extension deep-link forwarding into the connected gateway session.
|
- Share extension deep-link forwarding into the connected gateway session.
|
||||||
|
|
||||||
|
## Computer Use Relationship
|
||||||
|
|
||||||
|
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
|
||||||
|
|
||||||
## Location Automation Use Case (Testing)
|
## Location Automation Use Case (Testing)
|
||||||
|
|
||||||
Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism.
|
Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism.
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ final class ShareViewController: UIViewController {
|
|||||||
let payload = extracted.payload
|
let payload = extracted.payload
|
||||||
self.pendingAttachments = extracted.attachments
|
self.pendingAttachments = extracted.attachments
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)"
|
// swiftlint:disable:next line_length
|
||||||
)
|
"share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)")
|
||||||
let message = self.composeDraft(from: payload)
|
let message = self.composeDraft(from: payload)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.draftTextView.text = message
|
self.draftTextView.text = message
|
||||||
@@ -287,7 +287,7 @@ final class ShareViewController: UIViewController {
|
|||||||
let isInvalidConnectParams =
|
let isInvalidConnectParams =
|
||||||
(code.contains("invalid") && code.contains("connect"))
|
(code.contains("invalid") && code.contains("connect"))
|
||||||
|| message.contains("invalid connect params")
|
|| message.contains("invalid connect params")
|
||||||
if isInvalidConnectParams && mentionsClientIdPath {
|
if isInvalidConnectParams, mentionsClientIdPath {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,7 +405,6 @@ final class ShareViewController: UIViewController {
|
|||||||
} else {
|
} else {
|
||||||
unknownCount += 1
|
unknownCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +474,7 @@ final class ShareViewController: UIViewController {
|
|||||||
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
|
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
|
||||||
if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier),
|
if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier),
|
||||||
let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
|
let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
url.scheme != nil
|
url.scheme != nil
|
||||||
{
|
{
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import OpenClawKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
import os
|
import os
|
||||||
|
|
||||||
actor CameraController {
|
actor CameraController {
|
||||||
struct CameraDeviceInfo: Codable, Sendable {
|
struct CameraDeviceInfo: Codable {
|
||||||
var id: String
|
var id: String
|
||||||
var name: String
|
var name: String
|
||||||
var position: String
|
var position: String
|
||||||
var deviceType: String
|
var deviceType: String
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CameraError: LocalizedError, Sendable {
|
enum CameraError: LocalizedError {
|
||||||
case cameraUnavailable
|
case cameraUnavailable
|
||||||
case microphoneUnavailable
|
case microphoneUnavailable
|
||||||
case permissionDenied(kind: String)
|
case permissionDenied(kind: String)
|
||||||
@@ -142,7 +142,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listDevices() -> [CameraDeviceInfo] {
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
return Self.discoverVideoDevices().map { device in
|
Self.discoverVideoDevices().map { device in
|
||||||
CameraDeviceInfo(
|
CameraDeviceInfo(
|
||||||
id: device.uniqueID,
|
id: device.uniqueID,
|
||||||
name: device.localizedName,
|
name: device.localizedName,
|
||||||
@@ -152,7 +152,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||||
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
|
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ actor CameraController {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
if let match = discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,8 +270,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
func photoOutput(
|
func photoOutput(
|
||||||
_ output: AVCapturePhotoOutput,
|
_ output: AVCapturePhotoOutput,
|
||||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
error: Error?
|
error: Error?)
|
||||||
) {
|
{
|
||||||
let alreadyResumed = self.resumed.withLock { old in
|
let alreadyResumed = self.resumed.withLock { old in
|
||||||
let was = old
|
let was = old
|
||||||
old = true
|
old = true
|
||||||
@@ -303,8 +303,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
func photoOutput(
|
func photoOutput(
|
||||||
_ output: AVCapturePhotoOutput,
|
_ output: AVCapturePhotoOutput,
|
||||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||||
error: Error?
|
error: Error?)
|
||||||
) {
|
{
|
||||||
guard let error else { return }
|
guard let error else { return }
|
||||||
let alreadyResumed = self.resumed.withLock { old in
|
let alreadyResumed = self.resumed.withLock { old in
|
||||||
let was = old
|
let was = old
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
import OpenClawChatUI
|
import OpenClawChatUI
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
import Foundation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||||
private let gateway: GatewayNodeSession
|
private let gateway: GatewayNodeSession
|
||||||
|
|
||||||
@@ -70,10 +70,9 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
|||||||
{
|
{
|
||||||
let startLogMessage =
|
let startLogMessage =
|
||||||
"chat.send start sessionKey=\(sessionKey) "
|
"chat.send start sessionKey=\(sessionKey) "
|
||||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||||
Self.logger.info(
|
Self.logger.info(
|
||||||
"\(startLogMessage, privacy: .public)"
|
"\(startLogMessage, privacy: .public)")
|
||||||
)
|
|
||||||
struct Params: Codable {
|
struct Params: Codable {
|
||||||
var sessionKey: String
|
var sessionKey: String
|
||||||
var message: String
|
var message: String
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ final class ContactsService: ContactsServicing {
|
|||||||
contact.givenName = givenName ?? ""
|
contact.givenName = givenName ?? ""
|
||||||
contact.familyName = familyName ?? ""
|
contact.familyName = familyName ?? ""
|
||||||
contact.organizationName = organizationName ?? ""
|
contact.organizationName = organizationName ?? ""
|
||||||
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
|
if contact.givenName.isEmpty, contact.familyName.isEmpty, let displayName {
|
||||||
contact.givenName = displayName
|
contact.givenName = displayName
|
||||||
}
|
}
|
||||||
contact.phoneNumbers = phoneNumbers.map {
|
contact.phoneNumbers = phoneNumbers.map {
|
||||||
@@ -86,13 +86,12 @@ final class ContactsService: ContactsServicing {
|
|||||||
save.add(contact, toContainerWithIdentifier: nil)
|
save.add(contact, toContainerWithIdentifier: nil)
|
||||||
try store.execute(save)
|
try store.execute(save)
|
||||||
|
|
||||||
let persisted: CNContact
|
let persisted: CNContact = if !contact.identifier.isEmpty {
|
||||||
if !contact.identifier.isEmpty {
|
try store.unifiedContact(
|
||||||
persisted = try store.unifiedContact(
|
|
||||||
withIdentifier: contact.identifier,
|
withIdentifier: contact.identifier,
|
||||||
keysToFetch: Self.payloadKeys)
|
keysToFetch: Self.payloadKeys)
|
||||||
} else {
|
} else {
|
||||||
persisted = contact
|
contact
|
||||||
}
|
}
|
||||||
|
|
||||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||||
@@ -137,7 +136,7 @@ final class ContactsService: ContactsServicing {
|
|||||||
phoneNumbers: [String],
|
phoneNumbers: [String],
|
||||||
emails: [String]) throws -> CNContact?
|
emails: [String]) throws -> CNContact?
|
||||||
{
|
{
|
||||||
if phoneNumbers.isEmpty && emails.isEmpty {
|
if phoneNumbers.isEmpty, emails.isEmpty {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,13 +162,13 @@ final class ContactsService: ContactsServicing {
|
|||||||
phoneNumbers: [String],
|
phoneNumbers: [String],
|
||||||
emails: [String]) -> CNContact?
|
emails: [String]) -> CNContact?
|
||||||
{
|
{
|
||||||
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
|
let normalizedPhones = Set(phoneNumbers.map { self.normalizePhone($0) }.filter { !$0.isEmpty })
|
||||||
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
|
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
|
|
||||||
for contact in contacts {
|
for contact in contacts {
|
||||||
guard seen.insert(contact.identifier).inserted else { continue }
|
guard seen.insert(contact.identifier).inserted else { continue }
|
||||||
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
|
let contactPhones = Set(contact.phoneNumbers.map { self.normalizePhone($0.value.stringValue) })
|
||||||
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
|
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
|
||||||
|
|
||||||
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
||||||
@@ -198,13 +197,13 @@ final class ContactsService: ContactsServicing {
|
|||||||
givenName: contact.givenName,
|
givenName: contact.givenName,
|
||||||
familyName: contact.familyName,
|
familyName: contact.familyName,
|
||||||
organizationName: contact.organizationName,
|
organizationName: contact.organizationName,
|
||||||
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
|
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
|
||||||
emails: contact.emailAddresses.map { String($0.value) })
|
emails: contact.emailAddresses.map { String($0.value) })
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||||
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
import Darwin
|
|
||||||
|
|
||||||
/// Shared device and platform info for Settings, gateway node payloads, and device status.
|
/// Shared device and platform info for Settings, gateway node payloads, and device status.
|
||||||
enum DeviceInfoHelper {
|
enum DeviceInfoHelper {
|
||||||
/// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads.
|
/// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads.
|
||||||
@@ -65,8 +64,8 @@ enum DeviceInfoHelper {
|
|||||||
|
|
||||||
/// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs.
|
/// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs.
|
||||||
static func openClawVersionString() -> String {
|
static func openClawVersionString() -> String {
|
||||||
let version = appVersion()
|
let version = self.appVersion()
|
||||||
let build = appBuild()
|
let build = self.appBuild()
|
||||||
if build.isEmpty || build == version {
|
if build.isEmpty || build == version {
|
||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,25 @@ enum NodeDisplayName {
|
|||||||
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
||||||
|
|
||||||
static func isGeneric(_ name: String) -> Bool {
|
static func isGeneric(_ name: String) -> Bool {
|
||||||
Self.genericNames.contains(name)
|
self.genericNames.contains(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
||||||
switch interfaceIdiom {
|
switch interfaceIdiom {
|
||||||
case .phone:
|
case .phone:
|
||||||
return "iPhone Node"
|
"iPhone Node"
|
||||||
case .pad:
|
case .pad:
|
||||||
return "iPad Node"
|
"iPad Node"
|
||||||
default:
|
default:
|
||||||
return "iOS Node"
|
"iOS Node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(
|
static func resolve(
|
||||||
existing: String?,
|
existing: String?,
|
||||||
deviceName: String,
|
deviceName: String,
|
||||||
interfaceIdiom: UIUserInterfaceIdiom
|
interfaceIdiom: UIUserInterfaceIdiom) -> String
|
||||||
) -> String {
|
{
|
||||||
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
||||||
return trimmedExisting
|
return trimmedExisting
|
||||||
|
|||||||
@@ -31,4 +31,3 @@ enum EventKitAuthorization {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import OpenClawKit
|
|||||||
///
|
///
|
||||||
/// Both sessions should derive all connection inputs from this config so we
|
/// Both sessions should derive all connection inputs from this config so we
|
||||||
/// don't accidentally persist gateway-scoped state under different keys.
|
/// don't accidentally persist gateway-scoped state under different keys.
|
||||||
struct GatewayConnectConfig: Sendable {
|
struct GatewayConnectConfig {
|
||||||
let url: URL
|
let url: URL
|
||||||
let stableID: String
|
let stableID: String
|
||||||
let tls: GatewayTLSParams?
|
let tls: GatewayTLSParams?
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import Contacts
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import CoreMotion
|
import CoreMotion
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Darwin
|
||||||
import EventKit
|
import EventKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Darwin
|
|
||||||
import OpenClawKit
|
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
import os
|
import os
|
||||||
import Photos
|
import Photos
|
||||||
import ReplayKit
|
import ReplayKit
|
||||||
@@ -28,7 +28,9 @@ final class GatewayConnectionController {
|
|||||||
let fingerprintSha256: String
|
let fingerprintSha256: String
|
||||||
let isManual: Bool
|
let isManual: Bool
|
||||||
|
|
||||||
var id: String { self.stableID }
|
var id: String {
|
||||||
|
self.stableID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||||
@@ -86,7 +88,6 @@ final class GatewayConnectionController {
|
|||||||
self.updateFromDiscovery()
|
self.updateFromDiscovery()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
||||||
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
||||||
await self.connectDiscoveredGateway(gateway)
|
await self.connectDiscoveredGateway(gateway)
|
||||||
@@ -177,7 +178,7 @@ final class GatewayConnectionController {
|
|||||||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||||
self.appModel?.gatewayStatusText =
|
self.appModel?.gatewayStatusText =
|
||||||
"TLS handshake failed for \(host):\(resolvedPort). "
|
"TLS handshake failed for \(host):\(resolvedPort). "
|
||||||
+ "Remote gateways must use HTTPS/WSS."
|
+ "Remote gateways must use HTTPS/WSS."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||||
@@ -557,11 +558,11 @@ final class GatewayConnectionController {
|
|||||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case let .hostPort(host, port):
|
case let .hostPort(host, port):
|
||||||
return (host: host.debugDescription, port: Int(port.rawValue))
|
(host: host.debugDescription, port: Int(port.rawValue))
|
||||||
case let .service(name, type, domain, _):
|
case let .service(name, type, domain, _):
|
||||||
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||||
default:
|
default:
|
||||||
return nil
|
nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,8 +570,8 @@ final class GatewayConnectionController {
|
|||||||
name: String,
|
name: String,
|
||||||
type: String,
|
type: String,
|
||||||
domain: String,
|
domain: String,
|
||||||
timeoutSeconds: TimeInterval = 3.0
|
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
|
||||||
) async -> (host: String, port: Int)? {
|
{
|
||||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||||
@@ -636,8 +637,8 @@ final class GatewayConnectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let addrs = svc.addresses else { return nil }
|
guard let addrs = svc.addresses else { return nil }
|
||||||
for addrData in addrs {
|
for addrData in addrs {
|
||||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
|
||||||
@@ -764,7 +765,8 @@ final class GatewayConnectionController {
|
|||||||
|
|
||||||
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||||
if let stableID,
|
if let stableID,
|
||||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID)
|
||||||
|
{
|
||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||||
@@ -781,7 +783,7 @@ final class GatewayConnectionController {
|
|||||||
}
|
}
|
||||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmedHost.isEmpty else { return nil }
|
guard !trimmedHost.isEmpty else { return nil }
|
||||||
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
if useTLS, self.shouldForceTLS(host: trimmedHost) {
|
||||||
return 443
|
return 443
|
||||||
}
|
}
|
||||||
return 18789
|
return 18789
|
||||||
@@ -929,9 +931,9 @@ final class GatewayConnectionController {
|
|||||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||||
switch status {
|
switch status {
|
||||||
case .authorizedAlways, .authorizedWhenInUse:
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
return true
|
true
|
||||||
default:
|
default:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,8 +1047,8 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
|||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
|
||||||
) {
|
{
|
||||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||||
let trust = challenge.protectionSpace.serverTrust
|
let trust = challenge.protectionSpace.serverTrust
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ enum GatewayConnectionIssue: Equatable {
|
|||||||
var needsAuthToken: Bool {
|
var needsAuthToken: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .tokenMissing, .unauthorized:
|
case .tokenMissing, .unauthorized:
|
||||||
return true
|
true
|
||||||
default:
|
default:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,17 @@ enum GatewayConnectionIssue: Equatable {
|
|||||||
}
|
}
|
||||||
switch problem.kind {
|
switch problem.kind {
|
||||||
case .deviceIdentityRequired,
|
case .deviceIdentityRequired,
|
||||||
.deviceSignatureExpired,
|
.deviceSignatureExpired,
|
||||||
.deviceNonceRequired,
|
.deviceNonceRequired,
|
||||||
.deviceNonceMismatch,
|
.deviceNonceMismatch,
|
||||||
.deviceSignatureInvalid,
|
.deviceSignatureInvalid,
|
||||||
.devicePublicKeyInvalid,
|
.devicePublicKeyInvalid,
|
||||||
.deviceIdMismatch,
|
.deviceIdMismatch,
|
||||||
.tailscaleIdentityMissing,
|
.tailscaleIdentityMissing,
|
||||||
.tailscaleProxyMissing,
|
.tailscaleProxyMissing,
|
||||||
.tailscaleWhoisFailed,
|
.tailscaleWhoisFailed,
|
||||||
.tailscaleIdentityMismatch,
|
.tailscaleIdentityMismatch,
|
||||||
.authRateLimited:
|
.authRateLimited:
|
||||||
return .unauthorized
|
return .unauthorized
|
||||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||||
return .network
|
return .network
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -13,7 +13,10 @@ final class GatewayDiscoveryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct DiscoveredGateway: Identifiable, Equatable {
|
struct DiscoveredGateway: Identifiable, Equatable {
|
||||||
var id: String { self.stableID }
|
var id: String {
|
||||||
|
self.stableID
|
||||||
|
}
|
||||||
|
|
||||||
var name: String
|
var name: String
|
||||||
var endpoint: NWEndpoint
|
var endpoint: NWEndpoint
|
||||||
var stableID: String
|
var stableID: String
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import OpenClawKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GatewayHealthMonitor {
|
final class GatewayHealthMonitor {
|
||||||
struct Config: Sendable {
|
struct Config {
|
||||||
var intervalSeconds: Double
|
var intervalSeconds: Double
|
||||||
var timeoutSeconds: Double
|
var timeoutSeconds: Double
|
||||||
var maxFailures: Int
|
var maxFailures: Int
|
||||||
@@ -17,8 +17,8 @@ final class GatewayHealthMonitor {
|
|||||||
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
||||||
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
}
|
})
|
||||||
) {
|
{
|
||||||
self.config = config
|
self.config = config
|
||||||
self.sleep = sleep
|
self.sleep = sleep
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ final class GatewayHealthMonitor {
|
|||||||
{
|
{
|
||||||
let timeout = max(0.0, timeoutSeconds)
|
let timeout = max(0.0, timeoutSeconds)
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
return (try? await check()) ?? false
|
return await (try? check()) ?? false
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let timeoutError = NSError(
|
let timeoutError = NSError(
|
||||||
|
|||||||
@@ -59,58 +59,57 @@ struct GatewayProblemBanner: View {
|
|||||||
.padding(14)
|
.padding(14)
|
||||||
.background(
|
.background(
|
||||||
.thinMaterial,
|
.thinMaterial,
|
||||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconName: String {
|
private var iconName: String {
|
||||||
switch self.problem.kind {
|
switch self.problem.kind {
|
||||||
case .pairingRequired,
|
case .pairingRequired,
|
||||||
.pairingRoleUpgradeRequired,
|
.pairingRoleUpgradeRequired,
|
||||||
.pairingScopeUpgradeRequired,
|
.pairingScopeUpgradeRequired,
|
||||||
.pairingMetadataUpgradeRequired:
|
.pairingMetadataUpgradeRequired:
|
||||||
return "person.crop.circle.badge.clock"
|
"person.crop.circle.badge.clock"
|
||||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||||
return "wifi.exclamationmark"
|
"wifi.exclamationmark"
|
||||||
case .deviceIdentityRequired,
|
case .deviceIdentityRequired,
|
||||||
.deviceSignatureExpired,
|
.deviceSignatureExpired,
|
||||||
.deviceNonceRequired,
|
.deviceNonceRequired,
|
||||||
.deviceNonceMismatch,
|
.deviceNonceMismatch,
|
||||||
.deviceSignatureInvalid,
|
.deviceSignatureInvalid,
|
||||||
.devicePublicKeyInvalid,
|
.devicePublicKeyInvalid,
|
||||||
.deviceIdMismatch:
|
.deviceIdMismatch:
|
||||||
return "lock.shield"
|
"lock.shield"
|
||||||
default:
|
default:
|
||||||
return "exclamationmark.triangle.fill"
|
"exclamationmark.triangle.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tint: Color {
|
private var tint: Color {
|
||||||
switch self.problem.kind {
|
switch self.problem.kind {
|
||||||
case .pairingRequired,
|
case .pairingRequired,
|
||||||
.pairingRoleUpgradeRequired,
|
.pairingRoleUpgradeRequired,
|
||||||
.pairingScopeUpgradeRequired,
|
.pairingScopeUpgradeRequired,
|
||||||
.pairingMetadataUpgradeRequired:
|
.pairingMetadataUpgradeRequired:
|
||||||
return .orange
|
.orange
|
||||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||||
return .yellow
|
.yellow
|
||||||
default:
|
default:
|
||||||
return .red
|
.red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ownerLabel: String {
|
private var ownerLabel: String {
|
||||||
switch self.problem.owner {
|
switch self.problem.owner {
|
||||||
case .gateway:
|
case .gateway:
|
||||||
return "Fix on gateway"
|
"Fix on gateway"
|
||||||
case .iphone:
|
case .iphone:
|
||||||
return "Fix on iPhone"
|
"Fix on iPhone"
|
||||||
case .both:
|
case .both:
|
||||||
return "Check both"
|
"Check both"
|
||||||
case .network:
|
case .network:
|
||||||
return "Check network"
|
"Check network"
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return "Needs attention"
|
"Needs attention"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,15 +217,15 @@ struct GatewayProblemDetailsSheet: View {
|
|||||||
private var ownerSummary: String {
|
private var ownerSummary: String {
|
||||||
switch self.problem.owner {
|
switch self.problem.owner {
|
||||||
case .gateway:
|
case .gateway:
|
||||||
return "Primary fix: gateway"
|
"Primary fix: gateway"
|
||||||
case .iphone:
|
case .iphone:
|
||||||
return "Primary fix: this iPhone"
|
"Primary fix: this iPhone"
|
||||||
case .both:
|
case .both:
|
||||||
return "Primary fix: check both this iPhone and the gateway"
|
"Primary fix: check both this iPhone and the gateway"
|
||||||
case .network:
|
case .network:
|
||||||
return "Primary fix: network or remote access"
|
"Primary fix: network or remote access"
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return "Primary fix: review details and retry"
|
"Primary fix: review details and retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
|
|
||||||
// NetService-based resolver for Bonjour services.
|
/// NetService-based resolver for Bonjour services.
|
||||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
/// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||||
private let service: NetService
|
private let service: NetService
|
||||||
private let completion: ((host: String, port: Int)?) -> Void
|
private let completion: ((host: String, port: Int)?) -> Void
|
||||||
@@ -38,7 +38,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
|||||||
self.finish(result: nil)
|
self.finish(result: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finish(result: ((host: String, port: Int))?) {
|
private func finish(result: (host: String, port: Int)?) {
|
||||||
guard !self.didFinish else { return }
|
guard !self.didFinish else { return }
|
||||||
self.didFinish = true
|
self.didFinish = true
|
||||||
self.service.stop()
|
self.service.stop()
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ enum GatewaySettingsStore {
|
|||||||
static func loadPreferredGatewayStableID() -> String? {
|
static func loadPreferredGatewayStableID() -> String? {
|
||||||
if let value = KeychainStore.loadString(
|
if let value = KeychainStore.loadString(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
account: self.preferredGatewayStableIDAccount
|
account: self.preferredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!value.isEmpty
|
!value.isEmpty
|
||||||
{
|
{
|
||||||
return value
|
return value
|
||||||
@@ -79,8 +78,7 @@ enum GatewaySettingsStore {
|
|||||||
static func loadLastDiscoveredGatewayStableID() -> String? {
|
static func loadLastDiscoveredGatewayStableID() -> String? {
|
||||||
if let value = KeychainStore.loadString(
|
if let value = KeychainStore.loadString(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
account: self.lastDiscoveredGatewayStableIDAccount
|
account: self.lastDiscoveredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!value.isEmpty
|
!value.isEmpty
|
||||||
{
|
{
|
||||||
return value
|
return value
|
||||||
@@ -160,18 +158,18 @@ enum GatewaySettingsStore {
|
|||||||
var stableID: String {
|
var stableID: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .manual(_, _, _, stableID):
|
case let .manual(_, _, _, stableID):
|
||||||
return stableID
|
stableID
|
||||||
case let .discovered(stableID, _):
|
case let .discovered(stableID, _):
|
||||||
return stableID
|
stableID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var useTLS: Bool {
|
var useTLS: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case let .manual(_, _, useTLS, _):
|
case let .manual(_, _, useTLS, _):
|
||||||
return useTLS
|
useTLS
|
||||||
case let .discovered(_, useTLS):
|
case let .discovered(_, useTLS):
|
||||||
return useTLS
|
useTLS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,7 +444,6 @@ enum GatewaySettingsStore {
|
|||||||
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GatewayDiagnostics {
|
enum GatewayDiagnostics {
|
||||||
@@ -518,7 +515,7 @@ enum GatewayDiagnostics {
|
|||||||
|
|
||||||
static func bootstrap() {
|
static func bootstrap() {
|
||||||
guard let url = fileURL else { return }
|
guard let url = fileURL else { return }
|
||||||
queue.async {
|
self.queue.async {
|
||||||
self.truncateLogIfNeeded(url: url)
|
self.truncateLogIfNeeded(url: url)
|
||||||
let timestamp = self.isoTimestamp()
|
let timestamp = self.isoTimestamp()
|
||||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||||
@@ -532,10 +529,10 @@ enum GatewayDiagnostics {
|
|||||||
static func log(_ message: String) {
|
static func log(_ message: String) {
|
||||||
let timestamp = self.isoTimestamp()
|
let timestamp = self.isoTimestamp()
|
||||||
let line = "[\(timestamp)] \(message)"
|
let line = "[\(timestamp)] \(message)"
|
||||||
logger.info("\(line, privacy: .public)")
|
self.logger.info("\(line, privacy: .public)")
|
||||||
|
|
||||||
guard let url = fileURL else { return }
|
guard let url = fileURL else { return }
|
||||||
queue.async {
|
self.queue.async {
|
||||||
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
||||||
count += 1
|
count += 1
|
||||||
if count >= self.logSizeCheckEveryWrites {
|
if count >= self.logSizeCheckEveryWrites {
|
||||||
@@ -556,7 +553,7 @@ enum GatewayDiagnostics {
|
|||||||
|
|
||||||
static func reset() {
|
static func reset() {
|
||||||
guard let url = fileURL else { return }
|
guard let url = fileURL else { return }
|
||||||
queue.async {
|
self.queue.async {
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,3 @@ enum TCPProbe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ private struct HomeToolbarStatusButton: View {
|
|||||||
.scaleEffect(
|
.scaleEffect(
|
||||||
self.gateway == .connecting && !self.reduceMotion
|
self.gateway == .connecting && !self.reduceMotion
|
||||||
? (self.pulse ? 1.15 : 0.85)
|
? (self.pulse ? 1.15 : 0.85)
|
||||||
: 1.0
|
: 1.0)
|
||||||
)
|
|
||||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||||
|
|
||||||
Text(self.gateway.title)
|
Text(self.gateway.title)
|
||||||
@@ -214,8 +213,7 @@ private struct HomeToolbarActionButton: View {
|
|||||||
(self.tint ?? .white).opacity(
|
(self.tint ?? .white).opacity(
|
||||||
self.isActive
|
self.isActive
|
||||||
? 0.34
|
? 0.34
|
||||||
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))
|
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))),
|
||||||
),
|
|
||||||
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
|
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import OpenClawKit
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ enum SignificantLocationMonitor {
|
|||||||
locationService: any LocationServicing,
|
locationService: any LocationServicing,
|
||||||
locationMode: OpenClawLocationMode,
|
locationMode: OpenClawLocationMode,
|
||||||
gateway: GatewayNodeSession,
|
gateway: GatewayNodeSession,
|
||||||
beforeSend: (@MainActor @Sendable () async -> Void)? = nil
|
beforeSend: (@MainActor @Sendable () async -> Void)? = nil)
|
||||||
) {
|
{
|
||||||
guard locationMode == .always else { return }
|
guard locationMode == .always else { return }
|
||||||
let status = locationService.authorizationStatus()
|
let status = locationService.authorizationStatus()
|
||||||
guard status == .authorizedAlways else { return }
|
guard status == .authorizedAlways else { return }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Photos
|
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
|
import Photos
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class PhotoLibraryService: PhotosServicing {
|
final class PhotoLibraryService: PhotosServicing {
|
||||||
@@ -139,7 +139,7 @@ final class PhotoLibraryService: PhotosServicing {
|
|||||||
if newWidth >= currentImage.size.width {
|
if newWidth >= currentImage.size.width {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
currentImage = resize(image: currentImage, targetWidth: newWidth)
|
currentImage = self.resize(image: currentImage, targetWidth: newWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw NSError(domain: "Photos", code: 4, userInfo: [
|
throw NSError(domain: "Photos", code: 4, userInfo: [
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import Observation
|
||||||
import OpenClawChatUI
|
import OpenClawChatUI
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
import Observation
|
|
||||||
import os
|
import os
|
||||||
import Security
|
import Security
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
// Wrap errors without pulling non-Sendable types into async notification paths.
|
/// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||||
private struct NotificationCallError: Error, Sendable {
|
private struct NotificationCallError: Error {
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ private struct GatewayRelayIdentityResponse: Decodable {
|
|||||||
let publicKey: String
|
let publicKey: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
/// Ensures notification requests return promptly even if the system prompt blocks.
|
||||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||||
@@ -61,7 +61,7 @@ final class NodeAppModel {
|
|||||||
let request: AgentDeepLink
|
let request: AgentDeepLink
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable {
|
struct ExecApprovalPrompt: Identifiable, Equatable, Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let commandText: String
|
let commandText: String
|
||||||
let commandPreview: String?
|
let commandPreview: String?
|
||||||
@@ -124,6 +124,7 @@ final class NodeAppModel {
|
|||||||
var gatewayDisplayStatusText: String {
|
var gatewayDisplayStatusText: String {
|
||||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||||
}
|
}
|
||||||
|
|
||||||
var seamColorHex: String?
|
var seamColorHex: String?
|
||||||
private var mainSessionBaseKey: String = "main"
|
private var mainSessionBaseKey: String = "main"
|
||||||
var selectedAgentId: String?
|
var selectedAgentId: String?
|
||||||
@@ -141,7 +142,7 @@ final class NodeAppModel {
|
|||||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||||
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
||||||
|
|
||||||
// Primary "node" connection: used for device capabilities and node.invoke requests.
|
/// Primary "node" connection: used for device capabilities and node.invoke requests.
|
||||||
private let nodeGateway = GatewayNodeSession()
|
private let nodeGateway = GatewayNodeSession()
|
||||||
// Secondary "operator" connection: used for chat/talk/config/voicewake requests.
|
// Secondary "operator" connection: used for chat/talk/config/voicewake requests.
|
||||||
private let operatorGateway = GatewayNodeSession()
|
private let operatorGateway = GatewayNodeSession()
|
||||||
@@ -188,8 +189,14 @@ final class NodeAppModel {
|
|||||||
private var apnsDeviceTokenHex: String?
|
private var apnsDeviceTokenHex: String?
|
||||||
private var apnsLastRegisteredTokenHex: String?
|
private var apnsLastRegisteredTokenHex: String?
|
||||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||||
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
var gatewaySession: GatewayNodeSession {
|
||||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
self.nodeGateway
|
||||||
|
}
|
||||||
|
|
||||||
|
var operatorSession: GatewayNodeSession {
|
||||||
|
self.operatorGateway
|
||||||
|
}
|
||||||
|
|
||||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||||
|
|
||||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||||
@@ -377,7 +384,6 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func setScenePhase(_ phase: ScenePhase) {
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
||||||
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
|
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
|
||||||
@@ -429,7 +435,7 @@ final class NodeAppModel {
|
|||||||
let operatorWasConnected = await MainActor.run { self.operatorConnected }
|
let operatorWasConnected = await MainActor.run { self.operatorConnected }
|
||||||
if operatorWasConnected {
|
if operatorWasConnected {
|
||||||
// Prefer keeping the connection if it's healthy; reconnect only when needed.
|
// Prefer keeping the connection if it's healthy; reconnect only when needed.
|
||||||
let healthy = (try? await self.operatorGateway.request(
|
let healthy = await (try? self.operatorGateway.request(
|
||||||
method: "health",
|
method: "health",
|
||||||
paramsJSON: nil,
|
paramsJSON: nil,
|
||||||
timeoutSeconds: 2)) != nil
|
timeoutSeconds: 2)) != nil
|
||||||
@@ -512,7 +518,7 @@ final class NodeAppModel {
|
|||||||
self.backgroundReconnectSuppressed = false
|
self.backgroundReconnectSuppressed = false
|
||||||
let leaseLogMessage =
|
let leaseLogMessage =
|
||||||
"Background reconnect lease reason=\(reason) "
|
"Background reconnect lease reason=\(reason) "
|
||||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||||
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +531,7 @@ final class NodeAppModel {
|
|||||||
guard changed else { return }
|
guard changed else { return }
|
||||||
let suppressLogMessage =
|
let suppressLogMessage =
|
||||||
"Background reconnect suppressed reason=\(reason) "
|
"Background reconnect suppressed reason=\(reason) "
|
||||||
+ "disconnect=\(disconnectIfNeeded)"
|
+ "disconnect=\(disconnectIfNeeded)"
|
||||||
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
||||||
guard disconnectIfNeeded else { return }
|
guard disconnectIfNeeded else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
@@ -646,7 +652,7 @@ final class NodeAppModel {
|
|||||||
self.applyMainSessionKey(decoded.mainkey)
|
self.applyMainSessionKey(decoded.mainkey)
|
||||||
|
|
||||||
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !selected.isEmpty && !decoded.agents.contains(where: { $0.id == selected }) {
|
if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
|
||||||
self.selectedAgentId = nil
|
self.selectedAgentId = nil
|
||||||
}
|
}
|
||||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||||
@@ -769,8 +775,7 @@ final class NodeAppModel {
|
|||||||
let data = try await self.operatorGateway.request(
|
let data = try await self.operatorGateway.request(
|
||||||
method: "health",
|
method: "health",
|
||||||
paramsJSON: nil,
|
paramsJSON: nil,
|
||||||
timeoutSeconds: 6
|
timeoutSeconds: 6)
|
||||||
)
|
|
||||||
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1057,6 +1062,7 @@ final class NodeAppModel {
|
|||||||
"""
|
"""
|
||||||
let resultJSON = try await self.screen.eval(javaScript: js)
|
let resultJSON = try await self.screen.eval(javaScript: js)
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
@@ -1294,8 +1300,8 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func isNotificationAuthorizationAllowed(
|
private static func isNotificationAuthorizationAllowed(
|
||||||
_ status: NotificationAuthorizationStatus
|
_ status: NotificationAuthorizationStatus) -> Bool
|
||||||
) -> Bool {
|
{
|
||||||
switch status {
|
switch status {
|
||||||
case .authorized, .provisional, .ephemeral:
|
case .authorized, .provisional, .ephemeral:
|
||||||
true
|
true
|
||||||
@@ -1306,8 +1312,8 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
private func runNotificationCall<T: Sendable>(
|
private func runNotificationCall<T: Sendable>(
|
||||||
timeoutSeconds: Double,
|
timeoutSeconds: Double,
|
||||||
operation: @escaping @Sendable () async throws -> T
|
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
|
||||||
) async -> Result<T, NotificationCallError> {
|
{
|
||||||
let latch = NotificationInvokeLatch<T>()
|
let latch = NotificationInvokeLatch<T>()
|
||||||
var opTask: Task<Void, Never>?
|
var opTask: Task<Void, Never>?
|
||||||
var timeoutTask: Task<Void, Never>?
|
var timeoutTask: Task<Void, Never>?
|
||||||
@@ -1481,12 +1487,11 @@ final class NodeAppModel {
|
|||||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension NodeAppModel {
|
extension NodeAppModel {
|
||||||
// Central registry for node invoke routing to keep commands in one place.
|
/// Central registry for node invoke routing to keep commands in one place.
|
||||||
func buildCapabilityRouter() -> NodeCapabilityRouter {
|
private func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||||
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
||||||
|
|
||||||
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
||||||
@@ -1610,7 +1615,7 @@ private extension NodeAppModel {
|
|||||||
return NodeCapabilityRouter(handlers: handlers)
|
return NodeCapabilityRouter(handlers: handlers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
switch req.command {
|
switch req.command {
|
||||||
case OpenClawWatchCommand.status.rawValue:
|
case OpenClawWatchCommand.status.rawValue:
|
||||||
let status = await self.watchMessagingService.status()
|
let status = await self.watchMessagingService.status()
|
||||||
@@ -1627,7 +1632,7 @@ private extension NodeAppModel {
|
|||||||
let normalizedParams = Self.normalizeWatchNotifyParams(params)
|
let normalizedParams = Self.normalizeWatchNotifyParams(params)
|
||||||
let title = normalizedParams.title
|
let title = normalizedParams.title
|
||||||
let body = normalizedParams.body
|
let body = normalizedParams.body
|
||||||
if title.isEmpty && body.isEmpty {
|
if title.isEmpty, body.isEmpty {
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1670,18 +1675,18 @@ private extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationMode() -> OpenClawLocationMode {
|
private func locationMode() -> OpenClawLocationMode {
|
||||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||||
}
|
}
|
||||||
|
|
||||||
func isLocationPreciseEnabled() -> Bool {
|
private func isLocationPreciseEnabled() -> Bool {
|
||||||
// iOS settings now expose a single location mode control.
|
// iOS settings now expose a single location mode control.
|
||||||
// Default location tool precision stays high unless a command explicitly requests balanced.
|
// Default location tool precision stays high unless a command explicitly requests balanced.
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
fileprivate static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||||
guard let json, let data = json.data(using: .utf8) else {
|
guard let json, let data = json.data(using: .utf8) else {
|
||||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||||
@@ -1690,7 +1695,7 @@ private extension NodeAppModel {
|
|||||||
return try JSONDecoder().decode(type, from: data)
|
return try JSONDecoder().decode(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func encodePayload(_ obj: some Encodable) throws -> String {
|
fileprivate static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||||
let data = try JSONEncoder().encode(obj)
|
let data = try JSONEncoder().encode(obj)
|
||||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
||||||
@@ -1700,17 +1705,17 @@ private extension NodeAppModel {
|
|||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
func isCameraEnabled() -> Bool {
|
private func isCameraEnabled() -> Bool {
|
||||||
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
||||||
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
||||||
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerCameraFlash() {
|
private func triggerCameraFlash() {
|
||||||
self.cameraFlashNonce &+= 1
|
self.cameraFlashNonce &+= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||||
self.cameraHUDDismissTask?.cancel()
|
self.cameraHUDDismissTask?.cancel()
|
||||||
|
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||||
@@ -1854,8 +1859,8 @@ extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension NodeAppModel {
|
extension NodeAppModel {
|
||||||
func prepareForGatewayConnect(url: URL, stableID: String) {
|
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||||
self.gatewayAutoReconnectEnabled = true
|
self.gatewayAutoReconnectEnabled = true
|
||||||
self.gatewayPairingPaused = false
|
self.gatewayPairingPaused = false
|
||||||
self.gatewayPairingRequestId = nil
|
self.gatewayPairingRequestId = nil
|
||||||
@@ -1878,13 +1883,13 @@ private extension NodeAppModel {
|
|||||||
self.apnsLastRegisteredTokenHex = nil
|
self.apnsLastRegisteredTokenHex = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearGatewayConnectionProblem() {
|
private func clearGatewayConnectionProblem() {
|
||||||
self.lastGatewayProblem = nil
|
self.lastGatewayProblem = nil
|
||||||
self.gatewayPairingPaused = false
|
self.gatewayPairingPaused = false
|
||||||
self.gatewayPairingRequestId = nil
|
self.gatewayPairingRequestId = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||||
self.lastGatewayProblem = problem
|
self.lastGatewayProblem = problem
|
||||||
self.gatewayStatusText = problem.statusText
|
self.gatewayStatusText = problem.statusText
|
||||||
self.gatewayServerName = nil
|
self.gatewayServerName = nil
|
||||||
@@ -1903,14 +1908,14 @@ private extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||||
guard let lastGatewayProblem else { return false }
|
guard let lastGatewayProblem else { return false }
|
||||||
return GatewayConnectionProblemMapper.shouldPreserve(
|
return GatewayConnectionProblemMapper.shouldPreserve(
|
||||||
previousProblem: lastGatewayProblem,
|
previousProblem: lastGatewayProblem,
|
||||||
overDisconnectReason: reason)
|
overDisconnectReason: reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldStartOperatorGatewayLoop(
|
private func shouldStartOperatorGatewayLoop(
|
||||||
token: String?,
|
token: String?,
|
||||||
bootstrapToken: String?,
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
@@ -1923,12 +1928,12 @@ private extension NodeAppModel {
|
|||||||
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
|
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
private func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func shouldStartOperatorGatewayLoop(
|
fileprivate nonisolated static func shouldStartOperatorGatewayLoop(
|
||||||
token: String?,
|
token: String?,
|
||||||
bootstrapToken: String?,
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
@@ -1949,7 +1954,8 @@ private extension NodeAppModel {
|
|||||||
return hasStoredOperatorToken
|
return hasStoredOperatorToken
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
fileprivate nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?)
|
||||||
|
-> GatewayConnectConfig? {
|
||||||
guard let config else { return nil }
|
guard let config else { return nil }
|
||||||
let trimmedBootstrapToken = config.bootstrapToken?
|
let trimmedBootstrapToken = config.bootstrapToken?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
@@ -1964,7 +1970,7 @@ private extension NodeAppModel {
|
|||||||
nodeOptions: config.nodeOptions)
|
nodeOptions: config.nodeOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentGatewayReconnectAuth(
|
private func currentGatewayReconnectAuth(
|
||||||
fallbackToken: String?,
|
fallbackToken: String?,
|
||||||
fallbackBootstrapToken: String?,
|
fallbackBootstrapToken: String?,
|
||||||
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
|
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
|
||||||
@@ -1975,7 +1981,7 @@ private extension NodeAppModel {
|
|||||||
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
|
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
private func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
||||||
// Always drop the in-memory bootstrap token after the first successful
|
// Always drop the in-memory bootstrap token after the first successful
|
||||||
// bootstrap connect so reconnect loops cannot reuse a spent token.
|
// bootstrap connect so reconnect loops cannot reuse a spent token.
|
||||||
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
|
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
|
||||||
@@ -1999,7 +2005,7 @@ private extension NodeAppModel {
|
|||||||
sessionBox: WebSocketSessionBox?) async
|
sessionBox: WebSocketSessionBox?) async
|
||||||
{
|
{
|
||||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
if self.operatorGatewayTask == nil, self.shouldStartOperatorGatewayLoop(
|
||||||
token: token,
|
token: token,
|
||||||
bootstrapToken: nil,
|
bootstrapToken: nil,
|
||||||
password: password,
|
password: password,
|
||||||
@@ -2020,7 +2026,7 @@ private extension NodeAppModel {
|
|||||||
_ = await self.requestNotificationAuthorizationIfNeeded()
|
_ = await self.requestNotificationAuthorizationIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||||
guard self.isBackgrounded else { return }
|
guard self.isBackgrounded else { return }
|
||||||
guard !self.backgroundReconnectSuppressed else { return }
|
guard !self.backgroundReconnectSuppressed else { return }
|
||||||
guard let leaseUntil = self.backgroundReconnectLeaseUntil else {
|
guard let leaseUntil = self.backgroundReconnectLeaseUntil else {
|
||||||
@@ -2032,12 +2038,12 @@ private extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
|
private func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
|
||||||
self.refreshBackgroundReconnectSuppressionIfNeeded(source: source)
|
self.refreshBackgroundReconnectSuppressionIfNeeded(source: source)
|
||||||
return self.isBackgrounded && self.backgroundReconnectSuppressed
|
return self.isBackgrounded && self.backgroundReconnectSuppressed
|
||||||
}
|
}
|
||||||
|
|
||||||
func startOperatorGatewayLoop(
|
private func startOperatorGatewayLoop(
|
||||||
url: URL,
|
url: URL,
|
||||||
stableID: String,
|
stableID: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
@@ -2141,7 +2147,7 @@ private extension NodeAppModel {
|
|||||||
|
|
||||||
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
|
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
|
||||||
// swiftlint:disable:next function_body_length
|
// swiftlint:disable:next function_body_length
|
||||||
func startNodeGatewayLoop(
|
private func startNodeGatewayLoop(
|
||||||
url: URL,
|
url: URL,
|
||||||
stableID: String,
|
stableID: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
@@ -2216,7 +2222,7 @@ private extension NodeAppModel {
|
|||||||
let usedBootstrapToken =
|
let usedBootstrapToken =
|
||||||
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
||||||
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty == false
|
.isEmpty == false
|
||||||
if usedBootstrapToken {
|
if usedBootstrapToken {
|
||||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||||
url: url,
|
url: url,
|
||||||
@@ -2230,8 +2236,7 @@ private extension NodeAppModel {
|
|||||||
(
|
(
|
||||||
sessionKey: self.mainSessionKey,
|
sessionKey: self.mainSessionKey,
|
||||||
deliveryChannel: self.shareDeliveryChannel,
|
deliveryChannel: self.shareDeliveryChannel,
|
||||||
deliveryTo: self.shareDeliveryTo
|
deliveryTo: self.shareDeliveryTo)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShareGatewayRelaySettings.saveConfig(
|
ShareGatewayRelaySettings.saveConfig(
|
||||||
ShareGatewayRelayConfig(
|
ShareGatewayRelayConfig(
|
||||||
@@ -2243,8 +2248,7 @@ private extension NodeAppModel {
|
|||||||
deliveryTo: relayData.deliveryTo))
|
deliveryTo: relayData.deliveryTo))
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"gateway connected host=\(url.host ?? "?") "
|
"gateway connected host=\(url.host ?? "?") "
|
||||||
+ "scheme=\(url.scheme ?? "?")"
|
+ "scheme=\(url.scheme ?? "?")")
|
||||||
)
|
|
||||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||||
}
|
}
|
||||||
@@ -2295,8 +2299,8 @@ private extension NodeAppModel {
|
|||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
if !didFallbackClientId,
|
if !didFallbackClientId,
|
||||||
let fallbackClientId = self.legacyClientIdFallback(
|
let fallbackClientId = self.legacyClientIdFallback(
|
||||||
currentClientId: currentOptions.clientId,
|
currentClientId: currentOptions.clientId,
|
||||||
error: error)
|
error: error)
|
||||||
{
|
{
|
||||||
didFallbackClientId = true
|
didFallbackClientId = true
|
||||||
currentOptions.clientId = fallbackClientId
|
currentOptions.clientId = fallbackClientId
|
||||||
@@ -2368,7 +2372,7 @@ private extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
|
private func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let storedOperatorScopes = DeviceAuthStore
|
let storedOperatorScopes = DeviceAuthStore
|
||||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||||
@@ -2379,11 +2383,11 @@ private extension NodeAppModel {
|
|||||||
storedOperatorScopes: storedOperatorScopes)
|
storedOperatorScopes: storedOperatorScopes)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func shouldRequestOperatorApprovalScope(
|
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
|
||||||
token: String?,
|
token: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
storedOperatorScopes: [String]
|
storedOperatorScopes: [String]) -> Bool
|
||||||
) -> Bool {
|
{
|
||||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !trimmedToken.isEmpty {
|
if !trimmedToken.isEmpty {
|
||||||
return true
|
return true
|
||||||
@@ -2395,11 +2399,11 @@ private extension NodeAppModel {
|
|||||||
return storedOperatorScopes.contains("operator.approvals")
|
return storedOperatorScopes.contains("operator.approvals")
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeOperatorConnectOptions(
|
private func makeOperatorConnectOptions(
|
||||||
clientId: String,
|
clientId: String,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
includeApprovalScope: Bool
|
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||||
) -> GatewayConnectOptions {
|
{
|
||||||
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
||||||
// Preserve reconnect compatibility for older paired operator tokens that were
|
// Preserve reconnect compatibility for older paired operator tokens that were
|
||||||
// approved before iOS requested operator.approvals by default.
|
// approved before iOS requested operator.approvals by default.
|
||||||
@@ -2418,7 +2422,7 @@ private extension NodeAppModel {
|
|||||||
includeDeviceIdentity: true)
|
includeDeviceIdentity: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||||
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
guard normalizedClientId == "openclaw-ios" else { return nil }
|
guard normalizedClientId == "openclaw-ios" else { return nil }
|
||||||
let message = error.localizedDescription.lowercased()
|
let message = error.localizedDescription.lowercased()
|
||||||
@@ -2428,7 +2432,7 @@ private extension NodeAppModel {
|
|||||||
return "moltbot-ios"
|
return "moltbot-ios"
|
||||||
}
|
}
|
||||||
|
|
||||||
func isOperatorConnected() async -> Bool {
|
private func isOperatorConnected() async -> Bool {
|
||||||
self.operatorConnected
|
self.operatorConnected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2568,8 +2572,10 @@ extension NodeAppModel {
|
|||||||
PendingForegroundNodeActionsResponse.self,
|
PendingForegroundNodeActionsResponse.self,
|
||||||
from: payload)
|
from: payload)
|
||||||
guard !decoded.actions.isEmpty else { return }
|
guard !decoded.actions.isEmpty else { return }
|
||||||
// swiftlint:disable:next line_length
|
self.pendingActionLogger
|
||||||
self.pendingActionLogger.info("Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
|
.info(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
|
||||||
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort only.
|
// Best-effort only.
|
||||||
@@ -2591,8 +2597,10 @@ extension NodeAppModel {
|
|||||||
command: action.command,
|
command: action.command,
|
||||||
paramsJSON: action.paramsJSON)
|
paramsJSON: action.paramsJSON)
|
||||||
let result = await self.handleInvoke(req)
|
let result = await self.handleInvoke(req)
|
||||||
// swiftlint:disable:next line_length
|
self.pendingActionLogger
|
||||||
self.pendingActionLogger.info("Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
|
.info(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
|
||||||
guard result.ok else { return }
|
guard result.ok else { return }
|
||||||
let acked = await self.ackPendingForegroundNodeAction(
|
let acked = await self.ackPendingForegroundNodeAction(
|
||||||
id: action.id,
|
id: action.id,
|
||||||
@@ -2616,17 +2624,19 @@ extension NodeAppModel {
|
|||||||
timeoutSeconds: 6)
|
timeoutSeconds: 6)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// swiftlint:disable:next line_length
|
self.pendingActionLogger
|
||||||
self.pendingActionLogger.error("Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||||
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
|
switch await self.watchReplyCoordinator.ingest(event, isGatewayConnected: self.isGatewayConnected()) {
|
||||||
case .dropMissingFields:
|
case .dropMissingFields:
|
||||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||||
case .deduped(let replyId):
|
case let .deduped(replyId):
|
||||||
self.watchReplyLogger.debug(
|
self.watchReplyLogger.debug(
|
||||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||||
case let .queue(replyId, actionId):
|
case let .queue(replyId, actionId):
|
||||||
@@ -2638,7 +2648,7 @@ extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func flushQueuedWatchRepliesIfConnected() async {
|
private func flushQueuedWatchRepliesIfConnected() async {
|
||||||
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
|
for event in await self.watchReplyCoordinator.drainIfConnected(self.isGatewayConnected()) {
|
||||||
await self.forwardWatchReplyToAgent(event)
|
await self.forwardWatchReplyToAgent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2660,13 +2670,13 @@ extension NodeAppModel {
|
|||||||
try await self.sendAgentRequest(link: link)
|
try await self.sendAgentRequest(link: link)
|
||||||
let forwardedMessage =
|
let forwardedMessage =
|
||||||
"watch reply forwarded replyId=\(event.replyId) "
|
"watch reply forwarded replyId=\(event.replyId) "
|
||||||
+ "action=\(event.actionId)"
|
+ "action=\(event.actionId)"
|
||||||
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
||||||
self.openChatRequestID &+= 1
|
self.openChatRequestID &+= 1
|
||||||
} catch {
|
} catch {
|
||||||
let failedMessage =
|
let failedMessage =
|
||||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||||
+ "error=\(error.localizedDescription)"
|
+ "error=\(error.localizedDescription)"
|
||||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||||
self.watchReplyCoordinator.requeueFront(event)
|
self.watchReplyCoordinator.requeueFront(event)
|
||||||
}
|
}
|
||||||
@@ -2811,7 +2821,7 @@ extension NodeAppModel {
|
|||||||
risk: nil)
|
risk: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
private nonisolated static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||||
reason: String) -> Bool
|
reason: String) -> Bool
|
||||||
{
|
{
|
||||||
reason == "resolve_retry"
|
reason == "resolve_retry"
|
||||||
@@ -2828,8 +2838,10 @@ extension NodeAppModel {
|
|||||||
self.watchExecApprovalLogger.debug(
|
self.watchExecApprovalLogger.debug(
|
||||||
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
||||||
} catch {
|
} catch {
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.error("watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
|
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
|
||||||
}
|
}
|
||||||
@@ -2850,8 +2862,10 @@ extension NodeAppModel {
|
|||||||
do {
|
do {
|
||||||
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
||||||
} catch {
|
} catch {
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.error("watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
|
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
|
||||||
}
|
}
|
||||||
@@ -2870,8 +2884,10 @@ extension NodeAppModel {
|
|||||||
do {
|
do {
|
||||||
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
||||||
} catch {
|
} catch {
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.error("watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
|
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
|
||||||
}
|
}
|
||||||
@@ -2900,13 +2916,17 @@ extension NodeAppModel {
|
|||||||
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
|
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
|
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.debug("watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
.debug(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
||||||
} catch {
|
} catch {
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
|
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.error("watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2917,7 +2937,7 @@ extension NodeAppModel {
|
|||||||
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
|
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func watchExecApprovalIDsNeedingFetch(
|
private nonisolated static func watchExecApprovalIDsNeedingFetch(
|
||||||
candidateIDs: [String],
|
candidateIDs: [String],
|
||||||
cachedApprovalIDs: [String]) -> [String]
|
cachedApprovalIDs: [String]) -> [String]
|
||||||
{
|
{
|
||||||
@@ -2972,8 +2992,10 @@ extension NodeAppModel {
|
|||||||
forApprovalID: approvalId,
|
forApprovalID: approvalId,
|
||||||
notificationCenter: self.notificationCenter)
|
notificationCenter: self.notificationCenter)
|
||||||
case let .failed(message):
|
case let .failed(message):
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.error("watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3054,8 +3076,10 @@ extension NodeAppModel {
|
|||||||
reason: .notFound)
|
reason: .notFound)
|
||||||
return true
|
return true
|
||||||
case let .failed(message):
|
case let .failed(message):
|
||||||
// swiftlint:disable:next line_length
|
self.watchExecApprovalLogger
|
||||||
self.watchExecApprovalLogger.error("watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3084,9 +3108,9 @@ extension NodeAppModel {
|
|||||||
let pushKind = Self.openclawPushKind(userInfo)
|
let pushKind = Self.openclawPushKind(userInfo)
|
||||||
let receivedMessage =
|
let receivedMessage =
|
||||||
"Silent push received wakeId=\(wakeId) "
|
"Silent push received wakeId=\(wakeId) "
|
||||||
+ "kind=\(pushKind) "
|
+ "kind=\(pushKind) "
|
||||||
+ "backgrounded=\(self.isBackgrounded) "
|
+ "backgrounded=\(self.isBackgrounded) "
|
||||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||||
|
|
||||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||||
@@ -3108,8 +3132,10 @@ extension NodeAppModel {
|
|||||||
{
|
{
|
||||||
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
||||||
if handled {
|
if handled {
|
||||||
// swiftlint:disable:next line_length
|
self.execApprovalNotificationLogger
|
||||||
self.execApprovalNotificationLogger.info("Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
.info(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
||||||
}
|
}
|
||||||
return handled
|
return handled
|
||||||
}
|
}
|
||||||
@@ -3117,9 +3143,9 @@ extension NodeAppModel {
|
|||||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||||
let outcomeMessage =
|
let outcomeMessage =
|
||||||
"Silent push outcome wakeId=\(wakeId) "
|
"Silent push outcome wakeId=\(wakeId) "
|
||||||
+ "applied=\(result.applied) "
|
+ "applied=\(result.applied) "
|
||||||
+ "reason=\(result.reason) "
|
+ "reason=\(result.reason) "
|
||||||
+ "durationMs=\(result.durationMs)"
|
+ "durationMs=\(result.durationMs)"
|
||||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||||
return result.applied
|
return result.applied
|
||||||
}
|
}
|
||||||
@@ -3128,16 +3154,16 @@ extension NodeAppModel {
|
|||||||
let wakeId = Self.makePushWakeAttemptID()
|
let wakeId = Self.makePushWakeAttemptID()
|
||||||
let receivedMessage =
|
let receivedMessage =
|
||||||
"Background refresh wake received wakeId=\(wakeId) "
|
"Background refresh wake received wakeId=\(wakeId) "
|
||||||
+ "trigger=\(trigger) "
|
+ "trigger=\(trigger) "
|
||||||
+ "backgrounded=\(self.isBackgrounded) "
|
+ "backgrounded=\(self.isBackgrounded) "
|
||||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||||
let outcomeMessage =
|
let outcomeMessage =
|
||||||
"Background refresh wake outcome wakeId=\(wakeId) "
|
"Background refresh wake outcome wakeId=\(wakeId) "
|
||||||
+ "applied=\(result.applied) "
|
+ "applied=\(result.applied) "
|
||||||
+ "reason=\(result.reason) "
|
+ "reason=\(result.reason) "
|
||||||
+ "durationMs=\(result.durationMs)"
|
+ "durationMs=\(result.durationMs)"
|
||||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||||
return result.applied
|
return result.applied
|
||||||
}
|
}
|
||||||
@@ -3157,7 +3183,7 @@ extension NodeAppModel {
|
|||||||
{
|
{
|
||||||
let throttledMessage =
|
let throttledMessage =
|
||||||
"Location wake throttled wakeId=\(wakeId) "
|
"Location wake throttled wakeId=\(wakeId) "
|
||||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||||
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3165,15 +3191,15 @@ extension NodeAppModel {
|
|||||||
|
|
||||||
let beginMessage =
|
let beginMessage =
|
||||||
"Location wake begin wakeId=\(wakeId) "
|
"Location wake begin wakeId=\(wakeId) "
|
||||||
+ "backgrounded=\(self.isBackgrounded) "
|
+ "backgrounded=\(self.isBackgrounded) "
|
||||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||||
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
||||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||||
let triggerMessage =
|
let triggerMessage =
|
||||||
"Location wake trigger wakeId=\(wakeId) "
|
"Location wake trigger wakeId=\(wakeId) "
|
||||||
+ "applied=\(result.applied) "
|
+ "applied=\(result.applied) "
|
||||||
+ "reason=\(result.reason) "
|
+ "reason=\(result.reason) "
|
||||||
+ "durationMs=\(result.durationMs)"
|
+ "durationMs=\(result.durationMs)"
|
||||||
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
||||||
|
|
||||||
guard result.applied else { return }
|
guard result.applied else { return }
|
||||||
@@ -3201,7 +3227,7 @@ extension NodeAppModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||||
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
|
if !usesRelayTransport, token == self.apnsLastRegisteredTokenHex {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
@@ -3330,8 +3356,10 @@ extension NodeAppModel {
|
|||||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||||
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
|
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
|
||||||
case let .failed(message):
|
case let .failed(message):
|
||||||
// swiftlint:disable:next line_length
|
self.execApprovalNotificationLogger
|
||||||
self.execApprovalNotificationLogger.error("Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
.error(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3369,7 +3397,7 @@ extension NodeAppModel {
|
|||||||
expiresAtMs: details.expiresAtMs)
|
expiresAtMs: details.expiresAtMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect(
|
private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect(
|
||||||
sourceReason: String,
|
sourceReason: String,
|
||||||
isBackgrounded: Bool) -> Bool
|
isBackgrounded: Bool) -> Bool
|
||||||
{
|
{
|
||||||
@@ -3387,24 +3415,22 @@ extension NodeAppModel {
|
|||||||
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
|
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
|
||||||
{
|
{
|
||||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let fetchReason: String
|
let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
||||||
if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
normalizedSourceReason
|
||||||
fetchReason = normalizedSourceReason
|
|
||||||
} else {
|
} else {
|
||||||
fetchReason = "direct"
|
"direct"
|
||||||
}
|
}
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
|
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
|
||||||
let connected: Bool
|
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
|
||||||
sourceReason: fetchReason,
|
sourceReason: fetchReason,
|
||||||
isBackgrounded: self.isBackgrounded)
|
isBackgrounded: self.isBackgrounded)
|
||||||
{
|
{
|
||||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||||
timeoutMs: 12_000,
|
timeoutMs: 12000,
|
||||||
reason: fetchReason)
|
reason: fetchReason)
|
||||||
} else {
|
} else {
|
||||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
||||||
}
|
}
|
||||||
guard connected else {
|
guard connected else {
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
@@ -3472,8 +3498,8 @@ extension NodeAppModel {
|
|||||||
|
|
||||||
func handleExecApprovalNotificationDecision(
|
func handleExecApprovalNotificationDecision(
|
||||||
approvalId: String,
|
approvalId: String,
|
||||||
decision: String
|
decision: String) async
|
||||||
) async {
|
{
|
||||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !normalizedApprovalID.isEmpty else { return }
|
guard !normalizedApprovalID.isEmpty else { return }
|
||||||
|
|
||||||
@@ -3499,8 +3525,8 @@ extension NodeAppModel {
|
|||||||
private func resolveExecApprovalNotificationDecision(
|
private func resolveExecApprovalNotificationDecision(
|
||||||
approvalId: String,
|
approvalId: String,
|
||||||
decision: String,
|
decision: String,
|
||||||
sourceReason: String? = nil
|
sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome
|
||||||
) async -> ExecApprovalResolutionOutcome {
|
{
|
||||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -3509,16 +3535,15 @@ extension NodeAppModel {
|
|||||||
return .failed(message: "Invalid approval request.")
|
return .failed(message: "Invalid approval request.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let connected: Bool
|
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
|
||||||
sourceReason: resolutionReason,
|
sourceReason: resolutionReason,
|
||||||
isBackgrounded: self.isBackgrounded)
|
isBackgrounded: self.isBackgrounded)
|
||||||
{
|
{
|
||||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||||
timeoutMs: 12_000,
|
timeoutMs: 12000,
|
||||||
reason: resolutionReason)
|
reason: resolutionReason)
|
||||||
} else {
|
} else {
|
||||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
||||||
}
|
}
|
||||||
guard connected else {
|
guard connected else {
|
||||||
self.execApprovalNotificationLogger.error(
|
self.execApprovalNotificationLogger.error(
|
||||||
@@ -3573,7 +3598,7 @@ extension NodeAppModel {
|
|||||||
self.dismissPendingExecApprovalPrompt()
|
self.dismissPendingExecApprovalPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||||
if gatewayError.code != "INVALID_REQUEST" {
|
if gatewayError.code != "INVALID_REQUEST" {
|
||||||
return false
|
return false
|
||||||
@@ -3584,7 +3609,7 @@ extension NodeAppModel {
|
|||||||
return gatewayError.message.lowercased().contains("unknown or expired approval id")
|
return gatewayError.message.lowercased().contains("unknown or expired approval id")
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
private nonisolated static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||||
if gatewayError.code != "INVALID_REQUEST" {
|
if gatewayError.code != "INVALID_REQUEST" {
|
||||||
return false
|
return false
|
||||||
@@ -3698,7 +3723,7 @@ extension NodeAppModel {
|
|||||||
|
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
|
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
|
||||||
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1_000)) / 1000.0 + 8.0))
|
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1000)) / 1000.0 + 8.0))
|
||||||
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
|
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: watch_request_reconnect_lease_granted "
|
"watch exec approval: watch_request_reconnect_lease_granted "
|
||||||
@@ -3722,7 +3747,7 @@ extension NodeAppModel {
|
|||||||
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
|
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
|
||||||
+ "reason=\(reconnectReason)")
|
+ "reason=\(reconnectReason)")
|
||||||
|
|
||||||
let initialWaitMs = min(2_500, max(750, timeoutMs / 4))
|
let initialWaitMs = min(2500, max(750, timeoutMs / 4))
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: watch_request_reconnect_wait "
|
"watch exec approval: watch_request_reconnect_wait "
|
||||||
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
||||||
@@ -3772,8 +3797,8 @@ extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
||||||
wakeId: String
|
wakeId: String) async -> SilentPushWakeAttemptResult
|
||||||
) async -> SilentPushWakeAttemptResult {
|
{
|
||||||
let startedAt = Date()
|
let startedAt = Date()
|
||||||
let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in
|
let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in
|
||||||
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
@@ -3817,8 +3842,7 @@ extension NodeAppModel {
|
|||||||
let data = try await self.operatorGateway.request(
|
let data = try await self.operatorGateway.request(
|
||||||
method: "voicewake.get",
|
method: "voicewake.get",
|
||||||
paramsJSON: "{}",
|
paramsJSON: "{}",
|
||||||
timeoutSeconds: 8
|
timeoutSeconds: 8)
|
||||||
)
|
|
||||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3876,8 +3900,8 @@ extension NodeAppModel {
|
|||||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !message.isEmpty else { return }
|
guard !message.isEmpty else { return }
|
||||||
self.deepLinkLogger.info(
|
self.deepLinkLogger.info(
|
||||||
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
|
// swiftlint:disable:next line_length
|
||||||
)
|
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)")
|
||||||
|
|
||||||
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
||||||
self.screen.errorText = "Deep link too large (message exceeds "
|
self.screen.errorText = "Deep link too large (message exceeds "
|
||||||
@@ -4173,8 +4197,8 @@ extension NodeAppModel {
|
|||||||
func _test_makeOperatorConnectOptions(
|
func _test_makeOperatorConnectOptions(
|
||||||
clientId: String,
|
clientId: String,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
includeApprovalScope: Bool
|
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||||
) -> GatewayConnectOptions {
|
{
|
||||||
self.makeOperatorConnectOptions(
|
self.makeOperatorConnectOptions(
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
@@ -4244,8 +4268,8 @@ extension NodeAppModel {
|
|||||||
host: String?,
|
host: String?,
|
||||||
nodeId: String?,
|
nodeId: String?,
|
||||||
agentId: String?,
|
agentId: String?,
|
||||||
expiresAtMs: Int?
|
expiresAtMs: Int?) -> ExecApprovalPrompt?
|
||||||
) -> ExecApprovalPrompt? {
|
{
|
||||||
self.makeExecApprovalPrompt(
|
self.makeExecApprovalPrompt(
|
||||||
from: ExecApprovalGetResponse(
|
from: ExecApprovalGetResponse(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -4282,8 +4306,8 @@ extension NodeAppModel {
|
|||||||
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
||||||
token: String?,
|
token: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
storedOperatorScopes: [String]
|
storedOperatorScopes: [String]) -> Bool
|
||||||
) -> Bool {
|
{
|
||||||
self.shouldRequestOperatorApprovalScope(
|
self.shouldRequestOperatorApprovalScope(
|
||||||
token: token,
|
token: token,
|
||||||
password: password,
|
password: password,
|
||||||
@@ -4291,8 +4315,8 @@ extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func _test_clearingBootstrapToken(
|
nonisolated static func _test_clearingBootstrapToken(
|
||||||
in config: GatewayConnectConfig?
|
in config: GatewayConnectConfig?) -> GatewayConnectConfig?
|
||||||
) -> GatewayConnectConfig? {
|
{
|
||||||
self.clearingBootstrapToken(in: config)
|
self.clearingBootstrapToken(in: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4313,7 +4337,6 @@ extension NodeAppModel {
|
|||||||
clientDisplayName: nil),
|
clientDisplayName: nil),
|
||||||
sessionBox: nil)
|
sessionBox: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// swiftlint:enable type_body_length file_length
|
// swiftlint:enable type_body_length file_length
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ final class MotionService: MotionServicing {
|
|||||||
|
|
||||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||||
let pedometer = CMPedometer()
|
let pedometer = CMPedometer()
|
||||||
let payload: OpenClawPedometerPayload = try await withCheckedThrowingContinuation { cont in
|
return try await withCheckedThrowingContinuation { cont in
|
||||||
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||||
if let error {
|
if let error {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
@@ -79,7 +79,6 @@ final class MotionService: MotionServicing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return payload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ private struct AutoDetectStep: View {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ManualEntryStep: View {
|
private struct ManualEntryStep: View {
|
||||||
@@ -229,7 +228,7 @@ private struct ManualEntryStep: View {
|
|||||||
private func manualPortValue() -> Int? {
|
private func manualPortValue() -> Int? {
|
||||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
return Int(trimmed.filter { $0.isNumber })
|
return Int(trimmed.filter(\.isNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetManualForm() {
|
private func resetManualForm() {
|
||||||
@@ -334,7 +333,6 @@ private func resetGatewayConnectionState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ViewBuilder
|
|
||||||
private func gatewayConnectionStatusSection(
|
private func gatewayConnectionStatusSection(
|
||||||
appModel: NodeAppModel,
|
appModel: NodeAppModel,
|
||||||
gatewayController: GatewayConnectionController,
|
gatewayController: GatewayConnectionController,
|
||||||
@@ -373,8 +371,8 @@ private struct ConnectionStatusBox: View {
|
|||||||
|
|
||||||
static func defaultLines(
|
static func defaultLines(
|
||||||
appModel: NodeAppModel,
|
appModel: NodeAppModel,
|
||||||
gatewayController: GatewayConnectionController
|
gatewayController: GatewayConnectionController) -> [String]
|
||||||
) -> [String] {
|
{
|
||||||
var lines: [String] = [
|
var lines: [String] = [
|
||||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||||
"discovery: \(gatewayController.discoveryStatusText)",
|
"discovery: \(gatewayController.discoveryStatusText)",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ enum OnboardingStateStore {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
|
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
|
||||||
if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
|
if defaults.bool(forKey: self.completedDefaultsKey) { return false }
|
||||||
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
|
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
|
||||||
// should handle reconnecting, and users can always open onboarding manually if needed.
|
// should handle reconnecting, and users can always open onboarding manually if needed.
|
||||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
||||||
@@ -33,28 +33,28 @@ enum OnboardingStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
|
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
|
||||||
defaults.set(true, forKey: Self.completedDefaultsKey)
|
defaults.set(true, forKey: self.completedDefaultsKey)
|
||||||
if let mode {
|
if let mode {
|
||||||
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
|
defaults.set(mode.rawValue, forKey: self.lastModeDefaultsKey)
|
||||||
}
|
}
|
||||||
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
|
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
|
||||||
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey)
|
!defaults.bool(forKey: self.firstRunIntroSeenDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
|
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
|
||||||
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey)
|
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func reset(defaults: UserDefaults = .standard) {
|
static func reset(defaults: UserDefaults = .standard) {
|
||||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||||
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey)
|
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import CoreImage
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreImage
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -151,8 +151,7 @@ struct OnboardingWizardView: View {
|
|||||||
#selector(UIResponder.resignFirstResponder),
|
#selector(UIResponder.resignFirstResponder),
|
||||||
to: nil,
|
to: nil,
|
||||||
from: nil,
|
from: nil,
|
||||||
for: nil
|
for: nil)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,137 +159,136 @@ struct OnboardingWizardView: View {
|
|||||||
.gatewayTrustPromptAlert()
|
.gatewayTrustPromptAlert()
|
||||||
.alert("QR Scanner Unavailable", isPresented: Binding(
|
.alert("QR Scanner Unavailable", isPresented: Binding(
|
||||||
get: { self.scannerError != nil },
|
get: { self.scannerError != nil },
|
||||||
set: { if !$0 { self.scannerError = nil } }
|
set: { if !$0 { self.scannerError = nil } }))
|
||||||
)) {
|
{
|
||||||
Button("OK", role: .cancel) {}
|
Button("OK", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(self.scannerError ?? "")
|
Text(self.scannerError ?? "")
|
||||||
}
|
}
|
||||||
.sheet(isPresented: self.$showQRScanner) {
|
.sheet(isPresented: self.$showQRScanner) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
QRScannerView(
|
QRScannerView(
|
||||||
onGatewayLink: { link in
|
onGatewayLink: { link in
|
||||||
self.handleScannedLink(link)
|
self.handleScannedLink(link)
|
||||||
},
|
},
|
||||||
onError: { error in
|
onError: { error in
|
||||||
self.showQRScanner = false
|
self.showQRScanner = false
|
||||||
self.statusLine = "Scanner error: \(error)"
|
self.statusLine = "Scanner error: \(error)"
|
||||||
self.scannerError = error
|
self.scannerError = error
|
||||||
},
|
},
|
||||||
onDismiss: {
|
onDismiss: {
|
||||||
self.showQRScanner = false
|
self.showQRScanner = false
|
||||||
})
|
})
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.navigationTitle("Scan QR Code")
|
.navigationTitle("Scan QR Code")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("Cancel") { self.showQRScanner = false }
|
Button("Cancel") { self.showQRScanner = false }
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||||
Label("Photos", systemImage: "photo")
|
Label("Photos", systemImage: "photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.selectedPhoto) { _, newValue in
|
||||||
|
guard let item = newValue else { return }
|
||||||
|
self.selectedPhoto = nil
|
||||||
|
Task {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||||||
|
self.showQRScanner = false
|
||||||
|
self.scannerError = "Could not load the selected image."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let message = self.detectQRCode(from: data) {
|
||||||
|
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
|
||||||
|
self.handleScannedLink(link)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let url = URL(string: message),
|
||||||
|
let route = DeepLinkParser.parse(url),
|
||||||
|
case let .gateway(link) = route
|
||||||
|
{
|
||||||
|
self.handleScannedLink(link)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: self.selectedPhoto) { _, newValue in
|
|
||||||
guard let item = newValue else { return }
|
|
||||||
self.selectedPhoto = nil
|
|
||||||
Task {
|
|
||||||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
|
||||||
self.showQRScanner = false
|
self.showQRScanner = false
|
||||||
self.scannerError = "Could not load the selected image."
|
self.scannerError = "No valid QR code found in the selected image."
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if let message = self.detectQRCode(from: data) {
|
|
||||||
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
|
|
||||||
self.handleScannedLink(link)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let url = URL(string: message),
|
|
||||||
let route = DeepLinkParser.parse(url),
|
|
||||||
case let .gateway(link) = route
|
|
||||||
{
|
|
||||||
self.handleScannedLink(link)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.showQRScanner = false
|
|
||||||
self.scannerError = "No valid QR code found in the selected image."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
if let currentProblem = self.currentProblem {
|
||||||
if let currentProblem = self.currentProblem {
|
GatewayProblemDetailsSheet(
|
||||||
GatewayProblemDetailsSheet(
|
problem: currentProblem,
|
||||||
problem: currentProblem,
|
primaryActionTitle: "Retry",
|
||||||
primaryActionTitle: "Retry",
|
onPrimaryAction: {
|
||||||
onPrimaryAction: {
|
Task { await self.retryLastAttempt() }
|
||||||
Task { await self.retryLastAttempt() }
|
})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
.onAppear {
|
||||||
.onAppear {
|
self.initializeState()
|
||||||
self.initializeState()
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
self.discoveryRestartTask?.cancel()
|
|
||||||
self.discoveryRestartTask = nil
|
|
||||||
}
|
|
||||||
.onChange(of: self.discoveryDomain) { _, _ in
|
|
||||||
self.scheduleDiscoveryRestart()
|
|
||||||
}
|
|
||||||
.onChange(of: self.manualPortText) { _, newValue in
|
|
||||||
let digits = newValue.filter(\.isNumber)
|
|
||||||
if digits != newValue {
|
|
||||||
self.manualPortText = digits
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
guard let parsed = Int(digits), parsed > 0 else {
|
.onDisappear {
|
||||||
self.manualPort = 0
|
self.discoveryRestartTask?.cancel()
|
||||||
return
|
self.discoveryRestartTask = nil
|
||||||
}
|
}
|
||||||
self.manualPort = min(parsed, 65535)
|
.onChange(of: self.discoveryDomain) { _, _ in
|
||||||
}
|
self.scheduleDiscoveryRestart()
|
||||||
.onChange(of: self.manualPort) { _, newValue in
|
|
||||||
let normalized = newValue > 0 ? String(newValue) : ""
|
|
||||||
if self.manualPortText != normalized {
|
|
||||||
self.manualPortText = normalized
|
|
||||||
}
|
}
|
||||||
}
|
.onChange(of: self.manualPortText) { _, newValue in
|
||||||
.onChange(of: self.gatewayToken) { _, newValue in
|
let digits = newValue.filter(\.isNumber)
|
||||||
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
|
if digits != newValue {
|
||||||
}
|
self.manualPortText = digits
|
||||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
return
|
||||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
}
|
||||||
}
|
guard let parsed = Int(digits), parsed > 0 else {
|
||||||
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
self.manualPort = 0
|
||||||
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
return
|
||||||
}
|
}
|
||||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
self.manualPort = min(parsed, 65535)
|
||||||
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
}
|
||||||
}
|
.onChange(of: self.manualPort) { _, newValue in
|
||||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
let normalized = newValue > 0 ? String(newValue) : ""
|
||||||
guard newValue != nil else { return }
|
if self.manualPortText != normalized {
|
||||||
self.showQRScanner = false
|
self.manualPortText = normalized
|
||||||
self.statusLine = "Connected."
|
}
|
||||||
if !self.didMarkCompleted, let selectedMode {
|
}
|
||||||
OnboardingStateStore.markCompleted(mode: selectedMode)
|
.onChange(of: self.gatewayToken) { _, newValue in
|
||||||
self.didMarkCompleted = true
|
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
|
||||||
|
}
|
||||||
|
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||||
|
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
||||||
|
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||||
|
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||||
|
guard newValue != nil else { return }
|
||||||
|
self.showQRScanner = false
|
||||||
|
self.statusLine = "Connected."
|
||||||
|
if !self.didMarkCompleted, let selectedMode {
|
||||||
|
OnboardingStateStore.markCompleted(mode: selectedMode)
|
||||||
|
self.didMarkCompleted = true
|
||||||
|
}
|
||||||
|
self.onClose()
|
||||||
|
}
|
||||||
|
.onChange(of: self.scenePhase) { _, newValue in
|
||||||
|
guard newValue == .active else { return }
|
||||||
|
self.attemptAutomaticPairingResumeIfNeeded()
|
||||||
|
}
|
||||||
|
.onReceive(Self.pairingAutoResumeTicker) { _ in
|
||||||
|
self.attemptAutomaticPairingResumeIfNeeded()
|
||||||
}
|
}
|
||||||
self.onClose()
|
|
||||||
}
|
|
||||||
.onChange(of: self.scenePhase) { _, newValue in
|
|
||||||
guard newValue == .active else { return }
|
|
||||||
self.attemptAutomaticPairingResumeIfNeeded()
|
|
||||||
}
|
|
||||||
.onReceive(Self.pairingAutoResumeTicker) { _ in
|
|
||||||
self.attemptAutomaticPairingResumeIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var introStep: some View {
|
private var introStep: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -369,7 +367,6 @@ struct OnboardingWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var welcomeStep: some View {
|
private var welcomeStep: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -712,7 +709,6 @@ struct OnboardingWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||||
Section(title) {
|
Section(title) {
|
||||||
TextField("Host", text: self.$manualHost)
|
TextField("Host", text: self.$manualHost)
|
||||||
@@ -868,8 +864,7 @@ struct OnboardingWizardView: View {
|
|||||||
let detector = CIDetector(
|
let detector = CIDetector(
|
||||||
ofType: CIDetectorTypeQRCode,
|
ofType: CIDetectorTypeQRCode,
|
||||||
context: nil,
|
context: nil,
|
||||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
|
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||||
)
|
|
||||||
let features = detector?.features(in: ciImage) ?? []
|
let features = detector?.features(in: ciImage) ?? []
|
||||||
for feature in features {
|
for feature in features {
|
||||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||||
@@ -891,6 +886,7 @@ struct OnboardingWizardView: View {
|
|||||||
self.connectMessage = nil
|
self.connectMessage = nil
|
||||||
self.step = target
|
self.step = target
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canConnectManual: Bool {
|
private var canConnectManual: Bool {
|
||||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
||||||
@@ -919,7 +915,7 @@ struct OnboardingWizardView: View {
|
|||||||
if self.selectedMode == nil {
|
if self.selectedMode == nil {
|
||||||
self.selectedMode = OnboardingStateStore.lastMode()
|
self.selectedMode = OnboardingStateStore.lastMode()
|
||||||
}
|
}
|
||||||
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
|
if self.selectedMode == .developerLocal, self.manualHost == "openclaw.local" {
|
||||||
self.manualHost = "localhost"
|
self.manualHost = "localhost"
|
||||||
self.manualTLS = false
|
self.manualTLS = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import SwiftUI
|
import BackgroundTasks
|
||||||
import Foundation
|
import Foundation
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import os
|
import os
|
||||||
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import BackgroundTasks
|
|
||||||
@preconcurrency import UserNotifications
|
@preconcurrency import UserNotifications
|
||||||
|
|
||||||
private struct PendingWatchPromptAction {
|
private struct PendingWatchPromptAction {
|
||||||
@@ -88,16 +88,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||||||
self.appModel ?? OpenClawAppModelRegistry.appModel
|
self.appModel ?? OpenClawAppModelRegistry.appModel
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||||
self.resolvedAppModel()
|
self.resolvedAppModel()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||||
) -> Bool
|
|
||||||
{
|
{
|
||||||
GatewayDiagnostics.log("app delegate: didFinishLaunching")
|
GatewayDiagnostics.log("app delegate: didFinishLaunching")
|
||||||
if self.appModel == nil {
|
if self.appModel == nil {
|
||||||
@@ -151,7 +150,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||||||
guard let appModel = self.resolvedAppModel() else {
|
guard let appModel = self.resolvedAppModel() else {
|
||||||
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
|
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
|
||||||
== ExecApprovalNotificationBridge.requestedKind,
|
== ExecApprovalNotificationBridge.requestedKind,
|
||||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||||
{
|
{
|
||||||
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
|
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
|
||||||
}
|
}
|
||||||
@@ -179,8 +178,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||||||
private func registerBackgroundWakeRefreshTask() {
|
private func registerBackgroundWakeRefreshTask() {
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier,
|
forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier,
|
||||||
using: nil
|
using: nil)
|
||||||
) { [weak self] task in
|
{ [weak self] task in
|
||||||
guard let refreshTask = task as? BGAppRefreshTask else {
|
guard let refreshTask = task as? BGAppRefreshTask else {
|
||||||
task.setTaskCompleted(success: false)
|
task.setTaskCompleted(success: false)
|
||||||
return
|
return
|
||||||
@@ -196,17 +195,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
|||||||
try BGTaskScheduler.shared.submit(request)
|
try BGTaskScheduler.shared.submit(request)
|
||||||
let scheduledLogMessage =
|
let scheduledLogMessage =
|
||||||
"Scheduled background wake refresh reason=\(reason) "
|
"Scheduled background wake refresh reason=\(reason) "
|
||||||
+ "delaySeconds=\(max(60, delay))"
|
+ "delaySeconds=\(max(60, delay))"
|
||||||
self.backgroundWakeLogger.info(
|
self.backgroundWakeLogger.info(
|
||||||
"\(scheduledLogMessage, privacy: .public)"
|
"\(scheduledLogMessage, privacy: .public)")
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
let failedLogMessage =
|
let failedLogMessage =
|
||||||
"Failed scheduling background wake refresh reason=\(reason) "
|
"Failed scheduling background wake refresh reason=\(reason) "
|
||||||
+ "error=\(error.localizedDescription)"
|
+ "error=\(error.localizedDescription)"
|
||||||
self.backgroundWakeLogger.error(
|
self.backgroundWakeLogger.error(
|
||||||
"\(failedLogMessage, privacy: .public)"
|
"\(failedLogMessage, privacy: .public)")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,14 +472,13 @@ enum WatchPromptNotificationBridge {
|
|||||||
|
|
||||||
private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] {
|
private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] {
|
||||||
actions.enumerated().map { index, action in
|
actions.enumerated().map { index, action in
|
||||||
let identifier: String
|
let identifier: String = switch index {
|
||||||
switch index {
|
|
||||||
case 0:
|
case 0:
|
||||||
identifier = self.actionPrimaryIdentifier
|
self.actionPrimaryIdentifier
|
||||||
case 1:
|
case 1:
|
||||||
identifier = self.actionSecondaryIdentifier
|
self.actionSecondaryIdentifier
|
||||||
default:
|
default:
|
||||||
identifier = "\(self.actionIdentifierPrefix)\(index)"
|
"\(self.actionIdentifierPrefix)\(index)"
|
||||||
}
|
}
|
||||||
return UNNotificationAction(
|
return UNNotificationAction(
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
@@ -494,12 +490,12 @@ enum WatchPromptNotificationBridge {
|
|||||||
private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions {
|
private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions {
|
||||||
switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||||
case "destructive":
|
case "destructive":
|
||||||
return [.destructive]
|
[.destructive]
|
||||||
case "foreground":
|
case "foreground":
|
||||||
// For mirrored watch actions, keep handling in background when possible.
|
// For mirrored watch actions, keep handling in background when possible.
|
||||||
return []
|
[]
|
||||||
default:
|
default:
|
||||||
return []
|
[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +506,7 @@ enum WatchPromptNotificationBridge {
|
|||||||
case .authorized, .provisional, .ephemeral:
|
case .authorized, .provisional, .ephemeral:
|
||||||
return true
|
return true
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
if !granted { return false }
|
if !granted { return false }
|
||||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||||
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
||||||
@@ -540,8 +536,8 @@ enum WatchPromptNotificationBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func notificationAuthorizationStatus(
|
private static func notificationAuthorizationStatus(
|
||||||
center: UNUserNotificationCenter
|
center: UNUserNotificationCenter) async -> UNAuthorizationStatus
|
||||||
) async -> UNAuthorizationStatus {
|
{
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
center.getNotificationSettings { settings in
|
center.getNotificationSettings { settings in
|
||||||
continuation.resume(returning: settings.authorizationStatus)
|
continuation.resume(returning: settings.authorizationStatus)
|
||||||
@@ -565,8 +561,8 @@ enum WatchPromptNotificationBridge {
|
|||||||
|
|
||||||
private static func addNotificationRequest(
|
private static func addNotificationRequest(
|
||||||
_ request: UNNotificationRequest,
|
_ request: UNNotificationRequest,
|
||||||
center: UNUserNotificationCenter
|
center: UNUserNotificationCenter) async throws
|
||||||
) async throws {
|
{
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
center.add(request) { error in
|
center.add(request) { error in
|
||||||
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
|
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
@preconcurrency import UserNotifications
|
||||||
|
|
||||||
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
|
struct ExecApprovalNotificationPrompt: Equatable {
|
||||||
let approvalId: String
|
let approvalId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,8 +38,7 @@ enum ExecApprovalNotificationBridge {
|
|||||||
|
|
||||||
static func parsePrompt(
|
static func parsePrompt(
|
||||||
actionIdentifier: String,
|
actionIdentifier: String,
|
||||||
userInfo: [AnyHashable: Any]
|
userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
|
||||||
) -> ExecApprovalNotificationPrompt?
|
|
||||||
{
|
{
|
||||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
||||||
|| actionIdentifier == self.reviewActionIdentifier
|
|| actionIdentifier == self.reviewActionIdentifier
|
||||||
@@ -54,8 +53,7 @@ enum ExecApprovalNotificationBridge {
|
|||||||
@MainActor
|
@MainActor
|
||||||
static func handleResolvedPushIfNeeded(
|
static func handleResolvedPushIfNeeded(
|
||||||
userInfo: [AnyHashable: Any],
|
userInfo: [AnyHashable: Any],
|
||||||
notificationCenter: NotificationCentering
|
notificationCenter: NotificationCentering) async -> Bool
|
||||||
) async -> Bool
|
|
||||||
{
|
{
|
||||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||||
let approvalId = self.approvalID(from: userInfo)
|
let approvalId = self.approvalID(from: userInfo)
|
||||||
@@ -70,8 +68,8 @@ enum ExecApprovalNotificationBridge {
|
|||||||
@MainActor
|
@MainActor
|
||||||
static func removeNotifications(
|
static func removeNotifications(
|
||||||
forApprovalID approvalId: String,
|
forApprovalID approvalId: String,
|
||||||
notificationCenter: NotificationCentering
|
notificationCenter: NotificationCentering) async
|
||||||
) async {
|
{
|
||||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !normalizedID.isEmpty else { return }
|
guard !normalizedID.isEmpty else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ actor PushRegistrationManager {
|
|||||||
}
|
}
|
||||||
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
|
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!installationId.isEmpty
|
!installationId.isEmpty
|
||||||
else {
|
else {
|
||||||
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
|
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ actor PushRegistrationManager {
|
|||||||
guard let expiresAtMs else { return true }
|
guard let expiresAtMs else { return true }
|
||||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
||||||
return expiresAtMs <= nowMs + 60_000
|
return expiresAtMs <= nowMs + 60000
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sha256Hex(_ value: String) -> String {
|
private static func sha256Hex(_ value: String) -> String {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum PushRelayError: LocalizedError {
|
|||||||
case .unsupportedAppAttest:
|
case .unsupportedAppAttest:
|
||||||
"App Attest unavailable on this device"
|
"App Attest unavailable on this device"
|
||||||
case .missingReceipt:
|
case .missingReceipt:
|
||||||
"App Store receipt missing after refresh"
|
"App Store app transaction missing after refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,33 +85,6 @@ private struct RelayErrorResponse: Decodable {
|
|||||||
var reason: String?
|
var reason: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
|
|
||||||
private var continuation: CheckedContinuation<Void, Error>?
|
|
||||||
private var activeRequest: SKReceiptRefreshRequest?
|
|
||||||
|
|
||||||
func refresh() async throws {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
self.continuation = continuation
|
|
||||||
let request = SKReceiptRefreshRequest()
|
|
||||||
self.activeRequest = request
|
|
||||||
request.delegate = self
|
|
||||||
request.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestDidFinish(_ request: SKRequest) {
|
|
||||||
self.continuation?.resume(returning: ())
|
|
||||||
self.continuation = nil
|
|
||||||
self.activeRequest = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
|
||||||
self.continuation?.resume(throwing: error)
|
|
||||||
self.continuation = nil
|
|
||||||
self.activeRequest = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PushRelayAppAttestProof {
|
private struct PushRelayAppAttestProof {
|
||||||
var keyId: String
|
var keyId: String
|
||||||
var attestationObject: String?
|
var attestationObject: String?
|
||||||
@@ -197,25 +170,27 @@ private final class PushRelayAppAttestService {
|
|||||||
|
|
||||||
private final class PushRelayReceiptProvider {
|
private final class PushRelayReceiptProvider {
|
||||||
func loadReceiptBase64() async throws -> String {
|
func loadReceiptBase64() async throws -> String {
|
||||||
if let receipt = self.readReceiptData() {
|
do {
|
||||||
return receipt.base64EncodedString()
|
let result = try await AppTransaction.shared
|
||||||
|
return try Self.appTransactionBase64(result)
|
||||||
|
} catch {
|
||||||
|
let refreshed = try await AppTransaction.refresh()
|
||||||
|
return try Self.appTransactionBase64(refreshed)
|
||||||
}
|
}
|
||||||
let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
|
|
||||||
try await refreshCoordinator.refresh()
|
|
||||||
if let refreshed = self.readReceiptData() {
|
|
||||||
return refreshed.base64EncodedString()
|
|
||||||
}
|
|
||||||
throw PushRelayError.missingReceipt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readReceiptData() -> Data? {
|
private static func appTransactionBase64(
|
||||||
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
_ result: StoreKit.VerificationResult<AppTransaction>) throws -> String
|
||||||
guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
|
{
|
||||||
return data
|
let jws = result.jwsRepresentation.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !jws.isEmpty else {
|
||||||
|
throw PushRelayError.missingReceipt
|
||||||
|
}
|
||||||
|
return Data(jws.utf8).base64EncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||||
final class PushRelayClient: @unchecked Sendable {
|
final class PushRelayClient: @unchecked Sendable {
|
||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
@@ -294,8 +269,7 @@ final class PushRelayClient: @unchecked Sendable {
|
|||||||
status: status,
|
status: status,
|
||||||
message: Self.decodeErrorMessage(data: data))
|
message: Self.decodeErrorMessage(data: data))
|
||||||
}
|
}
|
||||||
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
|
return try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||||
return decoded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ final class RemindersService: RemindersServicing {
|
|||||||
let filtered = (items ?? []).filter { reminder in
|
let filtered = (items ?? []).filter { reminder in
|
||||||
switch statusFilter {
|
switch statusFilter {
|
||||||
case .all:
|
case .all:
|
||||||
return true
|
true
|
||||||
case .completed:
|
case .completed:
|
||||||
return reminder.isCompleted
|
reminder.isCompleted
|
||||||
case .incomplete:
|
case .incomplete:
|
||||||
return !reminder.isCompleted
|
!reminder.isCompleted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let selected = Array(filtered.prefix(limit))
|
let selected = Array(filtered.prefix(limit))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import OpenClawProtocol
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import OpenClawProtocol
|
|
||||||
|
|
||||||
struct RootCanvas: View {
|
struct RootCanvas: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@@ -262,7 +262,7 @@ struct RootCanvas: View {
|
|||||||
eyebrow: "Connected to \(gatewayLabel)",
|
eyebrow: "Connected to \(gatewayLabel)",
|
||||||
title: "Your agents are ready",
|
title: "Your agents are ready",
|
||||||
subtitle:
|
subtitle:
|
||||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||||
gatewayLabel: gatewayLabel,
|
gatewayLabel: gatewayLabel,
|
||||||
activeAgentName: self.appModel.activeAgentName,
|
activeAgentName: self.appModel.activeAgentName,
|
||||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||||
@@ -276,7 +276,7 @@ struct RootCanvas: View {
|
|||||||
eyebrow: "Reconnecting",
|
eyebrow: "Reconnecting",
|
||||||
title: "OpenClaw is syncing back up",
|
title: "OpenClaw is syncing back up",
|
||||||
subtitle:
|
subtitle:
|
||||||
"The gateway session is coming back online. "
|
"The gateway session is coming back online. "
|
||||||
+ "Agent shortcuts should settle automatically in a moment.",
|
+ "Agent shortcuts should settle automatically in a moment.",
|
||||||
gatewayLabel: gatewayLabel,
|
gatewayLabel: gatewayLabel,
|
||||||
activeAgentName: self.appModel.activeAgentName,
|
activeAgentName: self.appModel.activeAgentName,
|
||||||
@@ -291,7 +291,7 @@ struct RootCanvas: View {
|
|||||||
eyebrow: "Welcome to OpenClaw",
|
eyebrow: "Welcome to OpenClaw",
|
||||||
title: "Your phone stays quiet until it is needed",
|
title: "Your phone stays quiet until it is needed",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Pair this device to your gateway to wake it only for real work, "
|
"Pair this device to your gateway to wake it only for real work, "
|
||||||
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
|
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||||
gatewayLabel: gatewayLabel,
|
gatewayLabel: gatewayLabel,
|
||||||
activeAgentName: "Main",
|
activeAgentName: "Main",
|
||||||
@@ -300,7 +300,7 @@ struct RootCanvas: View {
|
|||||||
agentCount: agents.count,
|
agentCount: agents.count,
|
||||||
agents: Array(agents.prefix(4)),
|
agents: Array(agents.prefix(4)),
|
||||||
footer:
|
footer:
|
||||||
"When connected, the gateway can wake the phone with a silent push "
|
"When connected, the gateway can wake the phone with a silent push "
|
||||||
+ "instead of holding an always-on session.")
|
+ "instead of holding an always-on session.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ struct RootCanvas: View {
|
|||||||
let words = self.homeCanvasName(for: agent)
|
let words = self.homeCanvasName(for: agent)
|
||||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||||
.prefix(2)
|
.prefix(2)
|
||||||
let initials = words.compactMap { $0.first }.map(String.init).joined()
|
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||||
if !initials.isEmpty {
|
if !initials.isEmpty {
|
||||||
return initials.uppercased()
|
return initials.uppercased()
|
||||||
}
|
}
|
||||||
@@ -468,8 +468,13 @@ private struct CanvasContent: View {
|
|||||||
var openSettings: () -> Void
|
var openSettings: () -> Void
|
||||||
var retryGatewayConnection: () -> Void
|
var retryGatewayConnection: () -> Void
|
||||||
|
|
||||||
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
private var brightenButtons: Bool {
|
||||||
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
|
self.systemColorScheme == .light
|
||||||
|
}
|
||||||
|
|
||||||
|
private var talkActive: Bool {
|
||||||
|
self.appModel.talkMode.isEnabled || self.talkEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Observation
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ final class ScreenController {
|
|||||||
NSLocalizedDescriptionKey: "web view unavailable",
|
NSLocalizedDescriptionKey: "web view unavailable",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
return try await withCheckedThrowingContinuation { cont in
|
||||||
webView.takeSnapshot(with: config) { image, error in
|
webView.takeSnapshot(with: config) { image, error in
|
||||||
if let error {
|
if let error {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
@@ -209,7 +209,6 @@ final class ScreenController {
|
|||||||
cont.resume(returning: image)
|
cont.resume(returning: image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return image
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachWebView(_ webView: WKWebView) {
|
func attachWebView(_ webView: WKWebView) {
|
||||||
|
|||||||
@@ -319,7 +319,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ protocol MotionServicing: Sendable {
|
|||||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchMessagingStatus: Sendable, Equatable {
|
struct WatchMessagingStatus: Equatable {
|
||||||
var supported: Bool
|
var supported: Bool
|
||||||
var paired: Bool
|
var paired: Bool
|
||||||
var appInstalled: Bool
|
var appInstalled: Bool
|
||||||
@@ -77,7 +77,7 @@ struct WatchMessagingStatus: Sendable, Equatable {
|
|||||||
var activationState: String
|
var activationState: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchQuickReplyEvent: Sendable, Equatable {
|
struct WatchQuickReplyEvent: Equatable {
|
||||||
var replyId: String
|
var replyId: String
|
||||||
var promptId: String
|
var promptId: String
|
||||||
var actionId: String
|
var actionId: String
|
||||||
@@ -88,7 +88,7 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
|
|||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
struct WatchExecApprovalResolveEvent: Equatable {
|
||||||
var replyId: String
|
var replyId: String
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var decision: OpenClawWatchExecApprovalDecision
|
var decision: OpenClawWatchExecApprovalDecision
|
||||||
@@ -96,13 +96,13 @@ struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
|||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable {
|
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
|
||||||
var requestId: String
|
var requestId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int?
|
||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchNotificationSendResult: Sendable, Equatable {
|
struct WatchNotificationSendResult: Equatable {
|
||||||
var deliveredImmediately: Bool
|
var deliveredImmediately: Bool
|
||||||
var queuedForDelivery: Bool
|
var queuedForDelivery: Bool
|
||||||
var transport: String
|
var transport: String
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct NotificationSnapshot: @unchecked Sendable {
|
|||||||
let userInfo: [AnyHashable: Any]
|
let userInfo: [AnyHashable: Any]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationAuthorizationStatus: Sendable {
|
enum NotificationAuthorizationStatus {
|
||||||
case notDetermined
|
case notDetermined
|
||||||
case denied
|
case denied
|
||||||
case authorized
|
case authorized
|
||||||
|
|||||||
@@ -21,13 +21,12 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
|
|||||||
},
|
},
|
||||||
errorHandler: { error in
|
errorHandler: { error in
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||||
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||||
|
|
||||||
private let session: WCSession?
|
private let session: WCSession?
|
||||||
private let callbacksLock = NSLock()
|
private let callbacksLock = NSLock()
|
||||||
@@ -228,7 +227,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
private nonisolated static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||||
WatchMessagingStatus(
|
WatchMessagingStatus(
|
||||||
supported: true,
|
supported: true,
|
||||||
paired: session.isPaired,
|
paired: session.isPaired,
|
||||||
@@ -237,7 +236,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
|||||||
activationState: self.activationStateLabel(session.activationState))
|
activationState: self.activationStateLabel(session.activationState))
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
private nonisolated static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||||
switch state {
|
switch state {
|
||||||
case .notActivated:
|
case .notActivated:
|
||||||
"notActivated"
|
"notActivated"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ enum WatchMessagingPayloadCodec {
|
|||||||
"title": params.title,
|
"title": params.title,
|
||||||
"body": params.body,
|
"body": params.body,
|
||||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||||
"sentAtMs": nowMs(),
|
"sentAtMs": self.nowMs(),
|
||||||
]
|
]
|
||||||
if let promptId = nonEmpty(params.promptId) {
|
if let promptId = nonEmpty(params.promptId) {
|
||||||
payload["promptId"] = promptId
|
payload["promptId"] = promptId
|
||||||
@@ -88,7 +88,7 @@ enum WatchMessagingPayloadCodec {
|
|||||||
{
|
{
|
||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
||||||
"approval": encodeExecApprovalItem(message.approval),
|
"approval": self.encodeExecApprovalItem(message.approval),
|
||||||
]
|
]
|
||||||
if let sentAtMs = message.sentAtMs {
|
if let sentAtMs = message.sentAtMs {
|
||||||
payload["sentAtMs"] = sentAtMs
|
payload["sentAtMs"] = sentAtMs
|
||||||
@@ -140,7 +140,7 @@ enum WatchMessagingPayloadCodec {
|
|||||||
{
|
{
|
||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||||
"approvals": message.approvals.map(encodeExecApprovalItem),
|
"approvals": message.approvals.map(self.encodeExecApprovalItem),
|
||||||
]
|
]
|
||||||
if let sentAtMs = message.sentAtMs {
|
if let sentAtMs = message.sentAtMs {
|
||||||
payload["sentAtMs"] = sentAtMs
|
payload["sentAtMs"] = sentAtMs
|
||||||
@@ -161,11 +161,11 @@ enum WatchMessagingPayloadCodec {
|
|||||||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||||
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
|
||||||
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||||
let note = nonEmpty(payload["note"] as? String)
|
let note = self.nonEmpty(payload["note"] as? String)
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||||
|
|
||||||
return WatchQuickReplyEvent(
|
return WatchQuickReplyEvent(
|
||||||
@@ -192,7 +192,7 @@ enum WatchMessagingPayloadCodec {
|
|||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||||
return WatchExecApprovalResolveEvent(
|
return WatchExecApprovalResolveEvent(
|
||||||
replyId: replyId,
|
replyId: replyId,
|
||||||
@@ -209,7 +209,7 @@ enum WatchMessagingPayloadCodec {
|
|||||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let requestId = nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||||
return WatchExecApprovalSnapshotRequestEvent(
|
return WatchExecApprovalSnapshotRequestEvent(
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
import os
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -247,8 +247,7 @@ struct SettingsTab: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
.background(
|
.background(
|
||||||
.thinMaterial,
|
.thinMaterial,
|
||||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -270,15 +269,17 @@ struct SettingsTab: View {
|
|||||||
self.featureToggle(
|
self.featureToggle(
|
||||||
"Voice Wake",
|
"Voice Wake",
|
||||||
isOn: self.$voiceWakeEnabled,
|
isOn: self.$voiceWakeEnabled,
|
||||||
help: "Enables wake-word activation to start a hands-free session.") { newValue in
|
help: "Enables wake-word activation to start a hands-free session.")
|
||||||
self.appModel.setVoiceWakeEnabled(newValue)
|
{ newValue in
|
||||||
}
|
self.appModel.setVoiceWakeEnabled(newValue)
|
||||||
|
}
|
||||||
self.featureToggle(
|
self.featureToggle(
|
||||||
"Talk Mode",
|
"Talk Mode",
|
||||||
isOn: self.$talkEnabled,
|
isOn: self.$talkEnabled,
|
||||||
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
|
help: "Enables voice conversation mode with your connected OpenClaw agent.")
|
||||||
self.appModel.setTalkEnabled(newValue)
|
{ newValue in
|
||||||
}
|
self.appModel.setTalkEnabled(newValue)
|
||||||
|
}
|
||||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||||
Text(option.label).tag(option.id)
|
Text(option.label).tag(option.id)
|
||||||
@@ -301,8 +302,7 @@ struct SettingsTab: View {
|
|||||||
"Allow Camera",
|
"Allow Camera",
|
||||||
isOn: self.$cameraEnabled,
|
isOn: self.$cameraEnabled,
|
||||||
help: "Allows the gateway to request photos or short video clips "
|
help: "Allows the gateway to request photos or short video clips "
|
||||||
+ "while OpenClaw is foregrounded."
|
+ "while OpenClaw is foregrounded.")
|
||||||
)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
@@ -313,8 +313,7 @@ struct SettingsTab: View {
|
|||||||
message: "Controls location permissions for OpenClaw. "
|
message: "Controls location permissions for OpenClaw. "
|
||||||
+ "Off disables location tools, While Using enables "
|
+ "Off disables location tools, While Using enables "
|
||||||
+ "foreground location, and Always enables "
|
+ "foreground location, and Always enables "
|
||||||
+ "background location."
|
+ "background location.")
|
||||||
)
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -347,8 +346,7 @@ struct SettingsTab: View {
|
|||||||
? (
|
? (
|
||||||
self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
||||||
? "Configured"
|
? "Configured"
|
||||||
: "Not configured"
|
: "Not configured")
|
||||||
)
|
|
||||||
: "Not loaded")
|
: "Not loaded")
|
||||||
LabeledContent(
|
LabeledContent(
|
||||||
"Default Model",
|
"Default Model",
|
||||||
@@ -365,7 +363,7 @@ struct SettingsTab: View {
|
|||||||
isOn: self.$talkButtonEnabled,
|
isOn: self.$talkButtonEnabled,
|
||||||
help: "Shows the Talk control in the main toolbar.")
|
help: "Shows the Talk control in the main toolbar.")
|
||||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||||
.lineLimit(2 ... 6)
|
.lineLimit(2...6)
|
||||||
.textInputAutocapitalization(.sentences)
|
.textInputAutocapitalization(.sentences)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("Default Share Instruction")
|
Text("Default Share Instruction")
|
||||||
@@ -376,8 +374,7 @@ struct SettingsTab: View {
|
|||||||
self.activeFeatureHelp = FeatureHelp(
|
self.activeFeatureHelp = FeatureHelp(
|
||||||
title: "Default Share Instruction",
|
title: "Default Share Instruction",
|
||||||
message: "Appends this instruction when sharing content "
|
message: "Appends this instruction when sharing content "
|
||||||
+ "into OpenClaw from iOS."
|
+ "into OpenClaw from iOS.")
|
||||||
)
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -441,8 +438,7 @@ struct SettingsTab: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(
|
Text(
|
||||||
"This will disconnect, clear saved gateway connection + credentials, "
|
"This will disconnect, clear saved gateway connection + credentials, "
|
||||||
+ "and reopen the onboarding wizard."
|
+ "and reopen the onboarding wizard.")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.alert(item: self.$activeFeatureHelp) { help in
|
.alert(item: self.$activeFeatureHelp) { help in
|
||||||
Alert(
|
Alert(
|
||||||
@@ -635,8 +631,8 @@ struct SettingsTab: View {
|
|||||||
_ title: String,
|
_ title: String,
|
||||||
isOn: Binding<Bool>,
|
isOn: Binding<Bool>,
|
||||||
help: String,
|
help: String,
|
||||||
onChange: ((Bool) -> Void)? = nil
|
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||||
) -> some View {
|
{
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Toggle(title, isOn: isOn)
|
Toggle(title, isOn: isOn)
|
||||||
Button {
|
Button {
|
||||||
@@ -754,8 +750,7 @@ struct SettingsTab: View {
|
|||||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) "
|
"setup code applied host=\(host) port=\(resolvedPort ?? -1) "
|
||||||
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)"
|
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
||||||
)
|
|
||||||
guard let port = resolvedPort else {
|
guard let port = resolvedPort else {
|
||||||
self.setupStatusText = "Failed: invalid port"
|
self.setupStatusText = "Failed: invalid port"
|
||||||
return
|
return
|
||||||
@@ -858,7 +853,7 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") {
|
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
|
||||||
return 443
|
return 443
|
||||||
}
|
}
|
||||||
return 18789
|
return 18789
|
||||||
@@ -868,7 +863,7 @@ struct SettingsTab: View {
|
|||||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return false }
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
|
||||||
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() {
|
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||||
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
|
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
|
||||||
self.setupStatusText = msg
|
self.setupStatusText = msg
|
||||||
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
||||||
@@ -1095,4 +1090,5 @@ struct SettingsTab: View {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:enable type_body_length
|
// swiftlint:enable type_body_length
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ enum StatusActivityBuilder {
|
|||||||
appModel: NodeAppModel,
|
appModel: NodeAppModel,
|
||||||
voiceWakeEnabled: Bool,
|
voiceWakeEnabled: Bool,
|
||||||
cameraHUDText: String?,
|
cameraHUDText: String?,
|
||||||
cameraHUDKind: NodeAppModel.CameraHUDKind?
|
cameraHUDKind: NodeAppModel.CameraHUDKind?) -> StatusPill.Activity?
|
||||||
) -> StatusPill.Activity? {
|
{
|
||||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||||
if appModel.isBackgrounded {
|
if appModel.isBackgrounded {
|
||||||
return StatusPill.Activity(
|
return StatusPill.Activity(
|
||||||
@@ -19,9 +19,9 @@ enum StatusActivityBuilder {
|
|||||||
if let gatewayProblem = appModel.lastGatewayProblem {
|
if let gatewayProblem = appModel.lastGatewayProblem {
|
||||||
switch gatewayProblem.kind {
|
switch gatewayProblem.kind {
|
||||||
case .pairingRequired,
|
case .pairingRequired,
|
||||||
.pairingRoleUpgradeRequired,
|
.pairingRoleUpgradeRequired,
|
||||||
.pairingScopeUpgradeRequired,
|
.pairingScopeUpgradeRequired,
|
||||||
.pairingMetadataUpgradeRequired:
|
.pairingMetadataUpgradeRequired:
|
||||||
return StatusPill.Activity(
|
return StatusPill.Activity(
|
||||||
title: "Approval pending",
|
title: "Approval pending",
|
||||||
systemImage: "person.crop.circle.badge.clock",
|
systemImage: "person.crop.circle.badge.clock",
|
||||||
@@ -93,4 +93,3 @@ enum StatusActivityBuilder {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ private struct StatusGlassCardModifier: ViewModifier {
|
|||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
lineWidth: self.contrast == .increased ? 1.0 : 0.5)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
}
|
}
|
||||||
@@ -32,8 +31,6 @@ extension View {
|
|||||||
StatusGlassCardModifier(
|
StatusGlassCardModifier(
|
||||||
brighten: brighten,
|
brighten: brighten,
|
||||||
verticalPadding: verticalPadding,
|
verticalPadding: verticalPadding,
|
||||||
horizontalPadding: horizontalPadding
|
horizontalPadding: horizontalPadding))
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ struct StatusPill: View {
|
|||||||
.scaleEffect(
|
.scaleEffect(
|
||||||
self.gateway == .connecting && !self.reduceMotion
|
self.gateway == .connecting && !self.reduceMotion
|
||||||
? (self.pulse ? 1.15 : 0.85)
|
? (self.pulse ? 1.15 : 0.85)
|
||||||
: 1.0
|
: 1.0)
|
||||||
)
|
|
||||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||||
|
|
||||||
Text(self.gateway.title)
|
Text(self.gateway.title)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ enum TalkModeGatewayConfigParser {
|
|||||||
config: [String: Any],
|
config: [String: Any],
|
||||||
defaultProvider: String,
|
defaultProvider: String,
|
||||||
defaultModelIdFallback: String,
|
defaultModelIdFallback: String,
|
||||||
defaultSilenceTimeoutMs: Int
|
defaultSilenceTimeoutMs: Int) -> TalkModeGatewayConfigState
|
||||||
) -> TalkModeGatewayConfigState {
|
{
|
||||||
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
|
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
|
||||||
let selection = TalkConfigParsing.selectProviderConfig(
|
let selection = TalkConfigParsing.selectProviderConfig(
|
||||||
talk,
|
talk,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
import OpenClawChatUI
|
import OpenClawChatUI
|
||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import Speech
|
import Speech
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
private var gateway: GatewayNodeSession?
|
private var gateway: GatewayNodeSession?
|
||||||
private var gatewayConnected = false
|
private var gatewayConnected = false
|
||||||
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000
|
private var silenceWindow: TimeInterval = .init(TalkModeManager.defaultSilenceTimeoutMs) / 1000
|
||||||
private var lastAudioActivity: Date?
|
private var lastAudioActivity: Date?
|
||||||
private var noiseFloorSamples: [Double] = []
|
private var noiseFloorSamples: [Double] = []
|
||||||
private var noiseFloor: Double?
|
private var noiseFloor: Double?
|
||||||
@@ -488,16 +488,16 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
private func startRecognition() throws {
|
private func startRecognition() throws {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
if self.allowSimulatorCapture {
|
if self.allowSimulatorCapture {
|
||||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
self.recognitionRequest?.shouldReportPartialResults = true
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !self.allowSimulatorCapture {
|
if !self.allowSimulatorCapture {
|
||||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
self.stopRecognition()
|
self.stopRecognition()
|
||||||
@@ -550,8 +550,7 @@ final class TalkModeManager: NSObject {
|
|||||||
let threshold = min(0.35, max(0.12, avg + 0.10))
|
let threshold = min(0.35, max(0.12, avg + 0.10))
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) "
|
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) "
|
||||||
+ "threshold=\(String(format: "%.3f", threshold))"
|
+ "threshold=\(String(format: "%.3f", threshold))")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,8 +575,7 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"talk speech: recognition started mode=\(String(describing: self.captureMode)) "
|
"talk speech: recognition started mode=\(String(describing: self.captureMode)) "
|
||||||
+ "engineRunning=\(self.audioEngine.isRunning)"
|
+ "engineRunning=\(self.audioEngine.isRunning)")
|
||||||
)
|
|
||||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if let error {
|
if let error {
|
||||||
@@ -722,7 +720,7 @@ final class TalkModeManager: NSObject {
|
|||||||
guard self.isListening, !self.isSpeechOutputActive else { return }
|
guard self.isListening, !self.isSpeechOutputActive else { return }
|
||||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !transcript.isEmpty else { return }
|
guard !transcript.isEmpty else { return }
|
||||||
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max()
|
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap(\.self).max()
|
||||||
guard let lastActivity else { return }
|
guard let lastActivity else { return }
|
||||||
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
||||||
await self.processTranscript(transcript, restartAfter: true)
|
await self.processTranscript(transcript, restartAfter: true)
|
||||||
@@ -733,13 +731,13 @@ final class TalkModeManager: NSObject {
|
|||||||
guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return }
|
guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return }
|
||||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !transcript.isEmpty else { return }
|
guard !transcript.isEmpty else { return }
|
||||||
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max()
|
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap(\.self).max()
|
||||||
guard let lastActivity else { return }
|
guard let lastActivity else { return }
|
||||||
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
||||||
_ = await self.endPushToTalk()
|
_ = await self.endPushToTalk()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guardrail for PTT once so we don't stay open indefinitely.
|
/// Guardrail for PTT once so we don't stay open indefinitely.
|
||||||
private func schedulePTTTimeout(seconds: TimeInterval) {
|
private func schedulePTTTimeout(seconds: TimeInterval) {
|
||||||
guard seconds > 0 else { return }
|
guard seconds > 0 else { return }
|
||||||
let nanos = UInt64(seconds * 1_000_000_000)
|
let nanos = UInt64(seconds * 1_000_000_000)
|
||||||
@@ -1103,7 +1101,10 @@ final class TalkModeManager: NSObject {
|
|||||||
result = await self.mp3Player.play(stream: rawStream)
|
result = await self.mp3Player.play(stream: rawStream)
|
||||||
}
|
}
|
||||||
let duration = Date().timeIntervalSince(started)
|
let duration = Date().timeIntervalSince(started)
|
||||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
self.logger
|
||||||
|
.info(
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
"elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||||
self.lastInterruptedAtSeconds = interruptedAt
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
}
|
}
|
||||||
@@ -1186,9 +1187,9 @@ final class TalkModeManager: NSObject {
|
|||||||
return !route.outputs.contains { output in
|
return !route.outputs.contains { output in
|
||||||
switch output.portType {
|
switch output.portType {
|
||||||
case .builtInSpeaker, .builtInReceiver:
|
case .builtInSpeaker, .builtInReceiver:
|
||||||
return true
|
true
|
||||||
default:
|
default:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1392,8 +1393,7 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
private func consumeIncrementalPrefetchedAudioIfAvailable(
|
private func consumeIncrementalPrefetchedAudioIfAvailable(
|
||||||
for segment: String,
|
for segment: String,
|
||||||
context: IncrementalSpeechContext?
|
context: IncrementalSpeechContext?) async -> IncrementalPrefetchedAudio?
|
||||||
) async -> IncrementalPrefetchedAudio?
|
|
||||||
{
|
{
|
||||||
guard let context else {
|
guard let context else {
|
||||||
self.cancelIncrementalPrefetch()
|
self.cancelIncrementalPrefetch()
|
||||||
@@ -1467,8 +1467,8 @@ final class TalkModeManager: NSObject {
|
|||||||
guard evt.event == "agent", let payload = evt.payload else { continue }
|
guard evt.event == "agent", let payload = evt.payload else { continue }
|
||||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(
|
guard let agentEvent = try? GatewayPayloadDecoding.decode(
|
||||||
payload,
|
payload,
|
||||||
as: OpenClawAgentEventPayload.self
|
as: OpenClawAgentEventPayload.self)
|
||||||
) else {
|
else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
|
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
|
||||||
@@ -1550,8 +1550,7 @@ final class TalkModeManager: NSObject {
|
|||||||
private func makeIncrementalTTSRequest(
|
private func makeIncrementalTTSRequest(
|
||||||
text: String,
|
text: String,
|
||||||
context: IncrementalSpeechContext,
|
context: IncrementalSpeechContext,
|
||||||
outputFormat: String?
|
outputFormat: String?) -> ElevenLabsTTSRequest
|
||||||
) -> ElevenLabsTTSRequest
|
|
||||||
{
|
{
|
||||||
ElevenLabsTTSRequest(
|
ElevenLabsTTSRequest(
|
||||||
text: text,
|
text: text,
|
||||||
@@ -1579,8 +1578,7 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
private static func monitorStreamFailures(
|
private static func monitorStreamFailures(
|
||||||
_ stream: AsyncThrowingStream<Data, Error>,
|
_ stream: AsyncThrowingStream<Data, Error>,
|
||||||
failureBox: StreamFailureBox
|
failureBox: StreamFailureBox) -> AsyncThrowingStream<Data, Error>
|
||||||
) -> AsyncThrowingStream<Data, Error>
|
|
||||||
{
|
{
|
||||||
AsyncThrowingStream { continuation in
|
AsyncThrowingStream { continuation in
|
||||||
let task = Task {
|
let task = Task {
|
||||||
@@ -1622,8 +1620,7 @@ final class TalkModeManager: NSObject {
|
|||||||
private func speakIncrementalSegment(
|
private func speakIncrementalSegment(
|
||||||
_ text: String,
|
_ text: String,
|
||||||
context preferredContext: IncrementalSpeechContext? = nil,
|
context preferredContext: IncrementalSpeechContext? = nil,
|
||||||
prefetchedAudio: IncrementalPrefetchedAudio? = nil
|
prefetchedAudio: IncrementalPrefetchedAudio? = nil) async
|
||||||
) async
|
|
||||||
{
|
{
|
||||||
let context: IncrementalSpeechContext
|
let context: IncrementalSpeechContext
|
||||||
if let preferredContext {
|
if let preferredContext {
|
||||||
@@ -1651,11 +1648,10 @@ final class TalkModeManager: NSObject {
|
|||||||
text: text,
|
text: text,
|
||||||
context: context,
|
context: context,
|
||||||
outputFormat: context.outputFormat)
|
outputFormat: context.outputFormat)
|
||||||
let rawStream: AsyncThrowingStream<Data, Error>
|
let rawStream: AsyncThrowingStream<Data, Error> = if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||||
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||||
rawStream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
|
||||||
} else {
|
} else {
|
||||||
rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
client.streamSynthesize(voiceId: voiceId, request: request)
|
||||||
}
|
}
|
||||||
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
|
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
|
||||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
|
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
|
||||||
@@ -1689,7 +1685,6 @@ final class TalkModeManager: NSObject {
|
|||||||
self.lastInterruptedAtSeconds = interruptedAt
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct IncrementalSpeechBuffer {
|
private struct IncrementalSpeechBuffer {
|
||||||
@@ -1818,7 +1813,7 @@ private struct IncrementalSpeechBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func isSoftBoundary(_ ch: Character, bufferedChars: Int) -> Bool {
|
private static func isSoftBoundary(_ ch: Character, bufferedChars: Int) -> Bool {
|
||||||
bufferedChars >= Self.softBoundaryMinChars && ch.isWhitespace
|
bufferedChars >= self.softBoundaryMinChars && ch.isWhitespace
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1987,8 +1982,7 @@ extension TalkModeManager {
|
|||||||
let res = try await gateway.request(
|
let res = try await gateway.request(
|
||||||
method: "talk.config",
|
method: "talk.config",
|
||||||
paramsJSON: "{\"includeSecrets\":true}",
|
paramsJSON: "{\"includeSecrets\":true}",
|
||||||
timeoutSeconds: 8
|
timeoutSeconds: 8)
|
||||||
)
|
|
||||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||||
guard let config = json["config"] as? [String: Any] else { return }
|
guard let config = json["config"] as? [String: Any] else { return }
|
||||||
let parsed = TalkModeGatewayConfigParser.parse(
|
let parsed = TalkModeGatewayConfigParser.parse(
|
||||||
@@ -2060,7 +2054,7 @@ extension TalkModeManager {
|
|||||||
.allowBluetoothHFP,
|
.allowBluetoothHFP,
|
||||||
.defaultToSpeaker,
|
.defaultToSpeaker,
|
||||||
])
|
])
|
||||||
try? session.setPreferredSampleRate(48_000)
|
try? session.setPreferredSampleRate(48000)
|
||||||
try? session.setPreferredIOBufferDuration(0.02)
|
try? session.setPreferredIOBufferDuration(0.02)
|
||||||
try session.setActive(true, options: [])
|
try session.setActive(true, options: [])
|
||||||
}
|
}
|
||||||
@@ -2101,19 +2095,19 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
|||||||
var shouldLog = false
|
var shouldLog = false
|
||||||
var shouldEmitLevel = false
|
var shouldEmitLevel = false
|
||||||
var count = 0
|
var count = 0
|
||||||
lock.lock()
|
self.lock.lock()
|
||||||
bufferCount += 1
|
self.bufferCount += 1
|
||||||
count = bufferCount
|
count = self.bufferCount
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastLoggedAt) >= 1.0 {
|
if now.timeIntervalSince(self.lastLoggedAt) >= 1.0 {
|
||||||
lastLoggedAt = now
|
self.lastLoggedAt = now
|
||||||
shouldLog = true
|
shouldLog = true
|
||||||
}
|
}
|
||||||
if now.timeIntervalSince(lastLevelEmitAt) >= 0.12 {
|
if now.timeIntervalSince(self.lastLevelEmitAt) >= 0.12 {
|
||||||
lastLevelEmitAt = now
|
self.lastLevelEmitAt = now
|
||||||
shouldEmitLevel = true
|
shouldEmitLevel = true
|
||||||
}
|
}
|
||||||
lock.unlock()
|
self.lock.unlock()
|
||||||
|
|
||||||
let rate = buffer.format.sampleRate
|
let rate = buffer.format.sampleRate
|
||||||
let ch = buffer.format.channelCount
|
let ch = buffer.format.channelCount
|
||||||
@@ -2133,12 +2127,12 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let resolvedRms = rms ?? 0
|
let resolvedRms = rms ?? 0
|
||||||
lock.lock()
|
self.lock.lock()
|
||||||
lastRms = resolvedRms
|
self.lastRms = resolvedRms
|
||||||
if resolvedRms > maxRmsWindow { maxRmsWindow = resolvedRms }
|
if resolvedRms > self.maxRmsWindow { self.maxRmsWindow = resolvedRms }
|
||||||
let maxRms = maxRmsWindow
|
let maxRms = self.maxRmsWindow
|
||||||
if shouldLog { maxRmsWindow = 0 }
|
if shouldLog { self.maxRmsWindow = 0 }
|
||||||
lock.unlock()
|
self.lock.unlock()
|
||||||
|
|
||||||
if shouldEmitLevel, let onLevel {
|
if shouldEmitLevel, let onLevel {
|
||||||
onLevel(resolvedRms)
|
onLevel(resolvedRms)
|
||||||
@@ -2146,9 +2140,8 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
|||||||
|
|
||||||
guard shouldLog else { return }
|
guard shouldLog else { return }
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
"\(self.label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))"
|
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ enum TalkSpeechLocale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func supportedOptions(
|
static func supportedOptions(
|
||||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> [Option]
|
||||||
) -> [Option] {
|
{
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
let dynamic: [Option] = supportedLocales
|
let dynamic: [Option] = supportedLocales
|
||||||
.compactMap { locale in
|
.compactMap { locale in
|
||||||
@@ -33,8 +33,8 @@ enum TalkSpeechLocale {
|
|||||||
gatewaySelection: String?,
|
gatewaySelection: String?,
|
||||||
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
|
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
|
||||||
fallbackLocaleID: String = Self.fallbackLocaleID,
|
fallbackLocaleID: String = Self.fallbackLocaleID,
|
||||||
supportedLocaleIDs: Set<String>
|
supportedLocaleIDs: Set<String>) -> String?
|
||||||
) -> String? {
|
{
|
||||||
TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
|
TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
|
||||||
preferredLocaleIDs: [
|
preferredLocaleIDs: [
|
||||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
|
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
|
||||||
@@ -48,8 +48,10 @@ enum TalkSpeechLocale {
|
|||||||
static func makeRecognizer(
|
static func makeRecognizer(
|
||||||
localSelection: String?,
|
localSelection: String?,
|
||||||
gatewaySelection: String?,
|
gatewaySelection: String?,
|
||||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> (
|
||||||
) -> (recognizer: SFSpeechRecognizer?, localeID: String?) {
|
recognizer: SFSpeechRecognizer?,
|
||||||
|
localeID: String?)
|
||||||
|
{
|
||||||
let supportedIDs = Set(supportedLocales.map(\.identifier))
|
let supportedIDs = Set(supportedLocales.map(\.identifier))
|
||||||
guard let localeID = self.resolvedLocaleID(
|
guard let localeID = self.resolvedLocaleID(
|
||||||
localSelection: localSelection,
|
localSelection: localSelection,
|
||||||
|
|||||||
@@ -1,34 +1,89 @@
|
|||||||
Sources/Gateway/GatewayConnectionController.swift
|
Sources/Calendar/CalendarService.swift
|
||||||
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
|
||||||
Sources/Gateway/GatewayDiscoveryModel.swift
|
|
||||||
Sources/Gateway/GatewaySettingsStore.swift
|
|
||||||
Sources/Gateway/KeychainStore.swift
|
|
||||||
Sources/Camera/CameraController.swift
|
Sources/Camera/CameraController.swift
|
||||||
|
Sources/Capabilities/NodeCapabilityRouter.swift
|
||||||
|
Sources/Chat/ChatSheet.swift
|
||||||
|
Sources/Chat/IOSGatewayChatTransport.swift
|
||||||
|
Sources/Contacts/ContactsService.swift
|
||||||
Sources/Device/DeviceInfoHelper.swift
|
Sources/Device/DeviceInfoHelper.swift
|
||||||
Sources/Device/DeviceStatusService.swift
|
Sources/Device/DeviceStatusService.swift
|
||||||
Sources/Device/NetworkStatusService.swift
|
Sources/Device/NetworkStatusService.swift
|
||||||
Sources/Chat/ChatSheet.swift
|
Sources/Device/NodeDisplayName.swift
|
||||||
Sources/Chat/IOSGatewayChatTransport.swift
|
Sources/EventKit/EventKitAuthorization.swift
|
||||||
Sources/OpenClawApp.swift
|
Sources/Gateway/DeepLinkAgentPromptAlert.swift
|
||||||
|
Sources/Gateway/ExecApprovalPromptDialog.swift
|
||||||
|
Sources/Gateway/GatewayConnectConfig.swift
|
||||||
|
Sources/Gateway/GatewayConnectionController.swift
|
||||||
|
Sources/Gateway/GatewayConnectionIssue.swift
|
||||||
|
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||||
|
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||||
|
Sources/Gateway/GatewayHealthMonitor.swift
|
||||||
|
Sources/Gateway/GatewayProblemView.swift
|
||||||
|
Sources/Gateway/GatewayQuickSetupSheet.swift
|
||||||
|
Sources/Gateway/GatewayServiceResolver.swift
|
||||||
|
Sources/Gateway/GatewaySettingsStore.swift
|
||||||
|
Sources/Gateway/GatewaySetupCode.swift
|
||||||
|
Sources/Gateway/GatewayTrustPromptAlert.swift
|
||||||
|
Sources/Gateway/KeychainStore.swift
|
||||||
|
Sources/Gateway/TCPProbe.swift
|
||||||
|
Sources/HomeToolbar.swift
|
||||||
|
Sources/LiveActivity/LiveActivityManager.swift
|
||||||
|
Sources/LiveActivity/OpenClawActivityAttributes.swift
|
||||||
Sources/Location/LocationService.swift
|
Sources/Location/LocationService.swift
|
||||||
Sources/Model/NodeAppModel.swift
|
Sources/Location/SignificantLocationMonitor.swift
|
||||||
|
Sources/Media/PhotoLibraryService.swift
|
||||||
Sources/Model/NodeAppModel+Canvas.swift
|
Sources/Model/NodeAppModel+Canvas.swift
|
||||||
|
Sources/Model/NodeAppModel+WatchNotifyNormalization.swift
|
||||||
|
Sources/Model/NodeAppModel.swift
|
||||||
Sources/Model/WatchReplyCoordinator.swift
|
Sources/Model/WatchReplyCoordinator.swift
|
||||||
|
Sources/Motion/MotionService.swift
|
||||||
|
Sources/Onboarding/GatewayOnboardingView.swift
|
||||||
|
Sources/Onboarding/OnboardingStateStore.swift
|
||||||
|
Sources/Onboarding/OnboardingWizardView.swift
|
||||||
|
Sources/Onboarding/QRScannerView.swift
|
||||||
|
Sources/OpenClawApp.swift
|
||||||
|
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||||
|
Sources/Push/PushBuildConfig.swift
|
||||||
|
Sources/Push/PushRegistrationManager.swift
|
||||||
|
Sources/Push/PushRelayClient.swift
|
||||||
|
Sources/Push/PushRelayKeychainStore.swift
|
||||||
|
Sources/Reminders/RemindersService.swift
|
||||||
Sources/RootCanvas.swift
|
Sources/RootCanvas.swift
|
||||||
Sources/RootTabs.swift
|
Sources/RootTabs.swift
|
||||||
|
Sources/RootView.swift
|
||||||
Sources/Screen/ScreenController.swift
|
Sources/Screen/ScreenController.swift
|
||||||
Sources/Screen/ScreenRecordService.swift
|
Sources/Screen/ScreenRecordService.swift
|
||||||
Sources/Screen/ScreenTab.swift
|
Sources/Screen/ScreenTab.swift
|
||||||
Sources/Screen/ScreenWebView.swift
|
Sources/Screen/ScreenWebView.swift
|
||||||
|
Sources/Services/NodeServiceProtocols.swift
|
||||||
|
Sources/Services/NotificationService.swift
|
||||||
|
Sources/Services/WatchConnectivityTransport.swift
|
||||||
|
Sources/Services/WatchMessagingPayloadCodec.swift
|
||||||
|
Sources/Services/WatchMessagingService.swift
|
||||||
Sources/SessionKey.swift
|
Sources/SessionKey.swift
|
||||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||||
Sources/Settings/SettingsTab.swift
|
Sources/Settings/SettingsTab.swift
|
||||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||||
|
Sources/Status/GatewayActionsDialog.swift
|
||||||
|
Sources/Status/GatewayStatusBuilder.swift
|
||||||
|
Sources/Status/StatusActivityBuilder.swift
|
||||||
|
Sources/Status/StatusGlassCard.swift
|
||||||
Sources/Status/StatusPill.swift
|
Sources/Status/StatusPill.swift
|
||||||
Sources/Status/VoiceWakeToast.swift
|
Sources/Status/VoiceWakeToast.swift
|
||||||
|
Sources/Voice/TalkDefaults.swift
|
||||||
|
Sources/Voice/TalkModeGatewayConfig.swift
|
||||||
|
Sources/Voice/TalkModeManager.swift
|
||||||
|
Sources/Voice/TalkOrbOverlay.swift
|
||||||
|
Sources/Voice/TalkSpeechLocale.swift
|
||||||
Sources/Voice/VoiceTab.swift
|
Sources/Voice/VoiceTab.swift
|
||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
|
ShareExtension/ShareViewController.swift
|
||||||
|
ActivityWidget/OpenClawActivityWidgetBundle.swift
|
||||||
|
ActivityWidget/OpenClawLiveActivity.swift
|
||||||
|
WatchExtension/Sources/OpenClawWatchApp.swift
|
||||||
|
WatchExtension/Sources/WatchConnectivityReceiver.swift
|
||||||
|
WatchExtension/Sources/WatchInboxStore.swift
|
||||||
|
WatchExtension/Sources/WatchInboxView.swift
|
||||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
|
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
|
||||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
|
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
|
||||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift
|
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift
|
||||||
@@ -61,9 +116,3 @@ Sources/Voice/VoiceWakePreferences.swift
|
|||||||
../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift
|
../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift
|
||||||
../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift
|
../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift
|
||||||
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
||||||
Sources/Voice/TalkModeManager.swift
|
|
||||||
Sources/Voice/TalkOrbOverlay.swift
|
|
||||||
Sources/LiveActivity/OpenClawActivityAttributes.swift
|
|
||||||
Sources/LiveActivity/LiveActivityManager.swift
|
|
||||||
ActivityWidget/OpenClawActivityWidgetBundle.swift
|
|
||||||
ActivityWidget/OpenClawLiveActivity.swift
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
|
||||||
struct WatchReplyDraft: Sendable {
|
struct WatchReplyDraft {
|
||||||
var replyId: String
|
var replyId: String
|
||||||
var promptId: String
|
var promptId: String
|
||||||
var actionId: String
|
var actionId: String
|
||||||
@@ -11,7 +11,7 @@ struct WatchReplyDraft: Sendable {
|
|||||||
var sentAtMs: Int
|
var sentAtMs: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchReplySendResult: Sendable, Equatable {
|
struct WatchReplySendResult: Equatable {
|
||||||
var deliveredImmediately: Bool
|
var deliveredImmediately: Bool
|
||||||
var queuedForDelivery: Bool
|
var queuedForDelivery: Bool
|
||||||
var transport: String
|
var transport: String
|
||||||
@@ -61,14 +61,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|||||||
let payload = Self.encodeSnapshotRequestPayload(request)
|
let payload = Self.encodeSnapshotRequestPayload(request)
|
||||||
if session.isReachable {
|
if session.isReachable {
|
||||||
do {
|
do {
|
||||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
// swiftlint:disable multiline_arguments
|
||||||
(continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<
|
||||||
|
Void,
|
||||||
|
Error,
|
||||||
|
>) in
|
||||||
session.sendMessage(payload, replyHandler: { _ in
|
session.sendMessage(payload, replyHandler: { _ in
|
||||||
continuation.resume(returning: ())
|
continuation.resume(returning: ())
|
||||||
}, errorHandler: { error in
|
}, errorHandler: { error in
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// swiftlint:enable multiline_arguments
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to queued delivery.
|
// Fall through to queued delivery.
|
||||||
@@ -136,14 +140,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|||||||
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
|
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
|
||||||
if session.isReachable {
|
if session.isReachable {
|
||||||
do {
|
do {
|
||||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
// swiftlint:disable multiline_arguments
|
||||||
(continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<
|
||||||
|
Void,
|
||||||
|
Error,
|
||||||
|
>) in
|
||||||
session.sendMessage(payload, replyHandler: { _ in
|
session.sendMessage(payload, replyHandler: { _ in
|
||||||
continuation.resume(returning: ())
|
continuation.resume(returning: ())
|
||||||
}, errorHandler: { error in
|
}, errorHandler: { error in
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// swiftlint:enable multiline_arguments
|
||||||
return WatchReplySendResult(
|
return WatchReplySendResult(
|
||||||
deliveredImmediately: true,
|
deliveredImmediately: true,
|
||||||
queuedForDelivery: false,
|
queuedForDelivery: false,
|
||||||
@@ -254,7 +262,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
|
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
|
||||||
guard let payload = value.flatMap(Self.normalizeObject) else {
|
guard let payload = value.flatMap(normalizeObject) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
@@ -291,7 +299,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|||||||
{
|
{
|
||||||
guard let type = payload["type"] as? String,
|
guard let type = payload["type"] as? String,
|
||||||
type == WatchPayloadType.execApprovalPrompt.rawValue,
|
type == WatchPayloadType.execApprovalPrompt.rawValue,
|
||||||
let approval = Self.parseExecApprovalItem(payload["approval"])
|
let approval = parseExecApprovalItem(payload["approval"])
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Observation
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
import WatchKit
|
import WatchKit
|
||||||
|
|
||||||
enum WatchPayloadType: String, Codable, Sendable, Equatable {
|
enum WatchPayloadType: String, Codable, Equatable {
|
||||||
case notify = "watch.notify"
|
case notify = "watch.notify"
|
||||||
case reply = "watch.reply"
|
case reply = "watch.reply"
|
||||||
case execApprovalPrompt = "watch.execApproval.prompt"
|
case execApprovalPrompt = "watch.execApproval.prompt"
|
||||||
@@ -14,18 +14,18 @@ enum WatchPayloadType: String, Codable, Sendable, Equatable {
|
|||||||
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
|
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WatchRiskLevel: String, Codable, Sendable, Equatable {
|
enum WatchRiskLevel: String, Codable, Equatable {
|
||||||
case low
|
case low
|
||||||
case medium
|
case medium
|
||||||
case high
|
case high
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WatchExecApprovalDecision: String, Codable, Sendable, Equatable {
|
enum WatchExecApprovalDecision: String, Codable, Equatable {
|
||||||
case allowOnce = "allow-once"
|
case allowOnce = "allow-once"
|
||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
|
enum WatchExecApprovalCloseReason: String, Codable, Equatable {
|
||||||
case expired
|
case expired
|
||||||
case notFound = "not-found"
|
case notFound = "not-found"
|
||||||
case unavailable
|
case unavailable
|
||||||
@@ -33,7 +33,7 @@ enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
|
|||||||
case resolved
|
case resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
|
||||||
var id: String
|
var id: String
|
||||||
var commandText: String
|
var commandText: String
|
||||||
var commandPreview: String?
|
var commandPreview: String?
|
||||||
@@ -45,51 +45,51 @@ struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
|||||||
var risk: WatchRiskLevel?
|
var risk: WatchRiskLevel?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
|
struct WatchExecApprovalPromptMessage: Codable, Equatable {
|
||||||
var approval: WatchExecApprovalItem
|
var approval: WatchExecApprovalItem
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int?
|
||||||
var deliveryId: String?
|
var deliveryId: String?
|
||||||
var resetResolvingState: Bool?
|
var resetResolvingState: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
|
struct WatchExecApprovalResolvedMessage: Codable, Equatable {
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var decision: WatchExecApprovalDecision?
|
var decision: WatchExecApprovalDecision?
|
||||||
var resolvedAtMs: Int?
|
var resolvedAtMs: Int?
|
||||||
var source: String?
|
var source: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
|
struct WatchExecApprovalExpiredMessage: Codable, Equatable {
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var reason: WatchExecApprovalCloseReason
|
var reason: WatchExecApprovalCloseReason
|
||||||
var expiredAtMs: Int?
|
var expiredAtMs: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
|
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
|
||||||
var approvals: [WatchExecApprovalItem]
|
var approvals: [WatchExecApprovalItem]
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int?
|
||||||
var snapshotId: String?
|
var snapshotId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
|
struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
|
||||||
var requestId: String
|
var requestId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
|
struct WatchExecApprovalResolveMessage: Codable, Equatable {
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var decision: WatchExecApprovalDecision
|
var decision: WatchExecApprovalDecision
|
||||||
var replyId: String
|
var replyId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable {
|
struct WatchPromptAction: Codable, Equatable, Identifiable {
|
||||||
var id: String
|
var id: String
|
||||||
var label: String
|
var label: String
|
||||||
var style: String?
|
var style: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchNotifyMessage: Sendable {
|
struct WatchNotifyMessage {
|
||||||
var id: String?
|
var id: String?
|
||||||
var title: String
|
var title: String
|
||||||
var body: String
|
var body: String
|
||||||
@@ -103,7 +103,7 @@ struct WatchNotifyMessage: Sendable {
|
|||||||
var actions: [WatchPromptAction]
|
var actions: [WatchPromptAction]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
var approval: WatchExecApprovalItem
|
var approval: WatchExecApprovalItem
|
||||||
var transport: String
|
var transport: String
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
@@ -112,7 +112,9 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
var statusText: String?
|
var statusText: String?
|
||||||
var statusAt: Date?
|
var statusAt: Date?
|
||||||
|
|
||||||
var id: String { self.approval.id }
|
var id: String {
|
||||||
|
self.approval.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor @Observable final class WatchInboxStore {
|
@MainActor @Observable final class WatchInboxStore {
|
||||||
@@ -333,14 +335,13 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
|
|
||||||
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
|
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
|
||||||
self.removeExecApproval(id: message.approvalId)
|
self.removeExecApproval(id: message.approvalId)
|
||||||
let statusText: String
|
let statusText = switch message.decision {
|
||||||
switch message.decision {
|
|
||||||
case .allowOnce:
|
case .allowOnce:
|
||||||
statusText = "Allowed once"
|
"Allowed once"
|
||||||
case .deny:
|
case .deny:
|
||||||
statusText = "Denied"
|
"Denied"
|
||||||
case nil:
|
case nil:
|
||||||
statusText = "Approval resolved"
|
"Approval resolved"
|
||||||
}
|
}
|
||||||
self.lastExecApprovalOutcomeText = statusText
|
self.lastExecApprovalOutcomeText = statusText
|
||||||
self.lastExecApprovalOutcomeAt = Date()
|
self.lastExecApprovalOutcomeAt = Date()
|
||||||
@@ -349,18 +350,17 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
|
|
||||||
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
|
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
|
||||||
self.removeExecApproval(id: message.approvalId)
|
self.removeExecApproval(id: message.approvalId)
|
||||||
let statusText: String
|
let statusText = switch message.reason {
|
||||||
switch message.reason {
|
|
||||||
case .expired:
|
case .expired:
|
||||||
statusText = "Approval expired"
|
"Approval expired"
|
||||||
case .notFound:
|
case .notFound:
|
||||||
statusText = "Approval no longer available"
|
"Approval no longer available"
|
||||||
case .resolved:
|
case .resolved:
|
||||||
statusText = "Approval resolved elsewhere"
|
"Approval resolved elsewhere"
|
||||||
case .replaced:
|
case .replaced:
|
||||||
statusText = "Approval replaced"
|
"Approval replaced"
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
statusText = "Approval unavailable"
|
"Approval unavailable"
|
||||||
}
|
}
|
||||||
self.lastExecApprovalOutcomeText = statusText
|
self.lastExecApprovalOutcomeText = statusText
|
||||||
self.lastExecApprovalOutcomeAt = Date()
|
self.lastExecApprovalOutcomeAt = Date()
|
||||||
@@ -482,7 +482,7 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
|
|
||||||
private func restorePersistedState() {
|
private func restorePersistedState() {
|
||||||
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
|
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
|
||||||
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
|
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -555,11 +555,11 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
private func mapHapticRisk(_ risk: String?) -> WKHapticType {
|
private func mapHapticRisk(_ risk: String?) -> WKHapticType {
|
||||||
switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||||
case "high":
|
case "high":
|
||||||
return .failure
|
.failure
|
||||||
case "medium":
|
case "medium":
|
||||||
return .notification
|
.notification
|
||||||
default:
|
default:
|
||||||
return .click
|
.click
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,13 +219,13 @@ private struct WatchExecApprovalDetailView: View {
|
|||||||
private func riskText(_ risk: WatchRiskLevel?) -> String? {
|
private func riskText(_ risk: WatchRiskLevel?) -> String? {
|
||||||
switch risk {
|
switch risk {
|
||||||
case .high:
|
case .high:
|
||||||
return "High"
|
"High"
|
||||||
case .medium:
|
case .medium:
|
||||||
return "Medium"
|
"Medium"
|
||||||
case .low:
|
case .low:
|
||||||
return "Low"
|
"Low"
|
||||||
case nil:
|
case nil:
|
||||||
return nil
|
nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,11 +246,11 @@ private struct WatchGenericInboxView: View {
|
|||||||
private func role(for action: WatchPromptAction) -> ButtonRole? {
|
private func role(for action: WatchPromptAction) -> ButtonRole? {
|
||||||
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||||
case "destructive":
|
case "destructive":
|
||||||
return .destructive
|
.destructive
|
||||||
case "cancel":
|
case "cancel":
|
||||||
return .cancel
|
.cancel
|
||||||
default:
|
default:
|
||||||
return nil
|
nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
Maintenance update for the current OpenClaw development release.
|
Maintenance update for the current OpenClaw development release.
|
||||||
|
|
||||||
|
- Refreshed build hygiene for the iOS app, Share extension, Activity widget, Watch app, and curated shared Swift sources; relay registration now uses StoreKit app transaction JWS data instead of deprecated receipt APIs.
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ targets:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
||||||
|
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../../Swabble" \
|
||||||
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
||||||
- name: SwiftLint
|
- name: SwiftLint
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
@@ -344,6 +345,7 @@ targets:
|
|||||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
||||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||||
|
SWIFT_EMIT_CONST_VALUE_PROTOCOLS: ""
|
||||||
SWIFT_VERSION: "6.0"
|
SWIFT_VERSION: "6.0"
|
||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
info:
|
info:
|
||||||
|
|||||||
@@ -281,9 +281,9 @@ struct OpenClawChatComposer: View {
|
|||||||
onPasteImageAttachment: { data, fileName, mimeType in
|
onPasteImageAttachment: { data, fileName, mimeType in
|
||||||
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
|
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
|
||||||
})
|
})
|
||||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
#else
|
#else
|
||||||
TextEditor(text: self.$viewModel.input)
|
TextEditor(text: self.$viewModel.input)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
@@ -441,7 +441,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||||||
var onSend: () -> Void
|
var onSend: () -> Void
|
||||||
var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void
|
var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSScrollView {
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
|
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
|
||||||
@@ -495,7 +497,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||||||
var parent: ChatComposerTextView
|
var parent: ChatComposerTextView
|
||||||
var isProgrammaticUpdate = false
|
var isProgrammaticUpdate = false
|
||||||
|
|
||||||
init(_ parent: ChatComposerTextView) { self.parent = parent }
|
init(_ parent: ChatComposerTextView) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
func textDidChange(_ notification: Notification) {
|
||||||
guard !self.isProgrammaticUpdate else { return }
|
guard !self.isProgrammaticUpdate else { return }
|
||||||
@@ -507,7 +511,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ChatComposerTextViewFactory {
|
enum ChatComposerTextViewFactory {
|
||||||
// Internal for @testable import coverage of composer text view defaults.
|
/// Internal for @testable import coverage of composer text view defaults.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func makeConfiguredTextView() -> NSTextView {
|
static func makeConfiguredTextView() -> NSTextView {
|
||||||
let textView = ChatComposerNSTextView()
|
let textView = ChatComposerNSTextView()
|
||||||
@@ -751,7 +755,10 @@ enum ChatComposerPasteSupport {
|
|||||||
(NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"),
|
(NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"),
|
||||||
]
|
]
|
||||||
|
|
||||||
private static func matches(_ preferredType: NSPasteboard.PasteboardType?, candidate: NSPasteboard.PasteboardType) -> Bool {
|
private static func matches(
|
||||||
|
_ preferredType: NSPasteboard.PasteboardType?,
|
||||||
|
candidate: NSPasteboard.PasteboardType) -> Bool
|
||||||
|
{
|
||||||
guard let preferredType else { return true }
|
guard let preferredType else { return true }
|
||||||
return preferredType == candidate
|
return preferredType == candidate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ChatMarkdownPreprocessor {
|
enum ChatMarkdownPreprocessor {
|
||||||
// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
|
/// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
|
||||||
// (`INBOUND_META_SENTINELS`), and extend parser expectations in
|
/// (`INBOUND_META_SENTINELS`), and extend parser expectations in
|
||||||
// `ChatMarkdownPreprocessorTests` when sentinels change.
|
/// `ChatMarkdownPreprocessorTests` when sentinels change.
|
||||||
private static let inboundContextHeaders = [
|
private static let inboundContextHeaders = [
|
||||||
"Conversation info (untrusted metadata):",
|
"Conversation info (untrusted metadata):",
|
||||||
"Sender (untrusted metadata):",
|
"Sender (untrusted metadata):",
|
||||||
@@ -152,11 +152,13 @@ enum ChatMarkdownPreprocessor {
|
|||||||
for index in lines.indices {
|
for index in lines.indices {
|
||||||
let currentLine = lines[index]
|
let currentLine = lines[index]
|
||||||
|
|
||||||
if !inMetaBlock && self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
|
if !inMetaBlock, self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if !inMetaBlock && self.inboundContextHeaders.contains(currentLine.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
if !inMetaBlock,
|
||||||
|
self.inboundContextHeaders.contains(currentLine.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
{
|
||||||
let nextLine = index + 1 < lines.count ? lines[index + 1] : nil
|
let nextLine = index + 1 < lines.count ? lines[index + 1] : nil
|
||||||
if nextLine?.trimmingCharacters(in: .whitespacesAndNewlines) != "```json" {
|
if nextLine?.trimmingCharacters(in: .whitespacesAndNewlines) != "```json" {
|
||||||
outputLines.append(currentLine)
|
outputLines.append(currentLine)
|
||||||
@@ -168,7 +170,7 @@ enum ChatMarkdownPreprocessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inMetaBlock {
|
if inMetaBlock {
|
||||||
if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
|
if !inFencedJson, currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
|
||||||
inFencedJson = true
|
inFencedJson = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ private struct InlineImageList: View {
|
|||||||
let images: [ChatMarkdownPreprocessor.InlineImage]
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ForEach(images, id: \.id) { item in
|
ForEach(self.images, id: \.id) { item in
|
||||||
if let img = item.image {
|
if let img = item.image {
|
||||||
OpenClawPlatformImageFactory.image(img)
|
OpenClawPlatformImageFactory.image(img)
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private enum ChatUIConstants {
|
private enum ChatUIConstants {
|
||||||
@@ -70,7 +70,12 @@ private struct ChatBubbleShape: InsettableShape {
|
|||||||
to: baseBottom,
|
to: baseBottom,
|
||||||
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
|
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
|
||||||
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
|
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
|
||||||
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r)
|
self.addBottomEdge(
|
||||||
|
path: &path,
|
||||||
|
bubbleMinX: bubbleMinX,
|
||||||
|
bubbleMaxX: bubbleMaxX,
|
||||||
|
bubbleMaxY: bubbleMaxY,
|
||||||
|
radius: r)
|
||||||
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
|
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
|
||||||
path.addQuadCurve(
|
path.addQuadCurve(
|
||||||
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
||||||
@@ -102,7 +107,12 @@ private struct ChatBubbleShape: InsettableShape {
|
|||||||
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
||||||
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
||||||
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
|
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
|
||||||
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r)
|
self.addBottomEdge(
|
||||||
|
path: &path,
|
||||||
|
bubbleMinX: bubbleMinX,
|
||||||
|
bubbleMaxX: bubbleMaxX,
|
||||||
|
bubbleMaxY: bubbleMaxY,
|
||||||
|
radius: r)
|
||||||
path.addLine(to: baseBottom)
|
path.addLine(to: baseBottom)
|
||||||
path.addCurve(
|
path.addCurve(
|
||||||
to: tip,
|
to: tip,
|
||||||
@@ -158,7 +168,9 @@ struct ChatMessageBubble: View {
|
|||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isUser: Bool { self.message.role.lowercased() == "user" }
|
private var isUser: Bool {
|
||||||
|
self.message.role.lowercased() == "user"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -498,8 +510,8 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension View {
|
extension View {
|
||||||
func assistantBubbleContainerStyle() -> some View {
|
fileprivate func assistantBubbleContainerStyle() -> some View {
|
||||||
self
|
self
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
|
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
|
||||||
|
|
||||||
@@ -270,7 +270,10 @@ public struct OpenClawChatEventPayload: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
|
public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
|
||||||
public var id: String { "\(self.runId)-\(self.seq ?? -1)" }
|
public var id: String {
|
||||||
|
"\(self.runId)-\(self.seq ?? -1)"
|
||||||
|
}
|
||||||
|
|
||||||
public let runId: String
|
public let runId: String
|
||||||
public let seq: Int?
|
public let seq: Int?
|
||||||
public let stream: String
|
public let stream: String
|
||||||
@@ -279,7 +282,10 @@ public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
|
public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
|
||||||
public var id: String { self.toolCallId }
|
public var id: String {
|
||||||
|
self.toolCallId
|
||||||
|
}
|
||||||
|
|
||||||
public let toolCallId: String
|
public let toolCallId: String
|
||||||
public let name: String
|
public let name: String
|
||||||
public let args: AnyCodable?
|
public let args: AnyCodable?
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
enum ChatPayloadDecoding {
|
enum ChatPayloadDecoding {
|
||||||
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {
|
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
|
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
|
||||||
public var id: String { self.selectionID }
|
public var id: String {
|
||||||
|
self.selectionID
|
||||||
|
}
|
||||||
|
|
||||||
public let modelID: String
|
public let modelID: String
|
||||||
public let name: String
|
public let name: String
|
||||||
@@ -44,7 +46,9 @@ public struct OpenClawChatSessionsDefaults: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
|
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
|
||||||
public var id: String { self.key }
|
public var id: String {
|
||||||
|
self.key
|
||||||
|
}
|
||||||
|
|
||||||
public let key: String
|
public let key: String
|
||||||
public let kind: String?
|
public let kind: String?
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ enum OpenClawChatTheme {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var userText: Color { .white }
|
static var userText: Color {
|
||||||
|
.white
|
||||||
|
}
|
||||||
|
|
||||||
static var assistantText: Color {
|
static var assistantText: Color {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ public struct OpenClawChatView: View {
|
|||||||
.sheet(isPresented: self.$showSessions) {
|
.sheet(isPresented: self.$showSessions) {
|
||||||
if self.showsSessionSwitcher {
|
if self.showsSessionSwitcher {
|
||||||
ChatSessionsSheet(viewModel: self.viewModel)
|
ChatSessionsSheet(viewModel: self.viewModel)
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,11 +97,11 @@ public struct OpenClawChatView: View {
|
|||||||
self.messageListRows
|
self.messageListRows
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(height: Layout.messageListPaddingBottom)
|
.frame(height: Layout.messageListPaddingBottom)
|
||||||
#else
|
#else
|
||||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||||
#endif
|
#endif
|
||||||
.id(self.scrollerBottomID)
|
.id(self.scrollerBottomID)
|
||||||
}
|
}
|
||||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||||
@@ -115,11 +113,11 @@ public struct OpenClawChatView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
#endif
|
#endif
|
||||||
// Keep the scroll pinned to the bottom for new messages.
|
// Keep the scroll pinned to the bottom for new messages.
|
||||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||||
.onChange(of: self.scrollPosition) { _, position in
|
.onChange(of: self.scrollPosition) { _, position in
|
||||||
guard let position else { return }
|
guard let position else { return }
|
||||||
self.isPinnedToBottom = position == self.scrollerBottomID
|
self.isPinnedToBottom = position == self.scrollerBottomID
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.viewModel.isLoading {
|
if self.viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -158,7 +156,8 @@ public struct OpenClawChatView: View {
|
|||||||
guard self.hasPerformedInitialScroll else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
if let lastMessage = self.viewModel.messages.last,
|
if let lastMessage = self.viewModel.messages.last,
|
||||||
lastMessage.role.lowercased() == "user",
|
lastMessage.role.lowercased() == "user",
|
||||||
lastMessage.id != self.lastUserMessageID {
|
lastMessage.id != self.lastUserMessageID
|
||||||
|
{
|
||||||
self.lastUserMessageID = lastMessage.id
|
self.lastUserMessageID = lastMessage.id
|
||||||
self.isPinnedToBottom = true
|
self.isPinnedToBottom = true
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import OpenClawKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
import OSLog
|
import OSLog
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
|
// swiftlint:disable:next type_body_length
|
||||||
public final class OpenClawChatViewModel {
|
public final class OpenClawChatViewModel {
|
||||||
public static let defaultModelSelectionID = "__default__"
|
public static let defaultModelSelectionID = "__default__"
|
||||||
|
|
||||||
@@ -659,8 +660,8 @@ public final class OpenClawChatViewModel {
|
|||||||
self.errorText = "Unable to compact the session. Please try again."
|
self.errorText = "Unable to compact the session. Please try again."
|
||||||
let nsError = error as NSError
|
let nsError = error as NSError
|
||||||
chatUILogger.error(
|
chatUILogger.error(
|
||||||
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)"
|
// swiftlint:disable:next line_length
|
||||||
)
|
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +734,10 @@ public final class OpenClawChatViewModel {
|
|||||||
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
|
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
|
||||||
}
|
}
|
||||||
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
|
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
|
||||||
self.applySuccessfulModelSelection(previous, sessionKey: sessionKey, syncSelection: sessionKey == self.sessionKey)
|
self.applySuccessfulModelSelection(
|
||||||
|
previous,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
syncSelection: sessionKey == self.sessionKey)
|
||||||
}
|
}
|
||||||
guard sessionKey == self.sessionKey else { return }
|
guard sessionKey == self.sessionKey else { return }
|
||||||
self.modelSelectionID = previous
|
self.modelSelectionID = previous
|
||||||
@@ -856,7 +860,8 @@ public final class OpenClawChatViewModel {
|
|||||||
syncSelection: syncSelection)
|
syncSelection: syncSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) {
|
private func resolvedSessionModelIdentity(forSelectionID selectionID: String)
|
||||||
|
-> (modelID: String?, modelProvider: String?) {
|
||||||
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
|
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
|
||||||
return (nil, nil)
|
return (nil, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension AnyCodable {
|
extension AnyCodable {
|
||||||
var stringValue: String? {
|
public var stringValue: String? {
|
||||||
self.value as? String
|
self.value as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
var boolValue: Bool? {
|
public var boolValue: Bool? {
|
||||||
if let value = self.value as? Bool {
|
if let value = self.value as? Bool {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ public extension AnyCodable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var intValue: Int? {
|
public var intValue: Int? {
|
||||||
if let value = self.value as? Int {
|
if let value = self.value as? Int {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ public extension AnyCodable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var doubleValue: Double? {
|
public var doubleValue: Double? {
|
||||||
if let value = self.value as? Double {
|
if let value = self.value as? Double {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ public extension AnyCodable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var dictionaryValue: [String: AnyCodable]? {
|
public var dictionaryValue: [String: AnyCodable]? {
|
||||||
if let value = self.value as? [String: AnyCodable] {
|
if let value = self.value as? [String: AnyCodable] {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ public extension AnyCodable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var arrayValue: [AnyCodable]? {
|
public var arrayValue: [AnyCodable]? {
|
||||||
if let value = self.value as? [AnyCodable] {
|
if let value = self.value as? [AnyCodable] {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ public extension AnyCodable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var foundationValue: Any {
|
public var foundationValue: Any {
|
||||||
switch self.value {
|
switch self.value {
|
||||||
case let dict as [String: AnyCodable]:
|
case let dict as [String: AnyCodable]:
|
||||||
dict.mapValues(\.foundationValue)
|
dict.mapValues(\.foundationValue)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
|
|
||||||
public typealias AnyCodable = OpenClawProtocol.AnyCodable
|
public typealias AnyCodable = OpenClawProtocol.AnyCodable
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ public enum OpenClawBonjour {
|
|||||||
private static func resolveWideAreaDomain(_ raw: String?) -> String? {
|
private static func resolveWideAreaDomain(_ raw: String?) -> String? {
|
||||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty { return nil }
|
if trimmed.isEmpty { return nil }
|
||||||
let normalized = normalizeServiceDomain(trimmed)
|
let normalized = self.normalizeServiceDomain(trimmed)
|
||||||
return normalized == gatewayServiceDomain ? nil : normalized
|
return normalized == self.gatewayServiceDomain ? nil : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func normalizeServiceDomain(_ raw: String?) -> String {
|
public static func normalizeServiceDomain(_ raw: String?) -> String {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import Foundation
|
|||||||
public enum CaptureRateLimits {
|
public enum CaptureRateLimits {
|
||||||
public static func clampDurationMs(
|
public static func clampDurationMs(
|
||||||
_ ms: Int?,
|
_ ms: Int?,
|
||||||
defaultMs: Int = 10_000,
|
defaultMs: Int = 10000,
|
||||||
minMs: Int = 250,
|
minMs: Int = 250,
|
||||||
maxMs: Int = 60_000) -> Int
|
maxMs: Int = 60000) -> Int
|
||||||
{
|
{
|
||||||
let value = ms ?? defaultMs
|
let value = ms ?? defaultMs
|
||||||
return min(maxMs, max(minMs, value))
|
return min(maxMs, max(minMs, value))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|||||||
|
|
||||||
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
|
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
|
||||||
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
||||||
guard let data = Self.decodeBase64Url(code) else { return nil }
|
guard let data = decodeBase64Url(code) else { return nil }
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
guard let urlString = json["url"] as? String,
|
guard let urlString = json["url"] as? String,
|
||||||
let parsed = URLComponents(string: urlString),
|
let parsed = URLComponents(string: urlString),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ public enum GatewayDeviceAuthPayload {
|
|||||||
{
|
{
|
||||||
let scopeString = scopes.joined(separator: ",")
|
let scopeString = scopes.joined(separator: ",")
|
||||||
let authToken = token ?? ""
|
let authToken = token ?? ""
|
||||||
let normalizedPlatform = normalizeMetadataField(platform)
|
let normalizedPlatform = self.normalizeMetadataField(platform)
|
||||||
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
|
let normalizedDeviceFamily = self.normalizeMetadataField(deviceFamily)
|
||||||
return [
|
return [
|
||||||
"v3",
|
"v3",
|
||||||
deviceId,
|
deviceId,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public enum DeviceAuthStore {
|
|||||||
|
|
||||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||||
let role = normalizeRole(role)
|
let role = self.normalizeRole(role)
|
||||||
return store.tokens[role]
|
return store.tokens[role]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ public enum DeviceAuthStore {
|
|||||||
deviceId: String,
|
deviceId: String,
|
||||||
role: String,
|
role: String,
|
||||||
token: String,
|
token: String,
|
||||||
scopes: [String] = []
|
scopes: [String] = []) -> DeviceAuthEntry
|
||||||
) -> DeviceAuthEntry {
|
{
|
||||||
let normalizedRole = normalizeRole(role)
|
let normalizedRole = self.normalizeRole(role)
|
||||||
var next = readStore()
|
var next = self.readStore()
|
||||||
if next?.deviceId != deviceId {
|
if next?.deviceId != deviceId {
|
||||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||||
}
|
}
|
||||||
@@ -44,24 +44,23 @@ public enum DeviceAuthStore {
|
|||||||
token: token,
|
token: token,
|
||||||
role: normalizedRole,
|
role: normalizedRole,
|
||||||
scopes: normalizeScopes(scopes),
|
scopes: normalizeScopes(scopes),
|
||||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
|
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||||
)
|
|
||||||
if next == nil {
|
if next == nil {
|
||||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||||
}
|
}
|
||||||
next?.tokens[normalizedRole] = entry
|
next?.tokens[normalizedRole] = entry
|
||||||
if let store = next {
|
if let store = next {
|
||||||
writeStore(store)
|
self.writeStore(store)
|
||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func clearToken(deviceId: String, role: String) {
|
public static func clearToken(deviceId: String, role: String) {
|
||||||
guard var store = readStore(), store.deviceId == deviceId else { return }
|
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||||
let normalizedRole = normalizeRole(role)
|
let normalizedRole = self.normalizeRole(role)
|
||||||
guard store.tokens[normalizedRole] != nil else { return }
|
guard store.tokens[normalizedRole] != nil else { return }
|
||||||
store.tokens.removeValue(forKey: normalizedRole)
|
store.tokens.removeValue(forKey: normalizedRole)
|
||||||
writeStore(store)
|
self.writeStore(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRole(_ role: String) -> String {
|
private static func normalizeRole(_ role: String) -> String {
|
||||||
@@ -78,11 +77,11 @@ public enum DeviceAuthStore {
|
|||||||
private static func fileURL() -> URL {
|
private static func fileURL() -> URL {
|
||||||
DeviceIdentityPaths.stateDirURL()
|
DeviceIdentityPaths.stateDirURL()
|
||||||
.appendingPathComponent("identity", isDirectory: true)
|
.appendingPathComponent("identity", isDirectory: true)
|
||||||
.appendingPathComponent(fileName, isDirectory: false)
|
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readStore() -> DeviceAuthStoreFile? {
|
private static func readStore() -> DeviceAuthStoreFile? {
|
||||||
let url = fileURL()
|
let url = self.fileURL()
|
||||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -92,7 +91,7 @@ public enum DeviceAuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||||
let url = fileURL()
|
let url = self.fileURL()
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ public enum DeviceIdentityStore {
|
|||||||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||||
!decoded.deviceId.isEmpty,
|
!decoded.deviceId.isEmpty,
|
||||||
!decoded.publicKey.isEmpty,
|
!decoded.publicKey.isEmpty,
|
||||||
!decoded.privateKey.isEmpty {
|
!decoded.privateKey.isEmpty
|
||||||
|
{
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
let identity = self.generate()
|
let identity = self.generate()
|
||||||
@@ -107,6 +108,6 @@ public enum DeviceIdentityStore {
|
|||||||
let base = DeviceIdentityPaths.stateDirURL()
|
let base = DeviceIdentityPaths.stateDirURL()
|
||||||
return base
|
return base
|
||||||
.appendingPathComponent("identity", isDirectory: true)
|
.appendingPathComponent("identity", isDirectory: true)
|
||||||
.appendingPathComponent(fileName, isDirectory: false)
|
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawProtocol
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawProtocol
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
public protocol WebSocketTasking: AnyObject {
|
public protocol WebSocketTasking: AnyObject {
|
||||||
@@ -20,9 +20,13 @@ public struct WebSocketTaskBox: @unchecked Sendable {
|
|||||||
self.task = task
|
self.task = task
|
||||||
}
|
}
|
||||||
|
|
||||||
public var state: URLSessionTask.State { self.task.state }
|
public var state: URLSessionTask.State {
|
||||||
|
self.task.state
|
||||||
|
}
|
||||||
|
|
||||||
public func resume() { self.task.resume() }
|
public func resume() {
|
||||||
|
self.task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
self.task.cancel(with: closeCode, reason: reason)
|
self.task.cancel(with: closeCode, reason: reason)
|
||||||
@@ -81,9 +85,9 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
public var clientId: String
|
public var clientId: String
|
||||||
public var clientMode: String
|
public var clientMode: String
|
||||||
public var clientDisplayName: String?
|
public var clientDisplayName: String?
|
||||||
// When false, the connection omits the signed device identity payload and cannot use
|
/// When false, the connection omits the signed device identity payload and cannot use
|
||||||
// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
|
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
|
||||||
// role/scoped sessions such as operator UI clients.
|
/// role/scoped sessions such as operator UI clients.
|
||||||
public var includeDeviceIdentity: Bool
|
public var includeDeviceIdentity: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -113,11 +117,11 @@ public enum GatewayAuthSource: String, Sendable {
|
|||||||
case deviceToken = "device-token"
|
case deviceToken = "device-token"
|
||||||
case sharedToken = "shared-token"
|
case sharedToken = "shared-token"
|
||||||
case bootstrapToken = "bootstrap-token"
|
case bootstrapToken = "bootstrap-token"
|
||||||
case password = "password"
|
case password
|
||||||
case none = "none"
|
case none
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid ambiguity with the app's own AnyCodable type.
|
/// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
|
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
|
||||||
|
|
||||||
private enum ConnectChallengeError: Error {
|
private enum ConnectChallengeError: Error {
|
||||||
@@ -132,13 +136,13 @@ private let defaultOperatorConnectScopes: [String] = [
|
|||||||
"operator.pairing",
|
"operator.pairing",
|
||||||
]
|
]
|
||||||
|
|
||||||
private extension String {
|
extension String {
|
||||||
var nilIfEmpty: String? {
|
fileprivate var nilIfEmpty: String? {
|
||||||
self.isEmpty ? nil : self
|
self.isEmpty ? nil : self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SelectedConnectAuth: Sendable {
|
private struct SelectedConnectAuth {
|
||||||
let authToken: String?
|
let authToken: String?
|
||||||
let authBootstrapToken: String?
|
let authBootstrapToken: String?
|
||||||
let authDeviceToken: String?
|
let authDeviceToken: String?
|
||||||
@@ -223,7 +227,9 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
public func authSource() -> GatewayAuthSource {
|
||||||
|
self.lastAuthSource
|
||||||
|
}
|
||||||
|
|
||||||
public func shutdown() async {
|
public func shutdown() async {
|
||||||
self.shouldReconnect = false
|
self.shouldReconnect = false
|
||||||
@@ -277,8 +283,7 @@ public actor GatewayChannelActor {
|
|||||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||||
self.reconnectPausedForAuthFailure = true
|
self.reconnectPausedForAuthFailure = true
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
|
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)")
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
||||||
@@ -312,11 +317,10 @@ public actor GatewayChannelActor {
|
|||||||
},
|
},
|
||||||
operation: { try await self.sendConnect() })
|
operation: { try await self.sendConnect() })
|
||||||
} catch {
|
} catch {
|
||||||
let wrapped: Error
|
let wrapped: Error = if let authError = error as? GatewayConnectAuthError {
|
||||||
if let authError = error as? GatewayConnectAuthError {
|
authError
|
||||||
wrapped = authError
|
|
||||||
} else {
|
} else {
|
||||||
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||||
}
|
}
|
||||||
self.connected = false
|
self.connected = false
|
||||||
self.task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
@@ -422,7 +426,7 @@ public actor GatewayChannelActor {
|
|||||||
role: role,
|
role: role,
|
||||||
includeDeviceIdentity: includeDeviceIdentity,
|
includeDeviceIdentity: includeDeviceIdentity,
|
||||||
deviceId: identity?.deviceId)
|
deviceId: identity?.deviceId)
|
||||||
if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry {
|
if selectedAuth.authDeviceToken != nil, self.pendingDeviceTokenRetry {
|
||||||
self.pendingDeviceTokenRetry = false
|
self.pendingDeviceTokenRetry = false
|
||||||
}
|
}
|
||||||
self.lastAuthSource = selectedAuth.authSource
|
self.lastAuthSource = selectedAuth.authSource
|
||||||
@@ -485,8 +489,8 @@ public actor GatewayChannelActor {
|
|||||||
self.deviceTokenRetryBudgetUsed = true
|
self.deviceTokenRetryBudgetUsed = true
|
||||||
self.backoffMs = min(self.backoffMs, 250)
|
self.backoffMs = min(self.backoffMs, 250)
|
||||||
} else if selectedAuth.authDeviceToken != nil,
|
} else if selectedAuth.authDeviceToken != nil,
|
||||||
let identity,
|
let identity,
|
||||||
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
||||||
{
|
{
|
||||||
// Retry failed with an explicit device-token mismatch; clear stale local token.
|
// Retry failed with an explicit device-token mismatch; clear stale local token.
|
||||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||||
@@ -498,39 +502,38 @@ public actor GatewayChannelActor {
|
|||||||
private func selectConnectAuth(
|
private func selectConnectAuth(
|
||||||
role: String,
|
role: String,
|
||||||
includeDeviceIdentity: Bool,
|
includeDeviceIdentity: Bool,
|
||||||
deviceId: String?
|
deviceId: String?) -> SelectedConnectAuth
|
||||||
) -> SelectedConnectAuth {
|
{
|
||||||
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
let explicitBootstrapToken =
|
let explicitBootstrapToken =
|
||||||
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
let storedToken =
|
let storedToken =
|
||||||
(includeDeviceIdentity && deviceId != nil)
|
(includeDeviceIdentity && deviceId != nil)
|
||||||
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
|
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
|
||||||
: nil
|
: nil
|
||||||
let shouldUseDeviceRetryToken =
|
let shouldUseDeviceRetryToken =
|
||||||
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
||||||
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||||
let authToken =
|
let authToken =
|
||||||
explicitToken ??
|
explicitToken ??
|
||||||
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
||||||
// silently reusing an older stored device token.
|
// silently reusing an older stored device token.
|
||||||
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
||||||
? storedToken
|
? storedToken
|
||||||
: nil)
|
: nil)
|
||||||
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
||||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||||
let authSource: GatewayAuthSource
|
let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
|
||||||
if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
|
.deviceToken
|
||||||
authSource = .deviceToken
|
|
||||||
} else if authToken != nil {
|
} else if authToken != nil {
|
||||||
authSource = .sharedToken
|
.sharedToken
|
||||||
} else if authBootstrapToken != nil {
|
} else if authBootstrapToken != nil {
|
||||||
authSource = .bootstrapToken
|
.bootstrapToken
|
||||||
} else if explicitPassword != nil {
|
} else if explicitPassword != nil {
|
||||||
authSource = .password
|
.password
|
||||||
} else {
|
} else {
|
||||||
authSource = .none
|
.none
|
||||||
}
|
}
|
||||||
return SelectedConnectAuth(
|
return SelectedConnectAuth(
|
||||||
authToken: authToken,
|
authToken: authToken,
|
||||||
@@ -560,7 +563,7 @@ public actor GatewayChannelActor {
|
|||||||
case "node":
|
case "node":
|
||||||
return []
|
return []
|
||||||
case "operator":
|
case "operator":
|
||||||
let allowedOperatorScopes: Set<String> = [
|
let allowedOperatorScopes: Set = [
|
||||||
"operator.approvals",
|
"operator.approvals",
|
||||||
"operator.read",
|
"operator.read",
|
||||||
"operator.talk.secrets",
|
"operator.talk.secrets",
|
||||||
@@ -576,8 +579,8 @@ public actor GatewayChannelActor {
|
|||||||
deviceId: String,
|
deviceId: String,
|
||||||
role: String,
|
role: String,
|
||||||
token: String,
|
token: String,
|
||||||
scopes: [String]
|
scopes: [String])
|
||||||
) {
|
{
|
||||||
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -593,8 +596,8 @@ public actor GatewayChannelActor {
|
|||||||
deviceId: String,
|
deviceId: String,
|
||||||
role: String,
|
role: String,
|
||||||
token: String,
|
token: String,
|
||||||
scopes: [String]
|
scopes: [String])
|
||||||
) {
|
{
|
||||||
if authSource == .bootstrapToken {
|
if authSource == .bootstrapToken {
|
||||||
guard self.shouldPersistBootstrapHandoffTokens() else {
|
guard self.shouldPersistBootstrapHandoffTokens() else {
|
||||||
return
|
return
|
||||||
@@ -616,8 +619,8 @@ public actor GatewayChannelActor {
|
|||||||
private func handleConnectResponse(
|
private func handleConnectResponse(
|
||||||
_ res: ResponseFrame,
|
_ res: ResponseFrame,
|
||||||
identity: DeviceIdentity?,
|
identity: DeviceIdentity?,
|
||||||
role: String
|
role: String) async throws
|
||||||
) async throws {
|
{
|
||||||
if res.ok == false {
|
if res.ok == false {
|
||||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||||
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
||||||
@@ -809,12 +812,11 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
||||||
let data: Data? = switch msg {
|
return switch msg {
|
||||||
case let .data(data): data
|
case let .data(data): data
|
||||||
case let .string(text): text.data(using: .utf8)
|
case let .string(text): text.data(using: .utf8)
|
||||||
@unknown default: nil
|
@unknown default: nil
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func watchTicks() async {
|
private func watchTicks() async {
|
||||||
@@ -853,8 +855,7 @@ public actor GatewayChannelActor {
|
|||||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||||
self.reconnectPausedForAuthFailure = true
|
self.reconnectPausedForAuthFailure = true
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
|
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)")
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let wrapped = self.wrap(error, context: "gateway reconnect")
|
let wrapped = self.wrap(error, context: "gateway reconnect")
|
||||||
@@ -867,8 +868,8 @@ public actor GatewayChannelActor {
|
|||||||
error: Error,
|
error: Error,
|
||||||
explicitGatewayToken: String?,
|
explicitGatewayToken: String?,
|
||||||
storedToken: String?,
|
storedToken: String?,
|
||||||
attemptedDeviceTokenRetry: Bool
|
attemptedDeviceTokenRetry: Bool) -> Bool
|
||||||
) -> Bool {
|
{
|
||||||
if self.deviceTokenRetryBudgetUsed {
|
if self.deviceTokenRetryBudgetUsed {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -895,8 +896,8 @@ public actor GatewayChannelActor {
|
|||||||
if authError.isNonRecoverable {
|
if authError.isNonRecoverable {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if authError.detail == .authTokenMismatch &&
|
if authError.detail == .authTokenMismatch,
|
||||||
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
|
self.deviceTokenRetryBudgetUsed, !self.pendingDeviceTokenRetry
|
||||||
{
|
{
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1007,7 +1008,7 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
/// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||||
private func wrap(_ error: Error, context: String) -> Error {
|
private func wrap(_ error: Error, context: String) -> Error {
|
||||||
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
|
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
|
||||||
return error
|
return error
|
||||||
@@ -1055,8 +1056,7 @@ public actor GatewayChannelActor {
|
|||||||
return (id: id, data: data)
|
return (id: id, data: data)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
|
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
)
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ public enum GatewayConnectChallengeSupport {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func waitForNonce<E: Error>(
|
public static func waitForNonce(
|
||||||
timeoutSeconds: Double,
|
timeoutSeconds: Double,
|
||||||
onTimeout: @escaping @Sendable () -> E,
|
onTimeout: @escaping @Sendable () -> some Error,
|
||||||
receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String
|
receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String
|
||||||
{
|
{
|
||||||
try await AsyncTimeout.withTimeout(
|
try await AsyncTimeout.withTimeout(
|
||||||
|
|||||||
@@ -81,32 +81,34 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
|
|||||||
|
|
||||||
public var needsPairingApproval: Bool {
|
public var needsPairingApproval: Bool {
|
||||||
switch self.kind {
|
switch self.kind {
|
||||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
|
||||||
return true
|
.pairingMetadataUpgradeRequired:
|
||||||
|
true
|
||||||
default:
|
default:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var needsCredentialUpdate: Bool {
|
public var needsCredentialUpdate: Bool {
|
||||||
switch self.kind {
|
switch self.kind {
|
||||||
case .gatewayAuthTokenMissing,
|
case .gatewayAuthTokenMissing,
|
||||||
.gatewayAuthTokenMismatch,
|
.gatewayAuthTokenMismatch,
|
||||||
.gatewayAuthTokenNotConfigured,
|
.gatewayAuthTokenNotConfigured,
|
||||||
.gatewayAuthPasswordMissing,
|
.gatewayAuthPasswordMissing,
|
||||||
.gatewayAuthPasswordMismatch,
|
.gatewayAuthPasswordMismatch,
|
||||||
.gatewayAuthPasswordNotConfigured,
|
.gatewayAuthPasswordNotConfigured,
|
||||||
.bootstrapTokenInvalid,
|
.bootstrapTokenInvalid,
|
||||||
.deviceTokenMismatch:
|
.deviceTokenMismatch:
|
||||||
return true
|
true
|
||||||
default:
|
default:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var statusText: String {
|
public var statusText: String {
|
||||||
switch self.kind {
|
switch self.kind {
|
||||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
|
||||||
|
.pairingMetadataUpgradeRequired:
|
||||||
if let requestId {
|
if let requestId {
|
||||||
return "\(self.title) (request ID: \(requestId))"
|
return "\(self.title) (request ID: \(requestId))"
|
||||||
}
|
}
|
||||||
@@ -123,7 +125,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum GatewayConnectionProblemMapper {
|
public enum GatewayConnectionProblemMapper {
|
||||||
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
|
public static func map(
|
||||||
|
error: Error,
|
||||||
|
preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem?
|
||||||
|
{
|
||||||
guard let nextProblem = self.rawMap(error) else {
|
guard let nextProblem = self.rawMap(error) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -136,14 +141,20 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
return nextProblem
|
return nextProblem
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
|
public static func shouldPreserve(
|
||||||
|
previousProblem: GatewayConnectionProblem,
|
||||||
|
over nextProblem: GatewayConnectionProblem) -> Bool
|
||||||
|
{
|
||||||
if nextProblem.kind == .websocketCancelled {
|
if nextProblem.kind == .websocketCancelled {
|
||||||
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
|
public static func shouldPreserve(
|
||||||
|
previousProblem: GatewayConnectionProblem,
|
||||||
|
overDisconnectReason reason: String) -> Bool
|
||||||
|
{
|
||||||
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
guard !normalized.isEmpty else { return false }
|
guard !normalized.isEmpty else { return false }
|
||||||
if normalized.contains("cancelled") || normalized.contains("canceled") {
|
if normalized.contains("cancelled") || normalized.contains("canceled") {
|
||||||
@@ -175,7 +186,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "This gateway requires an auth token, but this iPhone did not send one.",
|
?? "This gateway requires an auth token, but this iPhone did not send one.",
|
||||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -187,9 +200,12 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
title: authError.titleOverride ?? "Gateway token is out of date",
|
title: authError.titleOverride ?? "Gateway token is out of date",
|
||||||
message: authError.userMessageOverride
|
message: authError.userMessageOverride
|
||||||
?? "The token on this iPhone does not match the gateway token.",
|
?? "The token on this iPhone does not match the gateway token.",
|
||||||
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
|
actionLabel: authError
|
||||||
|
.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
|
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
|
||||||
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
|
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
|
||||||
@@ -203,7 +219,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
|
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
|
||||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||||
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
|
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -217,7 +235,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "This gateway requires a password, but this iPhone did not send one.",
|
?? "This gateway requires a password, but this iPhone did not send one.",
|
||||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -231,7 +251,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "The saved password on this iPhone does not match the gateway password.",
|
?? "The saved password on this iPhone does not match the gateway password.",
|
||||||
actionLabel: authError.actionLabel ?? "Update password",
|
actionLabel: authError.actionLabel ?? "Update password",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -245,7 +267,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
|
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
|
||||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||||
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
|
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -286,7 +310,8 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
owner: .iphone,
|
owner: .iphone,
|
||||||
title: authError.titleOverride ?? "Secure device identity is required",
|
title: authError.titleOverride ?? "Secure device identity is required",
|
||||||
message: authError.userMessageOverride
|
message: authError.userMessageOverride
|
||||||
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
|
??
|
||||||
|
"This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
|
||||||
actionLabel: authError.actionLabel ?? "Retry from the app",
|
actionLabel: authError.actionLabel ?? "Retry from the app",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
|
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
|
||||||
@@ -302,7 +327,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
message: authError.userMessageOverride ?? "The device signature is too old to use.",
|
message: authError.userMessageOverride ?? "The device signature is too old to use.",
|
||||||
actionLabel: authError.actionLabel ?? "Check iPhone time",
|
actionLabel: authError.actionLabel ?? "Check iPhone time",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -316,7 +343,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
|
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
|
||||||
actionLabel: authError.actionLabel ?? "Retry",
|
actionLabel: authError.actionLabel ?? "Retry",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -329,7 +358,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
|
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
|
||||||
actionLabel: authError.actionLabel ?? "Retry",
|
actionLabel: authError.actionLabel ?? "Retry",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -441,7 +472,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
|
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
|
||||||
actionLabel: authError.actionLabel ?? "Wait and retry",
|
actionLabel: authError.actionLabel ?? "Wait and retry",
|
||||||
actionCommand: authError.actionCommand,
|
actionCommand: authError.actionCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
docsURL: self.docsURL(
|
||||||
|
authError.docsURLString,
|
||||||
|
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||||
requestId: authError.requestId,
|
requestId: authError.requestId,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
pauseReconnect: true,
|
pauseReconnect: true,
|
||||||
@@ -520,7 +553,8 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
retryable: true,
|
retryable: true,
|
||||||
pauseReconnect: false,
|
pauseReconnect: false,
|
||||||
technicalDetails: rawMessage)
|
technicalDetails: rawMessage)
|
||||||
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
|
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost,
|
||||||
|
.internationalRoamingOff, .callIsActive, .dataNotAllowed:
|
||||||
return GatewayConnectionProblem(
|
return GatewayConnectionProblem(
|
||||||
kind: .reachabilityFailed,
|
kind: .reachabilityFailed,
|
||||||
owner: .network,
|
owner: .network,
|
||||||
@@ -575,7 +609,9 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
pauseReconnect: false,
|
pauseReconnect: false,
|
||||||
technicalDetails: rawMessage)
|
technicalDetails: rawMessage)
|
||||||
}
|
}
|
||||||
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
|
if lower.contains("cannot find host") || lower.contains("could not connect") || lower
|
||||||
|
.contains("network is unreachable")
|
||||||
|
{
|
||||||
return GatewayConnectionProblem(
|
return GatewayConnectionProblem(
|
||||||
kind: .reachabilityFailed,
|
kind: .reachabilityFailed,
|
||||||
owner: .network,
|
owner: .network,
|
||||||
@@ -615,7 +651,8 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
owner: .gateway,
|
owner: .gateway,
|
||||||
title: authError.titleOverride ?? "Additional approval required",
|
title: authError.titleOverride ?? "Additional approval required",
|
||||||
message: authError.userMessageOverride
|
message: authError.userMessageOverride
|
||||||
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
|
??
|
||||||
|
"This iPhone is already paired, but it is requesting a new role that was not previously approved.",
|
||||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||||
@@ -643,7 +680,8 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
owner: .gateway,
|
owner: .gateway,
|
||||||
title: authError.titleOverride ?? "Device approval needs refresh",
|
title: authError.titleOverride ?? "Device approval needs refresh",
|
||||||
message: authError.userMessageOverride
|
message: authError.userMessageOverride
|
||||||
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
|
??
|
||||||
|
"The gateway detected a change in this device's approved identity metadata and requires re-approval.",
|
||||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||||
@@ -736,17 +774,17 @@ public enum GatewayConnectionProblemMapper {
|
|||||||
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
|
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
|
||||||
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||||
case "gateway":
|
case "gateway":
|
||||||
return .gateway
|
.gateway
|
||||||
case "iphone", "ios", "device":
|
case "iphone", "ios", "device":
|
||||||
return .iphone
|
.iphone
|
||||||
case "both":
|
case "both":
|
||||||
return .both
|
.both
|
||||||
case "network":
|
case "network":
|
||||||
return .network
|
.network
|
||||||
case "unknown", "":
|
case "unknown", "":
|
||||||
return .unknown
|
.unknown
|
||||||
default:
|
default:
|
||||||
return nil
|
nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,4 +36,3 @@ public enum GatewayDiscoveryStatusText {
|
|||||||
return "Searching…"
|
return "Searching…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawProtocol
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawProtocol
|
||||||
|
|
||||||
public enum GatewayConnectAuthDetailCode: String, Sendable {
|
public enum GatewayConnectAuthDetailCode: String, Sendable {
|
||||||
case authRequired = "AUTH_REQUIRED"
|
case authRequired = "AUTH_REQUIRED"
|
||||||
@@ -129,9 +129,13 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
public var detailCode: String? { self.detailCodeRaw }
|
public var detailCode: String? {
|
||||||
|
self.detailCodeRaw
|
||||||
|
}
|
||||||
|
|
||||||
public var recommendedNextStepCode: String? { self.recommendedNextStepRaw }
|
public var recommendedNextStepCode: String? {
|
||||||
|
self.recommendedNextStepRaw
|
||||||
|
}
|
||||||
|
|
||||||
public var detail: GatewayConnectAuthDetailCode? {
|
public var detail: GatewayConnectAuthDetailCode? {
|
||||||
guard let detailCodeRaw else { return nil }
|
guard let detailCodeRaw else { return nil }
|
||||||
@@ -143,23 +147,25 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
|||||||
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
|
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var errorDescription: String? { self.message }
|
public var errorDescription: String? {
|
||||||
|
self.message
|
||||||
|
}
|
||||||
|
|
||||||
public var isNonRecoverable: Bool {
|
public var isNonRecoverable: Bool {
|
||||||
switch self.detail {
|
switch self.detail {
|
||||||
case .authTokenMissing,
|
case .authTokenMissing,
|
||||||
.authBootstrapTokenInvalid,
|
.authBootstrapTokenInvalid,
|
||||||
.authTokenNotConfigured,
|
.authTokenNotConfigured,
|
||||||
.authPasswordMissing,
|
.authPasswordMissing,
|
||||||
.authPasswordMismatch,
|
.authPasswordMismatch,
|
||||||
.authPasswordNotConfigured,
|
.authPasswordNotConfigured,
|
||||||
.authRateLimited,
|
.authRateLimited,
|
||||||
.pairingRequired,
|
.pairingRequired,
|
||||||
.controlUiDeviceIdentityRequired,
|
.controlUiDeviceIdentityRequired,
|
||||||
.deviceIdentityRequired:
|
.deviceIdentityRequired:
|
||||||
return true
|
true
|
||||||
default:
|
default:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,5 +209,7 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
|
|||||||
self.message = message
|
self.message = message
|
||||||
}
|
}
|
||||||
|
|
||||||
public var errorDescription: String? { "\(self.method): \(self.message)" }
|
public var errorDescription: String? {
|
||||||
|
"\(self.method): \(self.message)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import OpenClawProtocol
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawProtocol
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private struct NodeInvokeRequestPayload: Codable, Sendable {
|
private struct NodeInvokeRequestPayload: Codable {
|
||||||
var id: String
|
var id: String
|
||||||
var nodeId: String
|
var nodeId: String
|
||||||
var command: String
|
var command: String
|
||||||
@@ -19,7 +19,7 @@ private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capabilit
|
|||||||
let nextSlash = suffix.firstIndex(of: "/")
|
let nextSlash = suffix.firstIndex(of: "/")
|
||||||
let nextQuery = suffix.firstIndex(of: "?")
|
let nextQuery = suffix.firstIndex(of: "?")
|
||||||
let nextFragment = suffix.firstIndex(of: "#")
|
let nextFragment = suffix.firstIndex(of: "#")
|
||||||
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex
|
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap(\.self).min() ?? scopedUrl.endIndex
|
||||||
guard capabilityStart < capabilityEnd else { return nil }
|
guard capabilityStart < capabilityEnd else { return nil }
|
||||||
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
|
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
|
||||||
}
|
}
|
||||||
@@ -55,12 +55,11 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
|
|||||||
return parsed.string ?? trimmed
|
return parsed.string ?? trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public actor GatewayNodeSession {
|
public actor GatewayNodeSession {
|
||||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private static let defaultInvokeTimeoutMs = 30_000
|
private static let defaultInvokeTimeoutMs = 30000
|
||||||
private var channel: GatewayChannelActor?
|
private var channel: GatewayChannelActor?
|
||||||
private var activeURL: URL?
|
private var activeURL: URL?
|
||||||
private var activeToken: String?
|
private var activeToken: String?
|
||||||
@@ -79,8 +78,8 @@ public actor GatewayNodeSession {
|
|||||||
static func invokeWithTimeout(
|
static func invokeWithTimeout(
|
||||||
request: BridgeInvokeRequest,
|
request: BridgeInvokeRequest,
|
||||||
timeoutMs: Int?,
|
timeoutMs: Int?,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async -> BridgeInvokeResponse
|
||||||
) async -> BridgeInvokeResponse {
|
{
|
||||||
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||||
let timeout: Int = {
|
let timeout: Int = {
|
||||||
if let timeoutMs { return max(0, timeoutMs) }
|
if let timeoutMs { return max(0, timeoutMs) }
|
||||||
@@ -144,13 +143,14 @@ public actor GatewayNodeSession {
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: OpenClawNodeError(
|
error: OpenClawNodeError(
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "node invoke timed out")
|
message: "node invoke timed out")))
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
timeoutLogger
|
||||||
|
.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||||
private var canvasHostUrl: String?
|
private var canvasHostUrl: String?
|
||||||
|
|
||||||
@@ -201,8 +201,8 @@ public actor GatewayNodeSession {
|
|||||||
sessionBox: WebSocketSessionBox?,
|
sessionBox: WebSocketSessionBox?,
|
||||||
onConnected: @escaping @Sendable () async -> Void,
|
onConnected: @escaping @Sendable () async -> Void,
|
||||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||||
) async throws {
|
{
|
||||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||||
let shouldReconnect = self.activeURL != url ||
|
let shouldReconnect = self.activeURL != url ||
|
||||||
self.activeToken != token ||
|
self.activeToken != token ||
|
||||||
@@ -273,7 +273,7 @@ public actor GatewayNodeSession {
|
|||||||
self.canvasHostUrl
|
self.canvasHostUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool {
|
public func refreshNodeCanvasCapability(timeoutMs: Int = 8000) async -> Bool {
|
||||||
guard let channel = self.channel else { return false }
|
guard let channel = self.channel else { return false }
|
||||||
do {
|
do {
|
||||||
let data = try await channel.request(
|
let data = try await channel.request(
|
||||||
@@ -455,8 +455,7 @@ public actor GatewayNodeSession {
|
|||||||
let response = await Self.invokeWithTimeout(
|
let response = await Self.invokeWithTimeout(
|
||||||
request: req,
|
request: req,
|
||||||
timeoutMs: request.timeoutMs,
|
timeoutMs: request.timeoutMs,
|
||||||
onInvoke: onInvoke
|
onInvoke: onInvoke)
|
||||||
)
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||||
await self.sendInvokeResult(request: request, response: response)
|
await self.sendInvokeResult(request: request, response: response)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenClawProtocol
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawProtocol
|
||||||
|
|
||||||
public enum GatewayPayloadDecoding {
|
public enum GatewayPayloadDecoding {
|
||||||
public static func decode<T: Decodable>(
|
public static func decode<T: Decodable>(
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ public enum GatewayTLSStore {
|
|||||||
!existing.isEmpty
|
!existing.isEmpty
|
||||||
else { return }
|
else { return }
|
||||||
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
|
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
|
||||||
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID) else {
|
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID)
|
||||||
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,8 +109,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||||||
public func urlSession(
|
public func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
|
||||||
) {
|
{
|
||||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||||
let trust = challenge.protectionSpace.serverTrust
|
let trust = challenge.protectionSpace.serverTrust
|
||||||
else {
|
else {
|
||||||
@@ -117,7 +118,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let expected = params.expectedFingerprint.map(normalizeFingerprint)
|
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
|
||||||
if let fingerprint = certificateFingerprint(trust) {
|
if let fingerprint = certificateFingerprint(trust) {
|
||||||
if let expected {
|
if let expected {
|
||||||
if fingerprint == expected {
|
if fingerprint == expected {
|
||||||
@@ -127,7 +128,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if params.allowTOFU {
|
if self.params.allowTOFU {
|
||||||
if let storeKey = params.storeKey {
|
if let storeKey = params.storeKey {
|
||||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||||
if ok || !params.required {
|
if ok || !self.params.required {
|
||||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||||
} else {
|
} else {
|
||||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ public enum GenericPasswordKeychainStore {
|
|||||||
_ value: String,
|
_ value: String,
|
||||||
service: String,
|
service: String,
|
||||||
account: String,
|
account: String,
|
||||||
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) -> Bool
|
||||||
) -> Bool {
|
{
|
||||||
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
|
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ public enum GenericPasswordKeychainStore {
|
|||||||
_ data: Data,
|
_ data: Data,
|
||||||
service: String,
|
service: String,
|
||||||
account: String,
|
account: String,
|
||||||
accessible: CFString
|
accessible: CFString) -> Bool
|
||||||
) -> Bool {
|
{
|
||||||
let query = self.baseQuery(service: service, account: account)
|
let query = self.baseQuery(service: service, account: account)
|
||||||
let previousData = self.loadData(service: service, account: account)
|
let previousData = self.loadData(service: service, account: account)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public enum InstanceIdentity {
|
|||||||
UserDefaults(suiteName: suiteName) ?? .standard
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
|
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
return MainActor.assumeIsolated { body() }
|
return MainActor.assumeIsolated { body() }
|
||||||
@@ -21,7 +21,7 @@ public enum InstanceIdentity {
|
|||||||
MainActor.assumeIsolated { body() }
|
MainActor.assumeIsolated { body() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public static let instanceId: String = {
|
public static let instanceId: String = {
|
||||||
let defaults = Self.defaults
|
let defaults = Self.defaults
|
||||||
@@ -38,23 +38,23 @@ public enum InstanceIdentity {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
public static let displayName: String = {
|
public static let displayName: String = {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
let name = Self.readMainActor {
|
let name = Self.readMainActor {
|
||||||
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
return name.isEmpty ? "openclaw" : name
|
return name.isEmpty ? "openclaw" : name
|
||||||
#else
|
#else
|
||||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!name.isEmpty
|
!name.isEmpty
|
||||||
{
|
{
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return "openclaw"
|
return "openclaw"
|
||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static let modelIdentifier: String? = {
|
public static let modelIdentifier: String? = {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
var systemInfo = utsname()
|
var systemInfo = utsname()
|
||||||
uname(&systemInfo)
|
uname(&systemInfo)
|
||||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||||
@@ -62,7 +62,7 @@ public enum InstanceIdentity {
|
|||||||
}
|
}
|
||||||
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
#else
|
#else
|
||||||
var size = 0
|
var size = 0
|
||||||
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
|
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
|
||||||
|
|
||||||
@@ -73,36 +73,36 @@ public enum InstanceIdentity {
|
|||||||
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
|
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static let deviceFamily: String = {
|
public static let deviceFamily: String = {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
return Self.readMainActor {
|
return Self.readMainActor {
|
||||||
switch UIDevice.current.userInterfaceIdiom {
|
switch UIDevice.current.userInterfaceIdiom {
|
||||||
case .pad: return "iPad"
|
case .pad: "iPad"
|
||||||
case .phone: return "iPhone"
|
case .phone: "iPhone"
|
||||||
default: return "iOS"
|
default: "iOS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
return "Mac"
|
return "Mac"
|
||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static let platformString: String = {
|
public static let platformString: String = {
|
||||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
let name = Self.readMainActor {
|
let name = Self.readMainActor {
|
||||||
switch UIDevice.current.userInterfaceIdiom {
|
switch UIDevice.current.userInterfaceIdiom {
|
||||||
case .pad: return "iPadOS"
|
case .pad: "iPadOS"
|
||||||
case .phone: return "iOS"
|
case .phone: "iOS"
|
||||||
default: return "iOS"
|
default: "iOS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
#else
|
#else
|
||||||
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import Foundation
|
|||||||
public enum LocationCurrentRequest {
|
public enum LocationCurrentRequest {
|
||||||
public typealias TimeoutRunner = @Sendable (
|
public typealias TimeoutRunner = @Sendable (
|
||||||
_ timeoutMs: Int,
|
_ timeoutMs: Int,
|
||||||
_ operation: @escaping @Sendable () async throws -> CLLocation
|
_ operation: @escaping @Sendable () async throws -> CLLocation) async throws -> CLLocation
|
||||||
) async throws -> CLLocation
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public static func resolve(
|
public static func resolve(
|
||||||
|
|||||||
@@ -7,21 +7,21 @@ public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate {
|
|||||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { get set }
|
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension LocationServiceCommon {
|
extension LocationServiceCommon {
|
||||||
func configureLocationManager() {
|
public func configureLocationManager() {
|
||||||
self.locationManager.delegate = self
|
self.locationManager.delegate = self
|
||||||
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizationStatus() -> CLAuthorizationStatus {
|
public func authorizationStatus() -> CLAuthorizationStatus {
|
||||||
self.locationManager.authorizationStatus
|
self.locationManager.authorizationStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
public func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||||
LocationServiceSupport.accuracyAuthorization(manager: self.locationManager)
|
LocationServiceSupport.accuracyAuthorization(manager: self.locationManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLocationOnce() async throws -> CLLocation {
|
public func requestLocationOnce() async throws -> CLLocation {
|
||||||
try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in
|
try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in
|
||||||
self.locationRequestContinuation = continuation
|
self.locationRequestContinuation = continuation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public enum OpenClawKitResources {
|
|||||||
private static func locateBundle() -> Bundle {
|
private static func locateBundle() -> Bundle {
|
||||||
// 1. Check inside Bundle.main (packaged apps copy resources here)
|
// 1. Check inside Bundle.main (packaged apps copy resources here)
|
||||||
if let mainResourceURL = Bundle.main.resourceURL {
|
if let mainResourceURL = Bundle.main.resourceURL {
|
||||||
let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle")
|
let bundleURL = mainResourceURL.appendingPathComponent("\(self.bundleName).bundle")
|
||||||
if let bundle = Bundle(url: bundleURL) {
|
if let bundle = Bundle(url: bundleURL) {
|
||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ public enum OpenClawKitResources {
|
|||||||
roots.append(baseURL.appendingPathComponent("Contents/Resources"))
|
roots.append(baseURL.appendingPathComponent("Contents/Resources"))
|
||||||
|
|
||||||
var current = baseURL
|
var current = baseURL
|
||||||
for _ in 0 ..< 5 {
|
for _ in 0..<5 {
|
||||||
current = current.deletingLastPathComponent()
|
current = current.deletingLastPathComponent()
|
||||||
roots.append(current)
|
roots.append(current)
|
||||||
roots.append(current.appendingPathComponent("Resources"))
|
roots.append(current.appendingPathComponent("Resources"))
|
||||||
@@ -68,7 +68,7 @@ public enum OpenClawKitResources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for root in roots {
|
for root in roots {
|
||||||
let bundleURL = root.appendingPathComponent("\(bundleName).bundle")
|
let bundleURL = root.appendingPathComponent("\(self.bundleName).bundle")
|
||||||
if let bundle = Bundle(url: bundleURL) {
|
if let bundle = Bundle(url: bundleURL) {
|
||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
@@ -79,5 +79,5 @@ public enum OpenClawKitResources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper class for bundle lookup via Bundle(for:)
|
/// Helper class for bundle lookup via Bundle(for:)
|
||||||
private final class BundleLocator {}
|
private final class BundleLocator {}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ public enum PhotoCapture {
|
|||||||
rawData: Data,
|
rawData: Data,
|
||||||
maxWidthPx: Int,
|
maxWidthPx: Int,
|
||||||
quality: Double,
|
quality: Double,
|
||||||
maxPayloadBytes: Int = 5 * 1024 * 1024
|
maxPayloadBytes: Int = 5 * 1024 * 1024) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||||
) throws -> (data: Data, widthPx: Int, heightPx: Int) {
|
{
|
||||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
|
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
|
||||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||||
return try JPEGTranscoder.transcodeToJPEG(
|
return try JPEGTranscoder.transcodeToJPEG(
|
||||||
@@ -16,4 +16,3 @@ public enum PhotoCapture {
|
|||||||
maxBytes: maxEncodedBytes)
|
maxBytes: maxEncodedBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public enum ShareToAgentDeepLink {
|
|||||||
let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
|
let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
|
||||||
|
|
||||||
var lines: [String] = ["Shared from iOS."]
|
var lines = ["Shared from iOS."]
|
||||||
if let title, !title.isEmpty {
|
if let title, !title.isEmpty {
|
||||||
lines.append("Title: \(title)")
|
lines.append("Title: \(title)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ public enum TalkConfigParsing {
|
|||||||
public static func selectProviderConfig(
|
public static func selectProviderConfig(
|
||||||
_ talk: [String: AnyCodable]?,
|
_ talk: [String: AnyCodable]?,
|
||||||
defaultProvider: String,
|
defaultProvider: String,
|
||||||
allowLegacyFallback: Bool = true,
|
allowLegacyFallback: Bool = true) -> TalkProviderConfigSelection?
|
||||||
) -> TalkProviderConfigSelection? {
|
{
|
||||||
guard let talk else { return nil }
|
guard let talk else { return nil }
|
||||||
if let resolvedSelection = self.resolvedProviderConfig(talk) {
|
if let resolvedSelection = self.resolvedProviderConfig(talk) {
|
||||||
return resolvedSelection
|
return resolvedSelection
|
||||||
@@ -63,16 +63,16 @@ public enum TalkConfigParsing {
|
|||||||
|
|
||||||
public static func resolvedSpeechLocaleID(
|
public static func resolvedSpeechLocaleID(
|
||||||
_ talk: [String: AnyCodable]?,
|
_ talk: [String: AnyCodable]?,
|
||||||
fallback: String? = nil
|
fallback: String? = nil) -> String?
|
||||||
) -> String? {
|
{
|
||||||
self.normalizedSpeechLocaleID(talk?["speechLocale"]?.stringValue)
|
self.normalizedSpeechLocaleID(talk?["speechLocale"]?.stringValue)
|
||||||
?? self.normalizedSpeechLocaleID(fallback)
|
?? self.normalizedSpeechLocaleID(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func normalizedExplicitSpeechLocaleID(
|
public static func normalizedExplicitSpeechLocaleID(
|
||||||
_ value: String?,
|
_ value: String?,
|
||||||
automaticID: String = "auto"
|
automaticID: String = "auto") -> String?
|
||||||
) -> String? {
|
{
|
||||||
let normalized = self.normalizedSpeechLocaleID(value)
|
let normalized = self.normalizedSpeechLocaleID(value)
|
||||||
return normalized == automaticID ? nil : normalized
|
return normalized == automaticID ? nil : normalized
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,8 @@ public enum TalkConfigParsing {
|
|||||||
public static func resolvedSpeechRecognitionLocaleID(
|
public static func resolvedSpeechRecognitionLocaleID(
|
||||||
preferredLocaleIDs: [String?],
|
preferredLocaleIDs: [String?],
|
||||||
fallbackLocaleID: String = "en-US",
|
fallbackLocaleID: String = "en-US",
|
||||||
supportedLocaleIDs: Set<String>
|
supportedLocaleIDs: Set<String>) -> String?
|
||||||
) -> String? {
|
{
|
||||||
let supported = Set(supportedLocaleIDs.compactMap(self.normalizedSpeechLocaleID))
|
let supported = Set(supportedLocaleIDs.compactMap(self.normalizedSpeechLocaleID))
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
let candidates = (preferredLocaleIDs + [fallbackLocaleID])
|
let candidates = (preferredLocaleIDs + [fallbackLocaleID])
|
||||||
@@ -102,8 +102,8 @@ public enum TalkConfigParsing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func resolvedProviderConfig(
|
private static func resolvedProviderConfig(
|
||||||
_ talk: [String: AnyCodable]
|
_ talk: [String: AnyCodable]) -> TalkProviderConfigSelection?
|
||||||
) -> TalkProviderConfigSelection? {
|
{
|
||||||
guard
|
guard
|
||||||
let resolved = talk["resolved"]?.dictionaryValue,
|
let resolved = talk["resolved"]?.dictionaryValue,
|
||||||
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
|
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ public enum TalkPromptBuilder: Sendable {
|
|||||||
public static func build(
|
public static func build(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
interruptedAtSeconds: Double?,
|
interruptedAtSeconds: Double?,
|
||||||
includeVoiceDirectiveHint: Bool = true
|
includeVoiceDirectiveHint: Bool = true) -> String
|
||||||
) -> String {
|
{
|
||||||
var lines: [String] = [
|
var lines: [String] = [
|
||||||
"Talk Mode active. Reply in a concise, spoken tone.",
|
"Talk Mode active. Reply in a concise, spoken tone.",
|
||||||
]
|
]
|
||||||
|
|
||||||
if includeVoiceDirectiveHint {
|
if includeVoiceDirectiveHint {
|
||||||
lines.append(
|
lines.append(
|
||||||
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}."
|
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}.")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let interruptedAtSeconds {
|
if let interruptedAtSeconds {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
|||||||
private var currentToken = UUID()
|
private var currentToken = UUID()
|
||||||
private var watchdog: Task<Void, Never>?
|
private var watchdog: Task<Void, Never>?
|
||||||
|
|
||||||
public var isSpeaking: Bool { self.synth.isSpeaking }
|
public var isSpeaking: Bool {
|
||||||
|
self.synth.isSpeaking
|
||||||
|
}
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -35,8 +37,8 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
|||||||
public func speak(
|
public func speak(
|
||||||
text: String,
|
text: String,
|
||||||
language: String? = nil,
|
language: String? = nil,
|
||||||
onStart: (() -> Void)? = nil
|
onStart: (() -> Void)? = nil) async throws
|
||||||
) async throws {
|
{
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
@@ -51,7 +53,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
|||||||
}
|
}
|
||||||
self.currentUtterance = utterance
|
self.currentUtterance = utterance
|
||||||
|
|
||||||
let watchdogTimeout = Self.watchdogTimeoutSeconds(text: trimmed, language: language ?? utterance.voice?.language)
|
let watchdogTimeout = Self.watchdogTimeoutSeconds(
|
||||||
|
text: trimmed,
|
||||||
|
language: language ?? utterance.voice?.language)
|
||||||
self.watchdog?.cancel()
|
self.watchdog?.cancel()
|
||||||
self.watchdog = Task { @MainActor [weak self] in
|
self.watchdog = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import Foundation
|
|||||||
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||||
public let value: Any
|
public let value: Any
|
||||||
|
|
||||||
public init(_ value: Any) { self.value = Self.normalize(value) }
|
public init(_ value: Any) {
|
||||||
|
self.value = Self.normalize(value)
|
||||||
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ public func anyCodableBool(_ value: AnyCodable?) -> Bool {
|
|||||||
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
|
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let arr as [AnyCodable]:
|
case let arr as [AnyCodable]:
|
||||||
return arr
|
arr
|
||||||
case let arr as [Any]:
|
case let arr as [Any]:
|
||||||
return arr.map { AnyCodable($0) }
|
arr.map { AnyCodable($0) }
|
||||||
default:
|
default:
|
||||||
return []
|
[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ Gateway-side requirement:
|
|||||||
|
|
||||||
How the flow works:
|
How the flow works:
|
||||||
|
|
||||||
- The iOS app registers with the relay using App Attest and the app receipt.
|
- The iOS app registers with the relay using App Attest and a StoreKit app transaction JWS.
|
||||||
- The relay returns an opaque relay handle plus a registration-scoped send grant.
|
- The relay returns an opaque relay handle plus a registration-scoped send grant.
|
||||||
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
|
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
|
||||||
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
|
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
|
||||||
@@ -136,8 +136,8 @@ Hop by hop:
|
|||||||
|
|
||||||
2. `iOS app -> relay`
|
2. `iOS app -> relay`
|
||||||
- The app calls the relay registration endpoints over HTTPS.
|
- The app calls the relay registration endpoints over HTTPS.
|
||||||
- Registration includes App Attest proof plus the app receipt.
|
- Registration includes App Attest proof plus a StoreKit app transaction JWS.
|
||||||
- The relay validates the bundle ID, App Attest proof, and Apple receipt, and requires the
|
- The relay validates the bundle ID, App Attest proof, and Apple distribution proof, and requires the
|
||||||
official/production distribution path.
|
official/production distribution path.
|
||||||
- This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be
|
- This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be
|
||||||
signed, but it does not satisfy the official Apple distribution proof the relay expects.
|
signed, but it does not satisfy the official Apple distribution proof the relay expects.
|
||||||
@@ -227,6 +227,18 @@ Notes:
|
|||||||
- The iOS node auto-navigates to A2UI on connect when a canvas host URL is advertised.
|
- The iOS node auto-navigates to A2UI on connect when a canvas host URL is advertised.
|
||||||
- Return to the built-in scaffold with `canvas.navigate` and `{"url":""}`.
|
- Return to the built-in scaffold with `canvas.navigate` and `{"url":""}`.
|
||||||
|
|
||||||
|
## Computer Use relationship
|
||||||
|
|
||||||
|
The iOS app is a mobile node surface, not a Codex Computer Use backend. Codex
|
||||||
|
Computer Use and `cua-driver mcp` control a local macOS desktop through MCP
|
||||||
|
tools; the iOS app exposes iPhone capabilities through OpenClaw node commands
|
||||||
|
such as `canvas.*`, `camera.*`, `screen.*`, `location.*`, and `talk.*`.
|
||||||
|
|
||||||
|
Agents can still operate the iOS app through OpenClaw by invoking node
|
||||||
|
commands, but those calls go through the gateway node protocol and follow iOS
|
||||||
|
foreground/background limits. Use [Codex Computer Use](/plugins/codex-computer-use)
|
||||||
|
for local desktop control and this page for iOS node capabilities.
|
||||||
|
|
||||||
### Canvas eval / snapshot
|
### Canvas eval / snapshot
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ a permission-aware host for Peekaboo CLI automation. Use this page when a
|
|||||||
Codex-mode OpenClaw agent should have Codex's native `computer-use` MCP plugin
|
Codex-mode OpenClaw agent should have Codex's native `computer-use` MCP plugin
|
||||||
available before the turn starts.
|
available before the turn starts.
|
||||||
|
|
||||||
|
## iOS app
|
||||||
|
|
||||||
|
The iOS app is separate from Codex Computer Use. It does not install or proxy
|
||||||
|
the Codex `computer-use` MCP server and it is not a desktop-control backend.
|
||||||
|
Instead, the iOS app connects as an OpenClaw node and exposes mobile
|
||||||
|
capabilities through node commands such as `canvas.*`, `camera.*`, `screen.*`,
|
||||||
|
`location.*`, and `talk.*`.
|
||||||
|
|
||||||
|
Use [iOS](/platforms/ios) when you want an agent to drive an iPhone node through
|
||||||
|
the gateway. Use this page when a Codex-mode agent should control the local
|
||||||
|
macOS desktop through Codex's native Computer Use plugin.
|
||||||
|
|
||||||
## Direct cua-driver MCP
|
## Direct cua-driver MCP
|
||||||
|
|
||||||
Codex Computer Use is not the only way to expose desktop control. If you want
|
Codex Computer Use is not the only way to expose desktop control. If you want
|
||||||
|
|||||||
Reference in New Issue
Block a user