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

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

View File

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

View File

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

View File

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

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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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