fix: harden ios app build hygiene

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

View File

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