fix: harden ios app build hygiene

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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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"
}
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

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

View File

@@ -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))
}
}

View File

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

View File

@@ -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 }

View File

@@ -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: [

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)",

View File

@@ -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? {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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) {

View File

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

View File

@@ -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

View File

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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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
}
}

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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))")
}
}

View File

@@ -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,