mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix: harden ios app build hygiene
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
import AVFoundation
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
|
||||
actor CameraController {
|
||||
struct CameraDeviceInfo: Codable, Sendable {
|
||||
struct CameraDeviceInfo: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var position: String
|
||||
var deviceType: String
|
||||
}
|
||||
|
||||
enum CameraError: LocalizedError, Sendable {
|
||||
enum CameraError: LocalizedError {
|
||||
case cameraUnavailable
|
||||
case microphoneUnavailable
|
||||
case permissionDenied(kind: String)
|
||||
@@ -142,7 +142,7 @@ actor CameraController {
|
||||
}
|
||||
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
return Self.discoverVideoDevices().map { device in
|
||||
Self.discoverVideoDevices().map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
@@ -152,7 +152,7 @@ actor CameraController {
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ actor CameraController {
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||
if let match = discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -270,8 +270,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
@@ -303,8 +303,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard let error else { return }
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
@@ -70,10 +70,9 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
{
|
||||
let startLogMessage =
|
||||
"chat.send start sessionKey=\(sessionKey) "
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)"
|
||||
)
|
||||
"\(startLogMessage, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
|
||||
@@ -72,7 +72,7 @@ final class ContactsService: ContactsServicing {
|
||||
contact.givenName = givenName ?? ""
|
||||
contact.familyName = familyName ?? ""
|
||||
contact.organizationName = organizationName ?? ""
|
||||
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
|
||||
if contact.givenName.isEmpty, contact.familyName.isEmpty, let displayName {
|
||||
contact.givenName = displayName
|
||||
}
|
||||
contact.phoneNumbers = phoneNumbers.map {
|
||||
@@ -86,13 +86,12 @@ final class ContactsService: ContactsServicing {
|
||||
save.add(contact, toContainerWithIdentifier: nil)
|
||||
try store.execute(save)
|
||||
|
||||
let persisted: CNContact
|
||||
if !contact.identifier.isEmpty {
|
||||
persisted = try store.unifiedContact(
|
||||
let persisted: CNContact = if !contact.identifier.isEmpty {
|
||||
try store.unifiedContact(
|
||||
withIdentifier: contact.identifier,
|
||||
keysToFetch: Self.payloadKeys)
|
||||
} else {
|
||||
persisted = contact
|
||||
contact
|
||||
}
|
||||
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||
@@ -137,7 +136,7 @@ final class ContactsService: ContactsServicing {
|
||||
phoneNumbers: [String],
|
||||
emails: [String]) throws -> CNContact?
|
||||
{
|
||||
if phoneNumbers.isEmpty && emails.isEmpty {
|
||||
if phoneNumbers.isEmpty, emails.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,13 +162,13 @@ final class ContactsService: ContactsServicing {
|
||||
phoneNumbers: [String],
|
||||
emails: [String]) -> CNContact?
|
||||
{
|
||||
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
|
||||
let normalizedPhones = Set(phoneNumbers.map { self.normalizePhone($0) }.filter { !$0.isEmpty })
|
||||
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
|
||||
var seen = Set<String>()
|
||||
|
||||
for contact in contacts {
|
||||
guard seen.insert(contact.identifier).inserted else { continue }
|
||||
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
|
||||
let contactPhones = Set(contact.phoneNumbers.map { self.normalizePhone($0.value.stringValue) })
|
||||
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
|
||||
|
||||
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
||||
@@ -198,13 +197,13 @@ final class ContactsService: ContactsServicing {
|
||||
givenName: contact.givenName,
|
||||
familyName: contact.familyName,
|
||||
organizationName: contact.organizationName,
|
||||
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
|
||||
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
import Darwin
|
||||
|
||||
/// Shared device and platform info for Settings, gateway node payloads, and device status.
|
||||
enum DeviceInfoHelper {
|
||||
/// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads.
|
||||
@@ -65,8 +64,8 @@ enum DeviceInfoHelper {
|
||||
|
||||
/// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs.
|
||||
static func openClawVersionString() -> String {
|
||||
let version = appVersion()
|
||||
let build = appBuild()
|
||||
let version = self.appVersion()
|
||||
let build = self.appBuild()
|
||||
if build.isEmpty || build == version {
|
||||
return version
|
||||
}
|
||||
|
||||
@@ -5,25 +5,25 @@ enum NodeDisplayName {
|
||||
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
||||
|
||||
static func isGeneric(_ name: String) -> Bool {
|
||||
Self.genericNames.contains(name)
|
||||
self.genericNames.contains(name)
|
||||
}
|
||||
|
||||
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
||||
switch interfaceIdiom {
|
||||
case .phone:
|
||||
return "iPhone Node"
|
||||
"iPhone Node"
|
||||
case .pad:
|
||||
return "iPad Node"
|
||||
"iPad Node"
|
||||
default:
|
||||
return "iOS Node"
|
||||
"iOS Node"
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
existing: String?,
|
||||
deviceName: String,
|
||||
interfaceIdiom: UIUserInterfaceIdiom
|
||||
) -> String {
|
||||
interfaceIdiom: UIUserInterfaceIdiom) -> String
|
||||
{
|
||||
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
||||
return trimmedExisting
|
||||
|
||||
@@ -31,4 +31,3 @@ enum EventKitAuthorization {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import OpenClawKit
|
||||
///
|
||||
/// Both sessions should derive all connection inputs from this config so we
|
||||
/// don't accidentally persist gateway-scoped state under different keys.
|
||||
struct GatewayConnectConfig: Sendable {
|
||||
struct GatewayConnectConfig {
|
||||
let url: URL
|
||||
let stableID: String
|
||||
let tls: GatewayTLSParams?
|
||||
|
||||
@@ -3,12 +3,12 @@ import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import EventKit
|
||||
import Foundation
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import Photos
|
||||
import ReplayKit
|
||||
@@ -28,7 +28,9 @@ final class GatewayConnectionController {
|
||||
let fingerprintSha256: String
|
||||
let isManual: Bool
|
||||
|
||||
var id: String { self.stableID }
|
||||
var id: String {
|
||||
self.stableID
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||
@@ -86,7 +88,6 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
}
|
||||
|
||||
|
||||
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
||||
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
@@ -177,7 +178,7 @@ final class GatewayConnectionController {
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||
self.appModel?.gatewayStatusText =
|
||||
"TLS handshake failed for \(host):\(resolvedPort). "
|
||||
+ "Remote gateways must use HTTPS/WSS."
|
||||
+ "Remote gateways must use HTTPS/WSS."
|
||||
return
|
||||
}
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||
@@ -557,11 +558,11 @@ final class GatewayConnectionController {
|
||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
return (host: host.debugDescription, port: Int(port.rawValue))
|
||||
(host: host.debugDescription, port: Int(port.rawValue))
|
||||
case let .service(name, type, domain, _):
|
||||
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,8 +570,8 @@ final class GatewayConnectionController {
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0
|
||||
) async -> (host: String, port: Int)? {
|
||||
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
|
||||
{
|
||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||
@@ -636,8 +637,8 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
guard let addrs = svc.addresses else { return nil }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
@@ -764,7 +765,8 @@ final class GatewayConnectionController {
|
||||
|
||||
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||
if let stableID,
|
||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID)
|
||||
{
|
||||
return override
|
||||
}
|
||||
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||
@@ -781,7 +783,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return nil }
|
||||
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
||||
if useTLS, self.shouldForceTLS(host: trimmedHost) {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
@@ -929,9 +931,9 @@ final class GatewayConnectionController {
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,8 +1047,8 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
|
||||
{
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
|
||||
@@ -19,9 +19,9 @@ enum GatewayConnectionIssue: Equatable {
|
||||
var needsAuthToken: Bool {
|
||||
switch self {
|
||||
case .tokenMissing, .unauthorized:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +40,17 @@ enum GatewayConnectionIssue: Equatable {
|
||||
}
|
||||
switch problem.kind {
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch,
|
||||
.tailscaleIdentityMissing,
|
||||
.tailscaleProxyMissing,
|
||||
.tailscaleWhoisFailed,
|
||||
.tailscaleIdentityMismatch,
|
||||
.authRateLimited:
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch,
|
||||
.tailscaleIdentityMissing,
|
||||
.tailscaleProxyMissing,
|
||||
.tailscaleWhoisFailed,
|
||||
.tailscaleIdentityMismatch,
|
||||
.authRateLimited:
|
||||
return .unauthorized
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .network
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -13,7 +13,10 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var id: String {
|
||||
self.stableID
|
||||
}
|
||||
|
||||
var name: String
|
||||
var endpoint: NWEndpoint
|
||||
var stableID: String
|
||||
|
||||
@@ -3,7 +3,7 @@ import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class GatewayHealthMonitor {
|
||||
struct Config: Sendable {
|
||||
struct Config {
|
||||
var intervalSeconds: Double
|
||||
var timeoutSeconds: Double
|
||||
var maxFailures: Int
|
||||
@@ -17,8 +17,8 @@ final class GatewayHealthMonitor {
|
||||
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
||||
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
) {
|
||||
})
|
||||
{
|
||||
self.config = config
|
||||
self.sleep = sleep
|
||||
}
|
||||
@@ -67,7 +67,7 @@ final class GatewayHealthMonitor {
|
||||
{
|
||||
let timeout = max(0.0, timeoutSeconds)
|
||||
if timeout == 0 {
|
||||
return (try? await check()) ?? false
|
||||
return await (try? check()) ?? false
|
||||
}
|
||||
do {
|
||||
let timeoutError = NSError(
|
||||
|
||||
@@ -59,58 +59,57 @@ struct GatewayProblemBanner: View {
|
||||
.padding(14)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return "person.crop.circle.badge.clock"
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
"person.crop.circle.badge.clock"
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return "wifi.exclamationmark"
|
||||
"wifi.exclamationmark"
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch:
|
||||
return "lock.shield"
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch:
|
||||
"lock.shield"
|
||||
default:
|
||||
return "exclamationmark.triangle.fill"
|
||||
"exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return .orange
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
.orange
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .yellow
|
||||
.yellow
|
||||
default:
|
||||
return .red
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private var ownerLabel: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Fix on gateway"
|
||||
"Fix on gateway"
|
||||
case .iphone:
|
||||
return "Fix on iPhone"
|
||||
"Fix on iPhone"
|
||||
case .both:
|
||||
return "Check both"
|
||||
"Check both"
|
||||
case .network:
|
||||
return "Check network"
|
||||
"Check network"
|
||||
case .unknown:
|
||||
return "Needs attention"
|
||||
"Needs attention"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,15 +217,15 @@ struct GatewayProblemDetailsSheet: View {
|
||||
private var ownerSummary: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Primary fix: gateway"
|
||||
"Primary fix: gateway"
|
||||
case .iphone:
|
||||
return "Primary fix: this iPhone"
|
||||
"Primary fix: this iPhone"
|
||||
case .both:
|
||||
return "Primary fix: check both this iPhone and the gateway"
|
||||
"Primary fix: check both this iPhone and the gateway"
|
||||
case .network:
|
||||
return "Primary fix: network or remote access"
|
||||
"Primary fix: network or remote access"
|
||||
case .unknown:
|
||||
return "Primary fix: review details and retry"
|
||||
"Primary fix: review details and retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// NetService-based resolver for Bonjour services.
|
||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
/// NetService-based resolver for Bonjour services.
|
||||
/// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private let service: NetService
|
||||
private let completion: ((host: String, port: Int)?) -> Void
|
||||
@@ -38,7 +38,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
self.finish(result: nil)
|
||||
}
|
||||
|
||||
private func finish(result: ((host: String, port: Int))?) {
|
||||
private func finish(result: (host: String, port: Int)?) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
|
||||
@@ -52,8 +52,7 @@ enum GatewaySettingsStore {
|
||||
static func loadPreferredGatewayStableID() -> String? {
|
||||
if let value = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.preferredGatewayStableIDAccount
|
||||
)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
account: self.preferredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
{
|
||||
return value
|
||||
@@ -79,8 +78,7 @@ enum GatewaySettingsStore {
|
||||
static func loadLastDiscoveredGatewayStableID() -> String? {
|
||||
if let value = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.lastDiscoveredGatewayStableIDAccount
|
||||
)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
account: self.lastDiscoveredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
{
|
||||
return value
|
||||
@@ -160,18 +158,18 @@ enum GatewaySettingsStore {
|
||||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
return stableID
|
||||
stableID
|
||||
case let .discovered(stableID, _):
|
||||
return stableID
|
||||
stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
return useTLS
|
||||
useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
return useTLS
|
||||
useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +444,6 @@ enum GatewaySettingsStore {
|
||||
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
@@ -518,7 +515,7 @@ enum GatewayDiagnostics {
|
||||
|
||||
static func bootstrap() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.queue.async {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
let timestamp = self.isoTimestamp()
|
||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||
@@ -532,10 +529,10 @@ enum GatewayDiagnostics {
|
||||
static func log(_ message: String) {
|
||||
let timestamp = self.isoTimestamp()
|
||||
let line = "[\(timestamp)] \(message)"
|
||||
logger.info("\(line, privacy: .public)")
|
||||
self.logger.info("\(line, privacy: .public)")
|
||||
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.queue.async {
|
||||
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
||||
count += 1
|
||||
if count >= self.logSizeCheckEveryWrites {
|
||||
@@ -556,7 +553,7 @@ enum GatewayDiagnostics {
|
||||
|
||||
static func reset() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.queue.async {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,3 @@ enum TCPProbe {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,7 @@ private struct HomeToolbarStatusButton: View {
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
: 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
@@ -214,8 +213,7 @@ private struct HomeToolbarActionButton: View {
|
||||
(self.tint ?? .white).opacity(
|
||||
self.isActive
|
||||
? 0.34
|
||||
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))
|
||||
),
|
||||
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OpenClawKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
|
||||
@@ -11,8 +11,8 @@ enum SignificantLocationMonitor {
|
||||
locationService: any LocationServicing,
|
||||
locationMode: OpenClawLocationMode,
|
||||
gateway: GatewayNodeSession,
|
||||
beforeSend: (@MainActor @Sendable () async -> Void)? = nil
|
||||
) {
|
||||
beforeSend: (@MainActor @Sendable () async -> Void)? = nil)
|
||||
{
|
||||
guard locationMode == .always else { return }
|
||||
let status = locationService.authorizationStatus()
|
||||
guard status == .authorizedAlways else { return }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import Photos
|
||||
import OpenClawKit
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
final class PhotoLibraryService: PhotosServicing {
|
||||
@@ -139,7 +139,7 @@ final class PhotoLibraryService: PhotosServicing {
|
||||
if newWidth >= currentImage.size.width {
|
||||
break
|
||||
}
|
||||
currentImage = resize(image: currentImage, targetWidth: newWidth)
|
||||
currentImage = self.resize(image: currentImage, targetWidth: newWidth)
|
||||
}
|
||||
|
||||
throw NSError(domain: "Photos", code: 4, userInfo: [
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Observation
|
||||
import os
|
||||
import Security
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||
private struct NotificationCallError: Error, Sendable {
|
||||
/// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||
private struct NotificationCallError: Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ private struct GatewayRelayIdentityResponse: Decodable {
|
||||
let publicKey: String
|
||||
}
|
||||
|
||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
/// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||
@@ -61,7 +61,7 @@ final class NodeAppModel {
|
||||
let request: AgentDeepLink
|
||||
}
|
||||
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable {
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable, Codable {
|
||||
let id: String
|
||||
let commandText: String
|
||||
let commandPreview: String?
|
||||
@@ -124,6 +124,7 @@ final class NodeAppModel {
|
||||
var gatewayDisplayStatusText: String {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
var selectedAgentId: String?
|
||||
@@ -141,7 +142,7 @@ final class NodeAppModel {
|
||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
||||
|
||||
// Primary "node" connection: used for device capabilities and node.invoke requests.
|
||||
/// Primary "node" connection: used for device capabilities and node.invoke requests.
|
||||
private let nodeGateway = GatewayNodeSession()
|
||||
// Secondary "operator" connection: used for chat/talk/config/voicewake requests.
|
||||
private let operatorGateway = GatewayNodeSession()
|
||||
@@ -188,8 +189,14 @@ final class NodeAppModel {
|
||||
private var apnsDeviceTokenHex: String?
|
||||
private var apnsLastRegisteredTokenHex: String?
|
||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
||||
var gatewaySession: GatewayNodeSession {
|
||||
self.nodeGateway
|
||||
}
|
||||
|
||||
var operatorSession: GatewayNodeSession {
|
||||
self.operatorGateway
|
||||
}
|
||||
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
@@ -377,7 +384,6 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
||||
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
|
||||
@@ -429,7 +435,7 @@ final class NodeAppModel {
|
||||
let operatorWasConnected = await MainActor.run { self.operatorConnected }
|
||||
if operatorWasConnected {
|
||||
// Prefer keeping the connection if it's healthy; reconnect only when needed.
|
||||
let healthy = (try? await self.operatorGateway.request(
|
||||
let healthy = await (try? self.operatorGateway.request(
|
||||
method: "health",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 2)) != nil
|
||||
@@ -512,7 +518,7 @@ final class NodeAppModel {
|
||||
self.backgroundReconnectSuppressed = false
|
||||
let leaseLogMessage =
|
||||
"Background reconnect lease reason=\(reason) "
|
||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -525,7 +531,7 @@ final class NodeAppModel {
|
||||
guard changed else { return }
|
||||
let suppressLogMessage =
|
||||
"Background reconnect suppressed reason=\(reason) "
|
||||
+ "disconnect=\(disconnectIfNeeded)"
|
||||
+ "disconnect=\(disconnectIfNeeded)"
|
||||
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
||||
guard disconnectIfNeeded else { return }
|
||||
Task { [weak self] in
|
||||
@@ -646,7 +652,7 @@ final class NodeAppModel {
|
||||
self.applyMainSessionKey(decoded.mainkey)
|
||||
|
||||
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !selected.isEmpty && !decoded.agents.contains(where: { $0.id == selected }) {
|
||||
if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
|
||||
self.selectedAgentId = nil
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
@@ -769,8 +775,7 @@ final class NodeAppModel {
|
||||
let data = try await self.operatorGateway.request(
|
||||
method: "health",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 6
|
||||
)
|
||||
timeoutSeconds: 6)
|
||||
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
@@ -1057,6 +1062,7 @@ final class NodeAppModel {
|
||||
"""
|
||||
let resultJSON = try await self.screen.eval(javaScript: js)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
@@ -1294,8 +1300,8 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private static func isNotificationAuthorizationAllowed(
|
||||
_ status: NotificationAuthorizationStatus
|
||||
) -> Bool {
|
||||
_ status: NotificationAuthorizationStatus) -> Bool
|
||||
{
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
true
|
||||
@@ -1306,8 +1312,8 @@ final class NodeAppModel {
|
||||
|
||||
private func runNotificationCall<T: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async -> Result<T, NotificationCallError> {
|
||||
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
|
||||
{
|
||||
let latch = NotificationInvokeLatch<T>()
|
||||
var opTask: Task<Void, Never>?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
@@ -1481,12 +1487,11 @@ final class NodeAppModel {
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension NodeAppModel {
|
||||
// Central registry for node invoke routing to keep commands in one place.
|
||||
func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||
extension NodeAppModel {
|
||||
/// Central registry for node invoke routing to keep commands in one place.
|
||||
private func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
||||
|
||||
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
||||
@@ -1610,7 +1615,7 @@ private extension NodeAppModel {
|
||||
return NodeCapabilityRouter(handlers: handlers)
|
||||
}
|
||||
|
||||
func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawWatchCommand.status.rawValue:
|
||||
let status = await self.watchMessagingService.status()
|
||||
@@ -1627,7 +1632,7 @@ private extension NodeAppModel {
|
||||
let normalizedParams = Self.normalizeWatchNotifyParams(params)
|
||||
let title = normalizedParams.title
|
||||
let body = normalizedParams.body
|
||||
if title.isEmpty && body.isEmpty {
|
||||
if title.isEmpty, body.isEmpty {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
@@ -1670,18 +1675,18 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func locationMode() -> OpenClawLocationMode {
|
||||
private func locationMode() -> OpenClawLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||
}
|
||||
|
||||
func isLocationPreciseEnabled() -> Bool {
|
||||
private func isLocationPreciseEnabled() -> Bool {
|
||||
// iOS settings now expose a single location mode control.
|
||||
// Default location tool precision stays high unless a command explicitly requests balanced.
|
||||
true
|
||||
}
|
||||
|
||||
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
fileprivate static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
@@ -1690,7 +1695,7 @@ private extension NodeAppModel {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||
fileprivate static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(obj)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
||||
@@ -1700,17 +1705,17 @@ private extension NodeAppModel {
|
||||
return json
|
||||
}
|
||||
|
||||
func isCameraEnabled() -> Bool {
|
||||
private func isCameraEnabled() -> Bool {
|
||||
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
||||
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||
}
|
||||
|
||||
func triggerCameraFlash() {
|
||||
private func triggerCameraFlash() {
|
||||
self.cameraFlashNonce &+= 1
|
||||
}
|
||||
|
||||
func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.cameraHUDDismissTask?.cancel()
|
||||
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
@@ -1854,8 +1859,8 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private extension NodeAppModel {
|
||||
func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -1878,13 +1883,13 @@ private extension NodeAppModel {
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
func clearGatewayConnectionProblem() {
|
||||
private func clearGatewayConnectionProblem() {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
|
||||
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
@@ -1903,14 +1908,14 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||
guard let lastGatewayProblem else { return false }
|
||||
return GatewayConnectionProblemMapper.shouldPreserve(
|
||||
previousProblem: lastGatewayProblem,
|
||||
overDisconnectReason: reason)
|
||||
}
|
||||
|
||||
func shouldStartOperatorGatewayLoop(
|
||||
private func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
@@ -1923,12 +1928,12 @@ private extension NodeAppModel {
|
||||
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
|
||||
}
|
||||
|
||||
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
||||
private func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
||||
}
|
||||
|
||||
nonisolated static func shouldStartOperatorGatewayLoop(
|
||||
fileprivate nonisolated static func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
@@ -1949,7 +1954,8 @@ private extension NodeAppModel {
|
||||
return hasStoredOperatorToken
|
||||
}
|
||||
|
||||
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
||||
fileprivate nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?)
|
||||
-> GatewayConnectConfig? {
|
||||
guard let config else { return nil }
|
||||
let trimmedBootstrapToken = config.bootstrapToken?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -1964,7 +1970,7 @@ private extension NodeAppModel {
|
||||
nodeOptions: config.nodeOptions)
|
||||
}
|
||||
|
||||
func currentGatewayReconnectAuth(
|
||||
private func currentGatewayReconnectAuth(
|
||||
fallbackToken: String?,
|
||||
fallbackBootstrapToken: String?,
|
||||
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
|
||||
@@ -1975,7 +1981,7 @@ private extension NodeAppModel {
|
||||
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
|
||||
}
|
||||
|
||||
func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
||||
private func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
||||
// Always drop the in-memory bootstrap token after the first successful
|
||||
// bootstrap connect so reconnect loops cannot reuse a spent token.
|
||||
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
|
||||
@@ -1999,7 +2005,7 @@ private extension NodeAppModel {
|
||||
sessionBox: WebSocketSessionBox?) async
|
||||
{
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
||||
if self.operatorGatewayTask == nil, self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
@@ -2020,7 +2026,7 @@ private extension NodeAppModel {
|
||||
_ = await self.requestNotificationAuthorizationIfNeeded()
|
||||
}
|
||||
|
||||
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
guard self.isBackgrounded else { return }
|
||||
guard !self.backgroundReconnectSuppressed else { return }
|
||||
guard let leaseUntil = self.backgroundReconnectLeaseUntil else {
|
||||
@@ -2032,12 +2038,12 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
|
||||
private func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
|
||||
self.refreshBackgroundReconnectSuppressionIfNeeded(source: source)
|
||||
return self.isBackgrounded && self.backgroundReconnectSuppressed
|
||||
}
|
||||
|
||||
func startOperatorGatewayLoop(
|
||||
private func startOperatorGatewayLoop(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
token: String?,
|
||||
@@ -2141,7 +2147,7 @@ private extension NodeAppModel {
|
||||
|
||||
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
|
||||
// swiftlint:disable:next function_body_length
|
||||
func startNodeGatewayLoop(
|
||||
private func startNodeGatewayLoop(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
token: String?,
|
||||
@@ -2216,7 +2222,7 @@ private extension NodeAppModel {
|
||||
let usedBootstrapToken =
|
||||
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
||||
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty == false
|
||||
.isEmpty == false
|
||||
if usedBootstrapToken {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: url,
|
||||
@@ -2230,8 +2236,7 @@ private extension NodeAppModel {
|
||||
(
|
||||
sessionKey: self.mainSessionKey,
|
||||
deliveryChannel: self.shareDeliveryChannel,
|
||||
deliveryTo: self.shareDeliveryTo
|
||||
)
|
||||
deliveryTo: self.shareDeliveryTo)
|
||||
}
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
@@ -2243,8 +2248,7 @@ private extension NodeAppModel {
|
||||
deliveryTo: relayData.deliveryTo))
|
||||
GatewayDiagnostics.log(
|
||||
"gateway connected host=\(url.host ?? "?") "
|
||||
+ "scheme=\(url.scheme ?? "?")"
|
||||
)
|
||||
+ "scheme=\(url.scheme ?? "?")")
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
}
|
||||
@@ -2295,8 +2299,8 @@ private extension NodeAppModel {
|
||||
if Task.isCancelled { break }
|
||||
if !didFallbackClientId,
|
||||
let fallbackClientId = self.legacyClientIdFallback(
|
||||
currentClientId: currentOptions.clientId,
|
||||
error: error)
|
||||
currentClientId: currentOptions.clientId,
|
||||
error: error)
|
||||
{
|
||||
didFallbackClientId = true
|
||||
currentOptions.clientId = fallbackClientId
|
||||
@@ -2368,7 +2372,7 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
|
||||
private func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedOperatorScopes = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
@@ -2379,11 +2383,11 @@ private extension NodeAppModel {
|
||||
storedOperatorScopes: storedOperatorScopes)
|
||||
}
|
||||
|
||||
nonisolated static func shouldRequestOperatorApprovalScope(
|
||||
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]
|
||||
) -> Bool {
|
||||
storedOperatorScopes: [String]) -> Bool
|
||||
{
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedToken.isEmpty {
|
||||
return true
|
||||
@@ -2395,11 +2399,11 @@ private extension NodeAppModel {
|
||||
return storedOperatorScopes.contains("operator.approvals")
|
||||
}
|
||||
|
||||
func makeOperatorConnectOptions(
|
||||
private func makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool
|
||||
) -> GatewayConnectOptions {
|
||||
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||
{
|
||||
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
||||
// Preserve reconnect compatibility for older paired operator tokens that were
|
||||
// approved before iOS requested operator.approvals by default.
|
||||
@@ -2418,7 +2422,7 @@ private extension NodeAppModel {
|
||||
includeDeviceIdentity: true)
|
||||
}
|
||||
|
||||
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard normalizedClientId == "openclaw-ios" else { return nil }
|
||||
let message = error.localizedDescription.lowercased()
|
||||
@@ -2428,7 +2432,7 @@ private extension NodeAppModel {
|
||||
return "moltbot-ios"
|
||||
}
|
||||
|
||||
func isOperatorConnected() async -> Bool {
|
||||
private func isOperatorConnected() async -> Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
}
|
||||
@@ -2568,8 +2572,10 @@ extension NodeAppModel {
|
||||
PendingForegroundNodeActionsResponse.self,
|
||||
from: payload)
|
||||
guard !decoded.actions.isEmpty else { return }
|
||||
// swiftlint:disable:next line_length
|
||||
self.pendingActionLogger.info("Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
|
||||
self.pendingActionLogger
|
||||
.info(
|
||||
// swiftlint:disable:next line_length
|
||||
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
|
||||
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
@@ -2591,8 +2597,10 @@ extension NodeAppModel {
|
||||
command: action.command,
|
||||
paramsJSON: action.paramsJSON)
|
||||
let result = await self.handleInvoke(req)
|
||||
// swiftlint:disable:next line_length
|
||||
self.pendingActionLogger.info("Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
|
||||
self.pendingActionLogger
|
||||
.info(
|
||||
// swiftlint:disable:next line_length
|
||||
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
|
||||
guard result.ok else { return }
|
||||
let acked = await self.ackPendingForegroundNodeAction(
|
||||
id: action.id,
|
||||
@@ -2616,17 +2624,19 @@ extension NodeAppModel {
|
||||
timeoutSeconds: 6)
|
||||
return true
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.pendingActionLogger.error("Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
self.pendingActionLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
|
||||
switch await self.watchReplyCoordinator.ingest(event, isGatewayConnected: self.isGatewayConnected()) {
|
||||
case .dropMissingFields:
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
case .deduped(let replyId):
|
||||
case let .deduped(replyId):
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
case let .queue(replyId, actionId):
|
||||
@@ -2638,7 +2648,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func flushQueuedWatchRepliesIfConnected() async {
|
||||
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
|
||||
for event in await self.watchReplyCoordinator.drainIfConnected(self.isGatewayConnected()) {
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
}
|
||||
@@ -2660,13 +2670,13 @@ extension NodeAppModel {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
let forwardedMessage =
|
||||
"watch reply forwarded replyId=\(event.replyId) "
|
||||
+ "action=\(event.actionId)"
|
||||
+ "action=\(event.actionId)"
|
||||
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
let failedMessage =
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.watchReplyCoordinator.requeueFront(event)
|
||||
}
|
||||
@@ -2811,7 +2821,7 @@ extension NodeAppModel {
|
||||
risk: nil)
|
||||
}
|
||||
|
||||
nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||
private nonisolated static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||
reason: String) -> Bool
|
||||
{
|
||||
reason == "resolve_retry"
|
||||
@@ -2828,8 +2838,10 @@ extension NodeAppModel {
|
||||
self.watchExecApprovalLogger.debug(
|
||||
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
|
||||
}
|
||||
@@ -2850,8 +2862,10 @@ extension NodeAppModel {
|
||||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
|
||||
}
|
||||
@@ -2870,8 +2884,10 @@ extension NodeAppModel {
|
||||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
||||
} catch {
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
|
||||
}
|
||||
@@ -2900,13 +2916,17 @@ extension NodeAppModel {
|
||||
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.debug("watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.debug(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
||||
} catch {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2917,7 +2937,7 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
|
||||
}
|
||||
|
||||
nonisolated private static func watchExecApprovalIDsNeedingFetch(
|
||||
private nonisolated static func watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: [String],
|
||||
cachedApprovalIDs: [String]) -> [String]
|
||||
{
|
||||
@@ -2972,8 +2992,10 @@ extension NodeAppModel {
|
||||
forApprovalID: approvalId,
|
||||
notificationCenter: self.notificationCenter)
|
||||
case let .failed(message):
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3054,8 +3076,10 @@ extension NodeAppModel {
|
||||
reason: .notFound)
|
||||
return true
|
||||
case let .failed(message):
|
||||
// swiftlint:disable:next line_length
|
||||
self.watchExecApprovalLogger.error("watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
||||
self.watchExecApprovalLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -3084,9 +3108,9 @@ extension NodeAppModel {
|
||||
let pushKind = Self.openclawPushKind(userInfo)
|
||||
let receivedMessage =
|
||||
"Silent push received wakeId=\(wakeId) "
|
||||
+ "kind=\(pushKind) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
+ "kind=\(pushKind) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
@@ -3108,8 +3132,10 @@ extension NodeAppModel {
|
||||
{
|
||||
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
||||
if handled {
|
||||
// swiftlint:disable:next line_length
|
||||
self.execApprovalNotificationLogger.info("Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
||||
self.execApprovalNotificationLogger
|
||||
.info(
|
||||
// swiftlint:disable:next line_length
|
||||
"Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
||||
}
|
||||
return handled
|
||||
}
|
||||
@@ -3117,9 +3143,9 @@ extension NodeAppModel {
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
@@ -3128,16 +3154,16 @@ extension NodeAppModel {
|
||||
let wakeId = Self.makePushWakeAttemptID()
|
||||
let receivedMessage =
|
||||
"Background refresh wake received wakeId=\(wakeId) "
|
||||
+ "trigger=\(trigger) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
+ "trigger=\(trigger) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Background refresh wake outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
@@ -3157,7 +3183,7 @@ extension NodeAppModel {
|
||||
{
|
||||
let throttledMessage =
|
||||
"Location wake throttled wakeId=\(wakeId) "
|
||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
||||
return
|
||||
}
|
||||
@@ -3165,15 +3191,15 @@ extension NodeAppModel {
|
||||
|
||||
let beginMessage =
|
||||
"Location wake begin wakeId=\(wakeId) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let triggerMessage =
|
||||
"Location wake trigger wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
||||
|
||||
guard result.applied else { return }
|
||||
@@ -3201,7 +3227,7 @@ extension NodeAppModel {
|
||||
return
|
||||
}
|
||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
|
||||
if !usesRelayTransport, token == self.apnsLastRegisteredTokenHex {
|
||||
return
|
||||
}
|
||||
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -3330,8 +3356,10 @@ extension NodeAppModel {
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
|
||||
case let .failed(message):
|
||||
// swiftlint:disable:next line_length
|
||||
self.execApprovalNotificationLogger.error("Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||
self.execApprovalNotificationLogger
|
||||
.error(
|
||||
// swiftlint:disable:next line_length
|
||||
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3369,7 +3397,7 @@ extension NodeAppModel {
|
||||
expiresAtMs: details.expiresAtMs)
|
||||
}
|
||||
|
||||
nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: String,
|
||||
isBackgrounded: Bool) -> Bool
|
||||
{
|
||||
@@ -3387,24 +3415,22 @@ extension NodeAppModel {
|
||||
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
|
||||
{
|
||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fetchReason: String
|
||||
if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
||||
fetchReason = normalizedSourceReason
|
||||
let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
||||
normalizedSourceReason
|
||||
} else {
|
||||
fetchReason = "direct"
|
||||
"direct"
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
|
||||
let connected: Bool
|
||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: fetchReason,
|
||||
isBackgrounded: self.isBackgrounded)
|
||||
{
|
||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12_000,
|
||||
await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12000,
|
||||
reason: fetchReason)
|
||||
} else {
|
||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
||||
}
|
||||
guard connected else {
|
||||
GatewayDiagnostics.log(
|
||||
@@ -3472,8 +3498,8 @@ extension NodeAppModel {
|
||||
|
||||
func handleExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String
|
||||
) async {
|
||||
decision: String) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
@@ -3499,8 +3525,8 @@ extension NodeAppModel {
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String,
|
||||
sourceReason: String? = nil
|
||||
) async -> ExecApprovalResolutionOutcome {
|
||||
sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -3509,16 +3535,15 @@ extension NodeAppModel {
|
||||
return .failed(message: "Invalid approval request.")
|
||||
}
|
||||
|
||||
let connected: Bool
|
||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: resolutionReason,
|
||||
isBackgrounded: self.isBackgrounded)
|
||||
{
|
||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12_000,
|
||||
await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12000,
|
||||
reason: resolutionReason)
|
||||
} else {
|
||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
||||
}
|
||||
guard connected else {
|
||||
self.execApprovalNotificationLogger.error(
|
||||
@@ -3573,7 +3598,7 @@ extension NodeAppModel {
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
nonisolated private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
@@ -3584,7 +3609,7 @@ extension NodeAppModel {
|
||||
return gatewayError.message.lowercased().contains("unknown or expired approval id")
|
||||
}
|
||||
|
||||
nonisolated private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||
private nonisolated static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
@@ -3698,7 +3723,7 @@ extension NodeAppModel {
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
|
||||
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1_000)) / 1000.0 + 8.0))
|
||||
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1000)) / 1000.0 + 8.0))
|
||||
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_lease_granted "
|
||||
@@ -3722,7 +3747,7 @@ extension NodeAppModel {
|
||||
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
|
||||
+ "reason=\(reconnectReason)")
|
||||
|
||||
let initialWaitMs = min(2_500, max(750, timeoutMs / 4))
|
||||
let initialWaitMs = min(2500, max(750, timeoutMs / 4))
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_wait "
|
||||
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
||||
@@ -3772,8 +3797,8 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
||||
wakeId: String
|
||||
) async -> SilentPushWakeAttemptResult {
|
||||
wakeId: String) async -> SilentPushWakeAttemptResult
|
||||
{
|
||||
let startedAt = Date()
|
||||
let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in
|
||||
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||
@@ -3817,8 +3842,7 @@ extension NodeAppModel {
|
||||
let data = try await self.operatorGateway.request(
|
||||
method: "voicewake.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
@@ -3876,8 +3900,8 @@ extension NodeAppModel {
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
self.deepLinkLogger.info(
|
||||
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
|
||||
)
|
||||
// swiftlint:disable:next line_length
|
||||
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)")
|
||||
|
||||
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
||||
self.screen.errorText = "Deep link too large (message exceeds "
|
||||
@@ -4173,8 +4197,8 @@ extension NodeAppModel {
|
||||
func _test_makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool
|
||||
) -> GatewayConnectOptions {
|
||||
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||
{
|
||||
self.makeOperatorConnectOptions(
|
||||
clientId: clientId,
|
||||
displayName: displayName,
|
||||
@@ -4244,8 +4268,8 @@ extension NodeAppModel {
|
||||
host: String?,
|
||||
nodeId: String?,
|
||||
agentId: String?,
|
||||
expiresAtMs: Int?
|
||||
) -> ExecApprovalPrompt? {
|
||||
expiresAtMs: Int?) -> ExecApprovalPrompt?
|
||||
{
|
||||
self.makeExecApprovalPrompt(
|
||||
from: ExecApprovalGetResponse(
|
||||
id: id,
|
||||
@@ -4282,8 +4306,8 @@ extension NodeAppModel {
|
||||
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]
|
||||
) -> Bool {
|
||||
storedOperatorScopes: [String]) -> Bool
|
||||
{
|
||||
self.shouldRequestOperatorApprovalScope(
|
||||
token: token,
|
||||
password: password,
|
||||
@@ -4291,8 +4315,8 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
nonisolated static func _test_clearingBootstrapToken(
|
||||
in config: GatewayConnectConfig?
|
||||
) -> GatewayConnectConfig? {
|
||||
in config: GatewayConnectConfig?) -> GatewayConnectConfig?
|
||||
{
|
||||
self.clearingBootstrapToken(in: config)
|
||||
}
|
||||
|
||||
@@ -4313,7 +4337,6 @@ extension NodeAppModel {
|
||||
clientDisplayName: nil),
|
||||
sessionBox: nil)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -62,7 +62,7 @@ final class MotionService: MotionServicing {
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let pedometer = CMPedometer()
|
||||
let payload: OpenClawPedometerPayload = try await withCheckedThrowingContinuation { cont in
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
@@ -79,7 +79,6 @@ final class MotionService: MotionServicing {
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||
|
||||
@@ -95,7 +95,6 @@ private struct AutoDetectStep: View {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@@ -229,7 +228,7 @@ private struct ManualEntryStep: View {
|
||||
private func manualPortValue() -> Int? {
|
||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return Int(trimmed.filter { $0.isNumber })
|
||||
return Int(trimmed.filter(\.isNumber))
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
@@ -334,7 +333,6 @@ private func resetGatewayConnectionState(
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
@@ -373,8 +371,8 @@ private struct ConnectionStatusBox: View {
|
||||
|
||||
static func defaultLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController
|
||||
) -> [String] {
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
|
||||
@@ -25,7 +25,7 @@ enum OnboardingStateStore {
|
||||
|
||||
@MainActor
|
||||
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
|
||||
if defaults.bool(forKey: self.completedDefaultsKey) { return false }
|
||||
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
|
||||
// should handle reconnecting, and users can always open onboarding manually if needed.
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
||||
@@ -33,28 +33,28 @@ enum OnboardingStateStore {
|
||||
}
|
||||
|
||||
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(true, forKey: self.completedDefaultsKey)
|
||||
if let mode {
|
||||
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
|
||||
defaults.set(mode.rawValue, forKey: self.lastModeDefaultsKey)
|
||||
}
|
||||
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
||||
}
|
||||
|
||||
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
|
||||
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
!defaults.bool(forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CoreImage
|
||||
import Combine
|
||||
import CoreImage
|
||||
import OpenClawKit
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
@@ -151,8 +151,7 @@ struct OnboardingWizardView: View {
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,137 +159,136 @@ struct OnboardingWizardView: View {
|
||||
.gatewayTrustPromptAlert()
|
||||
.alert("QR Scanner Unavailable", isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }
|
||||
)) {
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: self.selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
self.selectedPhoto = nil
|
||||
Task {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "Could not load the selected image."
|
||||
return
|
||||
}
|
||||
if let message = self.detectQRCode(from: data) {
|
||||
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
if let url = URL(string: message),
|
||||
let route = DeepLinkParser.parse(url),
|
||||
case let .gateway(link) = route
|
||||
{
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: self.selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
self.selectedPhoto = nil
|
||||
Task {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "Could not load the selected image."
|
||||
return
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
if let message = self.detectQRCode(from: data) {
|
||||
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
if let url = URL(string: message),
|
||||
let route = DeepLinkParser.parse(url),
|
||||
case let .gateway(link) = route
|
||||
{
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: currentProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
})
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: currentProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = nil
|
||||
}
|
||||
.onChange(of: self.discoveryDomain) { _, _ in
|
||||
self.scheduleDiscoveryRestart()
|
||||
}
|
||||
.onChange(of: self.manualPortText) { _, newValue in
|
||||
let digits = newValue.filter(\.isNumber)
|
||||
if digits != newValue {
|
||||
self.manualPortText = digits
|
||||
return
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
guard let parsed = Int(digits), parsed > 0 else {
|
||||
self.manualPort = 0
|
||||
return
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = nil
|
||||
}
|
||||
self.manualPort = min(parsed, 65535)
|
||||
}
|
||||
.onChange(of: self.manualPort) { _, newValue in
|
||||
let normalized = newValue > 0 ? String(newValue) : ""
|
||||
if self.manualPortText != normalized {
|
||||
self.manualPortText = normalized
|
||||
.onChange(of: self.discoveryDomain) { _, _ in
|
||||
self.scheduleDiscoveryRestart()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
||||
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Connected."
|
||||
if !self.didMarkCompleted, let selectedMode {
|
||||
OnboardingStateStore.markCompleted(mode: selectedMode)
|
||||
self.didMarkCompleted = true
|
||||
.onChange(of: self.manualPortText) { _, newValue in
|
||||
let digits = newValue.filter(\.isNumber)
|
||||
if digits != newValue {
|
||||
self.manualPortText = digits
|
||||
return
|
||||
}
|
||||
guard let parsed = Int(digits), parsed > 0 else {
|
||||
self.manualPort = 0
|
||||
return
|
||||
}
|
||||
self.manualPort = min(parsed, 65535)
|
||||
}
|
||||
.onChange(of: self.manualPort) { _, newValue in
|
||||
let normalized = newValue > 0 ? String(newValue) : ""
|
||||
if self.manualPortText != normalized {
|
||||
self.manualPortText = normalized
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
||||
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Connected."
|
||||
if !self.didMarkCompleted, let selectedMode {
|
||||
OnboardingStateStore.markCompleted(mode: selectedMode)
|
||||
self.didMarkCompleted = true
|
||||
}
|
||||
self.onClose()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == .active else { return }
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
.onReceive(Self.pairingAutoResumeTicker) { _ in
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
self.onClose()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == .active else { return }
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
.onReceive(Self.pairingAutoResumeTicker) { _ in
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var introStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -369,7 +367,6 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -712,7 +709,6 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section(title) {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
@@ -868,8 +864,7 @@ struct OnboardingWizardView: View {
|
||||
let detector = CIDetector(
|
||||
ofType: CIDetectorTypeQRCode,
|
||||
context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
|
||||
)
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||
let features = detector?.features(in: ciImage) ?? []
|
||||
for feature in features {
|
||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||
@@ -891,6 +886,7 @@ struct OnboardingWizardView: View {
|
||||
self.connectMessage = nil
|
||||
self.step = target
|
||||
}
|
||||
|
||||
private var canConnectManual: Bool {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
||||
@@ -919,7 +915,7 @@ struct OnboardingWizardView: View {
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = OnboardingStateStore.lastMode()
|
||||
}
|
||||
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
|
||||
if self.selectedMode == .developerLocal, self.manualHost == "openclaw.local" {
|
||||
self.manualHost = "localhost"
|
||||
self.manualTLS = false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import SwiftUI
|
||||
import BackgroundTasks
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import BackgroundTasks
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
private struct PendingWatchPromptAction {
|
||||
@@ -88,16 +88,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
self.appModel ?? OpenClawAppModelRegistry.appModel
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
{
|
||||
GatewayDiagnostics.log("app delegate: didFinishLaunching")
|
||||
if self.appModel == nil {
|
||||
@@ -151,7 +150,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
|
||||
== ExecApprovalNotificationBridge.requestedKind,
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
{
|
||||
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
|
||||
}
|
||||
@@ -179,8 +178,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
private func registerBackgroundWakeRefreshTask() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier,
|
||||
using: nil
|
||||
) { [weak self] task in
|
||||
using: nil)
|
||||
{ [weak self] task in
|
||||
guard let refreshTask = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
@@ -196,17 +195,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
let scheduledLogMessage =
|
||||
"Scheduled background wake refresh reason=\(reason) "
|
||||
+ "delaySeconds=\(max(60, delay))"
|
||||
+ "delaySeconds=\(max(60, delay))"
|
||||
self.backgroundWakeLogger.info(
|
||||
"\(scheduledLogMessage, privacy: .public)"
|
||||
)
|
||||
"\(scheduledLogMessage, privacy: .public)")
|
||||
} catch {
|
||||
let failedLogMessage =
|
||||
"Failed scheduling background wake refresh reason=\(reason) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.backgroundWakeLogger.error(
|
||||
"\(failedLogMessage, privacy: .public)"
|
||||
)
|
||||
"\(failedLogMessage, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,14 +472,13 @@ enum WatchPromptNotificationBridge {
|
||||
|
||||
private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] {
|
||||
actions.enumerated().map { index, action in
|
||||
let identifier: String
|
||||
switch index {
|
||||
let identifier: String = switch index {
|
||||
case 0:
|
||||
identifier = self.actionPrimaryIdentifier
|
||||
self.actionPrimaryIdentifier
|
||||
case 1:
|
||||
identifier = self.actionSecondaryIdentifier
|
||||
self.actionSecondaryIdentifier
|
||||
default:
|
||||
identifier = "\(self.actionIdentifierPrefix)\(index)"
|
||||
"\(self.actionIdentifierPrefix)\(index)"
|
||||
}
|
||||
return UNNotificationAction(
|
||||
identifier: identifier,
|
||||
@@ -494,12 +490,12 @@ enum WatchPromptNotificationBridge {
|
||||
private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions {
|
||||
switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "destructive":
|
||||
return [.destructive]
|
||||
[.destructive]
|
||||
case "foreground":
|
||||
// For mirrored watch actions, keep handling in background when possible.
|
||||
return []
|
||||
[]
|
||||
default:
|
||||
return []
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,7 +506,7 @@ enum WatchPromptNotificationBridge {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .notDetermined:
|
||||
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
if !granted { return false }
|
||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
||||
@@ -540,8 +536,8 @@ enum WatchPromptNotificationBridge {
|
||||
}
|
||||
|
||||
private static func notificationAuthorizationStatus(
|
||||
center: UNUserNotificationCenter
|
||||
) async -> UNAuthorizationStatus {
|
||||
center: UNUserNotificationCenter) async -> UNAuthorizationStatus
|
||||
{
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
@@ -565,8 +561,8 @@ enum WatchPromptNotificationBridge {
|
||||
|
||||
private static func addNotificationRequest(
|
||||
_ request: UNNotificationRequest,
|
||||
center: UNUserNotificationCenter
|
||||
) async throws {
|
||||
center: UNUserNotificationCenter) async throws
|
||||
{
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
|
||||
struct ExecApprovalNotificationPrompt: Equatable {
|
||||
let approvalId: String
|
||||
}
|
||||
|
||||
@@ -38,8 +38,7 @@ enum ExecApprovalNotificationBridge {
|
||||
|
||||
static func parsePrompt(
|
||||
actionIdentifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) -> ExecApprovalNotificationPrompt?
|
||||
userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
||||
|| actionIdentifier == self.reviewActionIdentifier
|
||||
@@ -54,8 +53,7 @@ enum ExecApprovalNotificationBridge {
|
||||
@MainActor
|
||||
static func handleResolvedPushIfNeeded(
|
||||
userInfo: [AnyHashable: Any],
|
||||
notificationCenter: NotificationCentering
|
||||
) async -> Bool
|
||||
notificationCenter: NotificationCentering) async -> Bool
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||
let approvalId = self.approvalID(from: userInfo)
|
||||
@@ -70,8 +68,8 @@ enum ExecApprovalNotificationBridge {
|
||||
@MainActor
|
||||
static func removeNotifications(
|
||||
forApprovalID approvalId: String,
|
||||
notificationCenter: NotificationCentering
|
||||
) async {
|
||||
notificationCenter: NotificationCentering) async
|
||||
{
|
||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ actor PushRegistrationManager {
|
||||
}
|
||||
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!installationId.isEmpty
|
||||
!installationId.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
|
||||
}
|
||||
@@ -145,7 +145,7 @@ actor PushRegistrationManager {
|
||||
guard let expiresAtMs else { return true }
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
||||
return expiresAtMs <= nowMs + 60_000
|
||||
return expiresAtMs <= nowMs + 60000
|
||||
}
|
||||
|
||||
private static func sha256Hex(_ value: String) -> String {
|
||||
|
||||
@@ -24,7 +24,7 @@ enum PushRelayError: LocalizedError {
|
||||
case .unsupportedAppAttest:
|
||||
"App Attest unavailable on this device"
|
||||
case .missingReceipt:
|
||||
"App Store receipt missing after refresh"
|
||||
"App Store app transaction missing after refresh"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,33 +85,6 @@ private struct RelayErrorResponse: Decodable {
|
||||
var reason: String?
|
||||
}
|
||||
|
||||
private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
|
||||
private var continuation: CheckedContinuation<Void, Error>?
|
||||
private var activeRequest: SKReceiptRefreshRequest?
|
||||
|
||||
func refresh() async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
let request = SKReceiptRefreshRequest()
|
||||
self.activeRequest = request
|
||||
request.delegate = self
|
||||
request.start()
|
||||
}
|
||||
}
|
||||
|
||||
func requestDidFinish(_ request: SKRequest) {
|
||||
self.continuation?.resume(returning: ())
|
||||
self.continuation = nil
|
||||
self.activeRequest = nil
|
||||
}
|
||||
|
||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||
self.continuation?.resume(throwing: error)
|
||||
self.continuation = nil
|
||||
self.activeRequest = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct PushRelayAppAttestProof {
|
||||
var keyId: String
|
||||
var attestationObject: String?
|
||||
@@ -197,25 +170,27 @@ private final class PushRelayAppAttestService {
|
||||
|
||||
private final class PushRelayReceiptProvider {
|
||||
func loadReceiptBase64() async throws -> String {
|
||||
if let receipt = self.readReceiptData() {
|
||||
return receipt.base64EncodedString()
|
||||
do {
|
||||
let result = try await AppTransaction.shared
|
||||
return try Self.appTransactionBase64(result)
|
||||
} catch {
|
||||
let refreshed = try await AppTransaction.refresh()
|
||||
return try Self.appTransactionBase64(refreshed)
|
||||
}
|
||||
let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
|
||||
try await refreshCoordinator.refresh()
|
||||
if let refreshed = self.readReceiptData() {
|
||||
return refreshed.base64EncodedString()
|
||||
}
|
||||
throw PushRelayError.missingReceipt
|
||||
}
|
||||
|
||||
private func readReceiptData() -> Data? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
||||
guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
|
||||
return data
|
||||
private static func appTransactionBase64(
|
||||
_ result: StoreKit.VerificationResult<AppTransaction>) throws -> String
|
||||
{
|
||||
let jws = result.jwsRepresentation.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !jws.isEmpty else {
|
||||
throw PushRelayError.missingReceipt
|
||||
}
|
||||
return Data(jws.utf8).base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||
final class PushRelayClient: @unchecked Sendable {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
@@ -294,8 +269,7 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
status: status,
|
||||
message: Self.decodeErrorMessage(data: data))
|
||||
}
|
||||
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
return decoded
|
||||
return try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||
|
||||
@@ -23,11 +23,11 @@ final class RemindersService: RemindersServicing {
|
||||
let filtered = (items ?? []).filter { reminder in
|
||||
switch statusFilter {
|
||||
case .all:
|
||||
return true
|
||||
true
|
||||
case .completed:
|
||||
return reminder.isCompleted
|
||||
reminder.isCompleted
|
||||
case .incomplete:
|
||||
return !reminder.isCompleted
|
||||
!reminder.isCompleted
|
||||
}
|
||||
}
|
||||
let selected = Array(filtered.prefix(limit))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OpenClawProtocol
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@@ -262,7 +262,7 @@ struct RootCanvas: View {
|
||||
eyebrow: "Connected to \(gatewayLabel)",
|
||||
title: "Your agents are ready",
|
||||
subtitle:
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||
@@ -276,7 +276,7 @@ struct RootCanvas: View {
|
||||
eyebrow: "Reconnecting",
|
||||
title: "OpenClaw is syncing back up",
|
||||
subtitle:
|
||||
"The gateway session is coming back online. "
|
||||
"The gateway session is coming back online. "
|
||||
+ "Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
@@ -291,7 +291,7 @@ struct RootCanvas: View {
|
||||
eyebrow: "Welcome to OpenClaw",
|
||||
title: "Your phone stays quiet until it is needed",
|
||||
subtitle:
|
||||
"Pair this device to your gateway to wake it only for real work, "
|
||||
"Pair this device to your gateway to wake it only for real work, "
|
||||
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: "Main",
|
||||
@@ -300,7 +300,7 @@ struct RootCanvas: View {
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer:
|
||||
"When connected, the gateway can wake the phone with a silent push "
|
||||
"When connected, the gateway can wake the phone with a silent push "
|
||||
+ "instead of holding an always-on session.")
|
||||
}
|
||||
}
|
||||
@@ -352,7 +352,7 @@ struct RootCanvas: View {
|
||||
let words = self.homeCanvasName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap { $0.first }.map(String.init).joined()
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
@@ -468,8 +468,13 @@ private struct CanvasContent: View {
|
||||
var openSettings: () -> Void
|
||||
var retryGatewayConnection: () -> Void
|
||||
|
||||
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
||||
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
|
||||
private var brightenButtons: Bool {
|
||||
self.systemColorScheme == .light
|
||||
}
|
||||
|
||||
private var talkActive: Bool {
|
||||
self.appModel.talkMode.isEnabled || self.talkEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawKit
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -194,7 +194,7 @@ final class ScreenController {
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
@@ -209,7 +209,6 @@ final class ScreenController {
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
func attachWebView(_ webView: WKWebView) {
|
||||
|
||||
@@ -319,7 +319,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -69,7 +69,7 @@ protocol MotionServicing: Sendable {
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||
}
|
||||
|
||||
struct WatchMessagingStatus: Sendable, Equatable {
|
||||
struct WatchMessagingStatus: Equatable {
|
||||
var supported: Bool
|
||||
var paired: Bool
|
||||
var appInstalled: Bool
|
||||
@@ -77,7 +77,7 @@ struct WatchMessagingStatus: Sendable, Equatable {
|
||||
var activationState: String
|
||||
}
|
||||
|
||||
struct WatchQuickReplyEvent: Sendable, Equatable {
|
||||
struct WatchQuickReplyEvent: Equatable {
|
||||
var replyId: String
|
||||
var promptId: String
|
||||
var actionId: String
|
||||
@@ -88,7 +88,7 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
||||
struct WatchExecApprovalResolveEvent: Equatable {
|
||||
var replyId: String
|
||||
var approvalId: String
|
||||
var decision: OpenClawWatchExecApprovalDecision
|
||||
@@ -96,13 +96,13 @@ struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable {
|
||||
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchNotificationSendResult: Sendable, Equatable {
|
||||
struct WatchNotificationSendResult: Equatable {
|
||||
var deliveredImmediately: Bool
|
||||
var queuedForDelivery: Bool
|
||||
var transport: String
|
||||
|
||||
@@ -6,7 +6,7 @@ struct NotificationSnapshot: @unchecked Sendable {
|
||||
let userInfo: [AnyHashable: Any]
|
||||
}
|
||||
|
||||
enum NotificationAuthorizationStatus: Sendable {
|
||||
enum NotificationAuthorizationStatus {
|
||||
case notDetermined
|
||||
case denied
|
||||
case authorized
|
||||
|
||||
@@ -21,13 +21,12 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
@@ -228,7 +227,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
private nonisolated static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
@@ -237,7 +236,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
activationState: self.activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
private nonisolated static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
|
||||
@@ -21,7 +21,7 @@ enum WatchMessagingPayloadCodec {
|
||||
"title": params.title,
|
||||
"body": params.body,
|
||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"sentAtMs": nowMs(),
|
||||
"sentAtMs": self.nowMs(),
|
||||
]
|
||||
if let promptId = nonEmpty(params.promptId) {
|
||||
payload["promptId"] = promptId
|
||||
@@ -88,7 +88,7 @@ enum WatchMessagingPayloadCodec {
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
||||
"approval": encodeExecApprovalItem(message.approval),
|
||||
"approval": self.encodeExecApprovalItem(message.approval),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
@@ -140,7 +140,7 @@ enum WatchMessagingPayloadCodec {
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||
"approvals": message.approvals.map(encodeExecApprovalItem),
|
||||
"approvals": message.approvals.map(self.encodeExecApprovalItem),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
@@ -161,11 +161,11 @@ enum WatchMessagingPayloadCodec {
|
||||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = nonEmpty(payload["note"] as? String)
|
||||
let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = self.nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
return WatchQuickReplyEvent(
|
||||
@@ -192,7 +192,7 @@ enum WatchMessagingPayloadCodec {
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalResolveEvent(
|
||||
replyId: replyId,
|
||||
@@ -209,7 +209,7 @@ enum WatchMessagingPayloadCodec {
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
||||
return nil
|
||||
}
|
||||
let requestId = nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: requestId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -247,8 +247,7 @@ struct SettingsTab: View {
|
||||
.padding(10)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -270,15 +269,17 @@ struct SettingsTab: View {
|
||||
self.featureToggle(
|
||||
"Voice Wake",
|
||||
isOn: self.$voiceWakeEnabled,
|
||||
help: "Enables wake-word activation to start a hands-free session.") { newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
help: "Enables wake-word activation to start a hands-free session.")
|
||||
{ newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
self.featureToggle(
|
||||
"Talk Mode",
|
||||
isOn: self.$talkEnabled,
|
||||
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
help: "Enables voice conversation mode with your connected OpenClaw agent.")
|
||||
{ newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).tag(option.id)
|
||||
@@ -301,8 +302,7 @@ struct SettingsTab: View {
|
||||
"Allow Camera",
|
||||
isOn: self.$cameraEnabled,
|
||||
help: "Allows the gateway to request photos or short video clips "
|
||||
+ "while OpenClaw is foregrounded."
|
||||
)
|
||||
+ "while OpenClaw is foregrounded.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Location Access")
|
||||
@@ -313,8 +313,7 @@ struct SettingsTab: View {
|
||||
message: "Controls location permissions for OpenClaw. "
|
||||
+ "Off disables location tools, While Using enables "
|
||||
+ "foreground location, and Always enables "
|
||||
+ "background location."
|
||||
)
|
||||
+ "background location.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -347,8 +346,7 @@ struct SettingsTab: View {
|
||||
? (
|
||||
self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
||||
? "Configured"
|
||||
: "Not configured"
|
||||
)
|
||||
: "Not configured")
|
||||
: "Not loaded")
|
||||
LabeledContent(
|
||||
"Default Model",
|
||||
@@ -365,7 +363,7 @@ struct SettingsTab: View {
|
||||
isOn: self.$talkButtonEnabled,
|
||||
help: "Shows the Talk control in the main toolbar.")
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.lineLimit(2 ... 6)
|
||||
.lineLimit(2...6)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
HStack(spacing: 8) {
|
||||
Text("Default Share Instruction")
|
||||
@@ -376,8 +374,7 @@ struct SettingsTab: View {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Default Share Instruction",
|
||||
message: "Appends this instruction when sharing content "
|
||||
+ "into OpenClaw from iOS."
|
||||
)
|
||||
+ "into OpenClaw from iOS.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -441,8 +438,7 @@ struct SettingsTab: View {
|
||||
} message: {
|
||||
Text(
|
||||
"This will disconnect, clear saved gateway connection + credentials, "
|
||||
+ "and reopen the onboarding wizard."
|
||||
)
|
||||
+ "and reopen the onboarding wizard.")
|
||||
}
|
||||
.alert(item: self.$activeFeatureHelp) { help in
|
||||
Alert(
|
||||
@@ -635,8 +631,8 @@ struct SettingsTab: View {
|
||||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
help: String,
|
||||
onChange: ((Bool) -> Void)? = nil
|
||||
) -> some View {
|
||||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Toggle(title, isOn: isOn)
|
||||
Button {
|
||||
@@ -754,8 +750,7 @@ struct SettingsTab: View {
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
GatewayDiagnostics.log(
|
||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) "
|
||||
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)"
|
||||
)
|
||||
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
||||
guard let port = resolvedPort else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
@@ -858,7 +853,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
@@ -868,7 +863,7 @@ struct SettingsTab: View {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
|
||||
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() {
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
|
||||
self.setupStatusText = msg
|
||||
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
||||
@@ -1095,4 +1090,5 @@ struct SettingsTab: View {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeWordsSettingsView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
@@ -6,8 +6,8 @@ enum StatusActivityBuilder {
|
||||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
cameraHUDText: String?,
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
) -> StatusPill.Activity? {
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?) -> StatusPill.Activity?
|
||||
{
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
@@ -19,9 +19,9 @@ enum StatusActivityBuilder {
|
||||
if let gatewayProblem = appModel.lastGatewayProblem {
|
||||
switch gatewayProblem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return StatusPill.Activity(
|
||||
title: "Approval pending",
|
||||
systemImage: "person.crop.circle.badge.clock",
|
||||
@@ -93,4 +93,3 @@ enum StatusActivityBuilder {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ private struct StatusGlassCardModifier: ViewModifier {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
@@ -32,8 +31,6 @@ extension View {
|
||||
StatusGlassCardModifier(
|
||||
brighten: brighten,
|
||||
verticalPadding: verticalPadding,
|
||||
horizontalPadding: horizontalPadding
|
||||
)
|
||||
)
|
||||
horizontalPadding: horizontalPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,7 @@ struct StatusPill: View {
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
: 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
|
||||
@@ -20,8 +20,8 @@ enum TalkModeGatewayConfigParser {
|
||||
config: [String: Any],
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int
|
||||
) -> TalkModeGatewayConfigState {
|
||||
defaultSilenceTimeoutMs: Int) -> TalkModeGatewayConfigState
|
||||
{
|
||||
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
@@ -99,7 +99,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private var gateway: GatewayNodeSession?
|
||||
private var gatewayConnected = false
|
||||
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000
|
||||
private var silenceWindow: TimeInterval = .init(TalkModeManager.defaultSilenceTimeoutMs) / 1000
|
||||
private var lastAudioActivity: Date?
|
||||
private var noiseFloorSamples: [Double] = []
|
||||
private var noiseFloor: Double?
|
||||
@@ -488,16 +488,16 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
if self.allowSimulatorCapture {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
if !self.allowSimulatorCapture {
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
}
|
||||
if self.allowSimulatorCapture {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
if !self.allowSimulatorCapture {
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
}
|
||||
#endif
|
||||
|
||||
self.stopRecognition()
|
||||
@@ -550,8 +550,7 @@ final class TalkModeManager: NSObject {
|
||||
let threshold = min(0.35, max(0.12, avg + 0.10))
|
||||
GatewayDiagnostics.log(
|
||||
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) "
|
||||
+ "threshold=\(String(format: "%.3f", threshold))"
|
||||
)
|
||||
+ "threshold=\(String(format: "%.3f", threshold))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,8 +575,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"talk speech: recognition started mode=\(String(describing: self.captureMode)) "
|
||||
+ "engineRunning=\(self.audioEngine.isRunning)"
|
||||
)
|
||||
+ "engineRunning=\(self.audioEngine.isRunning)")
|
||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
@@ -722,7 +720,7 @@ final class TalkModeManager: NSObject {
|
||||
guard self.isListening, !self.isSpeechOutputActive else { return }
|
||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !transcript.isEmpty else { return }
|
||||
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max()
|
||||
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap(\.self).max()
|
||||
guard let lastActivity else { return }
|
||||
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
||||
await self.processTranscript(transcript, restartAfter: true)
|
||||
@@ -733,13 +731,13 @@ final class TalkModeManager: NSObject {
|
||||
guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return }
|
||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !transcript.isEmpty else { return }
|
||||
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max()
|
||||
let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap(\.self).max()
|
||||
guard let lastActivity else { return }
|
||||
if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return }
|
||||
_ = await self.endPushToTalk()
|
||||
}
|
||||
|
||||
// Guardrail for PTT once so we don't stay open indefinitely.
|
||||
/// Guardrail for PTT once so we don't stay open indefinitely.
|
||||
private func schedulePTTTimeout(seconds: TimeInterval) {
|
||||
guard seconds > 0 else { return }
|
||||
let nanos = UInt64(seconds * 1_000_000_000)
|
||||
@@ -1103,7 +1101,10 @@ final class TalkModeManager: NSObject {
|
||||
result = await self.mp3Player.play(stream: rawStream)
|
||||
}
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||
self.logger
|
||||
.info(
|
||||
// swiftlint:disable:next line_length
|
||||
"elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
@@ -1186,9 +1187,9 @@ final class TalkModeManager: NSObject {
|
||||
return !route.outputs.contains { output in
|
||||
switch output.portType {
|
||||
case .builtInSpeaker, .builtInReceiver:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1392,8 +1393,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func consumeIncrementalPrefetchedAudioIfAvailable(
|
||||
for segment: String,
|
||||
context: IncrementalSpeechContext?
|
||||
) async -> IncrementalPrefetchedAudio?
|
||||
context: IncrementalSpeechContext?) async -> IncrementalPrefetchedAudio?
|
||||
{
|
||||
guard let context else {
|
||||
self.cancelIncrementalPrefetch()
|
||||
@@ -1467,8 +1467,8 @@ final class TalkModeManager: NSObject {
|
||||
guard evt.event == "agent", let payload = evt.payload else { continue }
|
||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: OpenClawAgentEventPayload.self
|
||||
) else {
|
||||
as: OpenClawAgentEventPayload.self)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
|
||||
@@ -1550,8 +1550,7 @@ final class TalkModeManager: NSObject {
|
||||
private func makeIncrementalTTSRequest(
|
||||
text: String,
|
||||
context: IncrementalSpeechContext,
|
||||
outputFormat: String?
|
||||
) -> ElevenLabsTTSRequest
|
||||
outputFormat: String?) -> ElevenLabsTTSRequest
|
||||
{
|
||||
ElevenLabsTTSRequest(
|
||||
text: text,
|
||||
@@ -1579,8 +1578,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private static func monitorStreamFailures(
|
||||
_ stream: AsyncThrowingStream<Data, Error>,
|
||||
failureBox: StreamFailureBox
|
||||
) -> AsyncThrowingStream<Data, Error>
|
||||
failureBox: StreamFailureBox) -> AsyncThrowingStream<Data, Error>
|
||||
{
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
@@ -1622,8 +1620,7 @@ final class TalkModeManager: NSObject {
|
||||
private func speakIncrementalSegment(
|
||||
_ text: String,
|
||||
context preferredContext: IncrementalSpeechContext? = nil,
|
||||
prefetchedAudio: IncrementalPrefetchedAudio? = nil
|
||||
) async
|
||||
prefetchedAudio: IncrementalPrefetchedAudio? = nil) async
|
||||
{
|
||||
let context: IncrementalSpeechContext
|
||||
if let preferredContext {
|
||||
@@ -1651,11 +1648,10 @@ final class TalkModeManager: NSObject {
|
||||
text: text,
|
||||
context: context,
|
||||
outputFormat: context.outputFormat)
|
||||
let rawStream: AsyncThrowingStream<Data, Error>
|
||||
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
rawStream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
let rawStream: AsyncThrowingStream<Data, Error> = if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
} else {
|
||||
rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
}
|
||||
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
|
||||
@@ -1689,7 +1685,6 @@ final class TalkModeManager: NSObject {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct IncrementalSpeechBuffer {
|
||||
@@ -1818,7 +1813,7 @@ private struct IncrementalSpeechBuffer {
|
||||
}
|
||||
|
||||
private static func isSoftBoundary(_ ch: Character, bufferedChars: Int) -> Bool {
|
||||
bufferedChars >= Self.softBoundaryMinChars && ch.isWhitespace
|
||||
bufferedChars >= self.softBoundaryMinChars && ch.isWhitespace
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1987,8 +1982,7 @@ extension TalkModeManager {
|
||||
let res = try await gateway.request(
|
||||
method: "talk.config",
|
||||
paramsJSON: "{\"includeSecrets\":true}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
@@ -2060,7 +2054,7 @@ extension TalkModeManager {
|
||||
.allowBluetoothHFP,
|
||||
.defaultToSpeaker,
|
||||
])
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
try? session.setPreferredSampleRate(48000)
|
||||
try? session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setActive(true, options: [])
|
||||
}
|
||||
@@ -2101,19 +2095,19 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
||||
var shouldLog = false
|
||||
var shouldEmitLevel = false
|
||||
var count = 0
|
||||
lock.lock()
|
||||
bufferCount += 1
|
||||
count = bufferCount
|
||||
self.lock.lock()
|
||||
self.bufferCount += 1
|
||||
count = self.bufferCount
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastLoggedAt) >= 1.0 {
|
||||
lastLoggedAt = now
|
||||
if now.timeIntervalSince(self.lastLoggedAt) >= 1.0 {
|
||||
self.lastLoggedAt = now
|
||||
shouldLog = true
|
||||
}
|
||||
if now.timeIntervalSince(lastLevelEmitAt) >= 0.12 {
|
||||
lastLevelEmitAt = now
|
||||
if now.timeIntervalSince(self.lastLevelEmitAt) >= 0.12 {
|
||||
self.lastLevelEmitAt = now
|
||||
shouldEmitLevel = true
|
||||
}
|
||||
lock.unlock()
|
||||
self.lock.unlock()
|
||||
|
||||
let rate = buffer.format.sampleRate
|
||||
let ch = buffer.format.channelCount
|
||||
@@ -2133,12 +2127,12 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let resolvedRms = rms ?? 0
|
||||
lock.lock()
|
||||
lastRms = resolvedRms
|
||||
if resolvedRms > maxRmsWindow { maxRmsWindow = resolvedRms }
|
||||
let maxRms = maxRmsWindow
|
||||
if shouldLog { maxRmsWindow = 0 }
|
||||
lock.unlock()
|
||||
self.lock.lock()
|
||||
self.lastRms = resolvedRms
|
||||
if resolvedRms > self.maxRmsWindow { self.maxRmsWindow = resolvedRms }
|
||||
let maxRms = self.maxRmsWindow
|
||||
if shouldLog { self.maxRmsWindow = 0 }
|
||||
self.lock.unlock()
|
||||
|
||||
if shouldEmitLevel, let onLevel {
|
||||
onLevel(resolvedRms)
|
||||
@@ -2146,9 +2140,8 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
||||
|
||||
guard shouldLog else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))"
|
||||
)
|
||||
"\(self.label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ enum TalkSpeechLocale {
|
||||
}
|
||||
|
||||
static func supportedOptions(
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
||||
) -> [Option] {
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> [Option]
|
||||
{
|
||||
var seen = Set<String>()
|
||||
let dynamic: [Option] = supportedLocales
|
||||
.compactMap { locale in
|
||||
@@ -33,8 +33,8 @@ enum TalkSpeechLocale {
|
||||
gatewaySelection: String?,
|
||||
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
|
||||
fallbackLocaleID: String = Self.fallbackLocaleID,
|
||||
supportedLocaleIDs: Set<String>
|
||||
) -> String? {
|
||||
supportedLocaleIDs: Set<String>) -> String?
|
||||
{
|
||||
TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
|
||||
preferredLocaleIDs: [
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
|
||||
@@ -48,8 +48,10 @@ enum TalkSpeechLocale {
|
||||
static func makeRecognizer(
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
||||
) -> (recognizer: SFSpeechRecognizer?, localeID: String?) {
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()) -> (
|
||||
recognizer: SFSpeechRecognizer?,
|
||||
localeID: String?)
|
||||
{
|
||||
let supportedIDs = Set(supportedLocales.map(\.identifier))
|
||||
guard let localeID = self.resolvedLocaleID(
|
||||
localSelection: localSelection,
|
||||
|
||||
Reference in New Issue
Block a user