mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
fix(ios): defer QR pairing after scanner dismissal (#99572)
* fix(ios): defer QR pairing after scanner dismissal * fix(ios): process QR pairing after scanner dismissal * fix(ios): harden QR scanner handoff * fix(ios): give QR scanner dismissal more time * fix(ios): keep onboarding open for QR trust prompt * fix(ios): keep QR trust prompt owned by onboarding * fix(ios): recover operator pairing after QR bootstrap * fix(ios): cancel stale QR scanner handoffs Co-authored-by: PollyBot13 <pollybot13@gmail.com> * fix(ios): defer QR setup until onboarding closes * fix(ios): keep QR setup links with visible settings * fix(ios): consume setup links during onboarding * fix(ios): handle setup links during onboarding launch * fix(ios): route setup links through active onboarding * fix(ios): harden QR gateway handoff * fix(ios): cancel superseded gateway attempts * fix(ios): serialize scanner result delivery * fix(ios): prevent stale gateway reconnects * fix(ios): serialize gateway target handoff * fix(ios): disable stale gateway relaunch route * fix(ios): await staged bootstrap reset * test(ios): bound gateway reset handoff * fix(ios): preserve explicit gateway handoff * fix(ios): harden gateway lifecycle ownership * chore(ios): sync native i18n inventory * test(ios): align gateway ownership assertions * refactor(ios): remove superseded gateway helpers * fix(ios): keep gateway auth route scoped * fix(ios): restore gateway target review state * fix(protocol): refresh Swift plugin approval model * test(ios): isolate state directory overrides * fix(ios): preserve watch alerts across gateway switches * fix(ios): bind deferred work to gateway ownership * docs(changelog): credit iOS gateway handoff fix * chore(i18n): sync native app inventory * test(ios): remove unused Watch approval hooks --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **iOS QR gateway handoff:** stop VisionKit before delivering scanned setup codes, and keep deferred auth, approval, Watch, and foreground-node work bound to its originating gateway across reconnects. (#99572) Thanks @PollyBot13.
|
||||
- **iOS Voice Wake cleanup:** avoid initializing the microphone audio pipeline while disabling inactive Voice Wake, preventing simulator launch aborts and unnecessary audio setup.
|
||||
- **Cron duration validation:** reject positive durations that truncate below one millisecond instead of silently scheduling a zero-duration interval. (#100311) Thanks @qingminglong.
|
||||
- **Skill workshop proposals:** preserve the terminal newline in generated proposal Markdown while still rejecting blank raw content. (#100293) Thanks @anyech.
|
||||
|
||||
+204
-156
File diff suppressed because it is too large
Load Diff
@@ -164,7 +164,7 @@ final class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws {
|
||||
guard let config = ShareGatewayRelaySettings.loadConfig() else {
|
||||
guard let config = ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth() else {
|
||||
throw NSError(
|
||||
domain: "OpenClawShare",
|
||||
code: 10,
|
||||
@@ -200,7 +200,9 @@ final class ShareViewController: UIViewController {
|
||||
clientMode: "node",
|
||||
clientDisplayName: "OpenClaw Share",
|
||||
deviceIdentityProfile: .shareExtension,
|
||||
includeDeviceIdentity: true)
|
||||
includeDeviceIdentity: true,
|
||||
allowStoredDeviceAuth: config.gatewayStableID != nil,
|
||||
deviceAuthGatewayID: config.gatewayStableID)
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
@@ -214,8 +214,19 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.requestHistory(sessionKey: sessionKey, ifCurrentRoute: nil)
|
||||
}
|
||||
|
||||
func requestHistory(
|
||||
sessionKey: String,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload
|
||||
{
|
||||
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
let res = try await self.gateway.request(
|
||||
method: "chat.history",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 15,
|
||||
ifCurrentRoute: expectedRoute)
|
||||
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
|
||||
}
|
||||
|
||||
@@ -236,6 +247,23 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments,
|
||||
ifCurrentRoute: nil)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload],
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let startLogMessage =
|
||||
"chat.send start sessionKey=\(sessionKey) "
|
||||
@@ -250,7 +278,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
do {
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let res = try await self.gateway.request(
|
||||
method: "chat.send",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 35,
|
||||
ifCurrentRoute: expectedRoute)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
|
||||
@@ -294,6 +326,17 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool {
|
||||
await self.waitForRunCompletion(
|
||||
runId: rawRunId,
|
||||
timeoutMs: timeoutMs,
|
||||
ifCurrentRoute: nil)
|
||||
}
|
||||
|
||||
func waitForRunCompletion(
|
||||
runId rawRunId: String,
|
||||
timeoutMs: Int,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async -> Bool
|
||||
{
|
||||
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !runId.isEmpty else { return false }
|
||||
|
||||
@@ -304,7 +347,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
let res = try await self.gateway.request(
|
||||
method: "agent.wait",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: requestTimeoutSeconds)
|
||||
timeoutSeconds: requestTimeoutSeconds,
|
||||
ifCurrentRoute: expectedRoute)
|
||||
let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId)
|
||||
GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)")
|
||||
if !completion.completed {
|
||||
|
||||
@@ -44,11 +44,15 @@ struct SettingsProTab: View {
|
||||
@State var selectedAgentPickerId = ""
|
||||
@State var gatewayToken = ""
|
||||
@State var gatewayPassword = ""
|
||||
@State var gatewayCredentialFieldStableID: String?
|
||||
@State var manualGatewayPortText = ""
|
||||
@State var setupStatusText: String?
|
||||
@State var setupAttemptID: UUID?
|
||||
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
|
||||
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
@State var scannerResultHandoff = QRScannerResultHandoff()
|
||||
@State var scannerScanID: UInt64 = 0
|
||||
@State var pendingTargetSuppression = GatewayPendingTargetSuppression()
|
||||
@State var defaultShareInstruction = ""
|
||||
@State var showQRScanner = false
|
||||
@State var scannerError: String?
|
||||
@@ -71,6 +75,7 @@ struct SettingsProTab: View {
|
||||
@State private var navigationPath: [SettingsRoute] = []
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
let acceptsGatewaySetupRequests: Bool
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let ownsNavigationStack: Bool
|
||||
let navigateToRoute: ((SettingsRoute) -> Void)?
|
||||
@@ -81,6 +86,7 @@ struct SettingsProTab: View {
|
||||
init(
|
||||
initialRoute: SettingsRoute? = nil,
|
||||
directRoute: SettingsRoute? = nil,
|
||||
acceptsGatewaySetupRequests: Bool = false,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
ownsNavigationStack: Bool = true,
|
||||
navigateToRoute: ((SettingsRoute) -> Void)? = nil,
|
||||
@@ -90,6 +96,7 @@ struct SettingsProTab: View {
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.acceptsGatewaySetupRequests = acceptsGatewaySetupRequests
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.ownsNavigationStack = ownsNavigationStack
|
||||
self.navigateToRoute = navigateToRoute
|
||||
@@ -155,6 +162,10 @@ struct SettingsProTab: View {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
self.notifyRouteChange()
|
||||
}
|
||||
.onDisappear {
|
||||
self.scannerResultHandoff.cancel()
|
||||
self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController)
|
||||
}
|
||||
.onChange(of: self.gatewaySetupRequest?.id) { _, _ in
|
||||
self.applyGatewaySetupRequestIfNeeded()
|
||||
}
|
||||
@@ -176,20 +187,18 @@ struct SettingsProTab: View {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.persistGatewayToken(newValue)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.persistGatewayPassword(newValue)
|
||||
}
|
||||
.onChange(of: self.setupCode) { _, newValue in
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.clearStagedGatewaySetupLink()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.acceptsGatewaySetupRequests) { _, acceptsRequests in
|
||||
guard acceptsRequests else { return }
|
||||
self.applyGatewaySetupRequestIfNeeded()
|
||||
}
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
// Root-owned resets leave Settings mounted behind onboarding.
|
||||
// Reload cleared credentials before the view can persist stale state.
|
||||
@@ -202,46 +211,52 @@ struct SettingsProTab: View {
|
||||
}
|
||||
|
||||
private func settingsModalPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
let scanID = self.scannerScanID
|
||||
return content
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.font(OpenClawType.body)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
self.showQRScanner = false
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.sheet(
|
||||
isPresented: self.$showQRScanner,
|
||||
onDismiss: {
|
||||
self.processQueuedScannerResult()
|
||||
},
|
||||
content: {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onResult: { result in
|
||||
self.queueScannedResult(result, scanID: scanID)
|
||||
},
|
||||
onError: { error in
|
||||
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.font(OpenClawType.body)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
self.scannerResultHandoff.cancel()
|
||||
self.showQRScanner = false
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.sheet(isPresented: self.$showNotificationRelayDisclosure) {
|
||||
HostedPushRelayDisclosureSheet(
|
||||
message: self.notificationRelayDisclosureMessage,
|
||||
@@ -279,6 +294,7 @@ struct SettingsProTab: View {
|
||||
}
|
||||
|
||||
private func applyGatewaySetupRequestIfNeeded() {
|
||||
guard self.acceptsGatewaySetupRequests else { return }
|
||||
guard let gatewaySetupRequest else { return }
|
||||
self.applyGatewaySetupLink(gatewaySetupRequest.link)
|
||||
self.onGatewaySetupRequestHandled?(gatewaySetupRequest.id)
|
||||
|
||||
@@ -156,8 +156,23 @@ extension SettingsProTab {
|
||||
self.refreshLocationPermissionSummary()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
guard let stableID = self.currentManualGatewayStableID else {
|
||||
self.gatewayCredentialFieldStableID = nil
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
self.pendingManualAuthOverride = nil
|
||||
return
|
||||
}
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: trimmedInstanceId,
|
||||
gatewayStableID: stableID)
|
||||
let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth
|
||||
self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil
|
||||
self.gatewayToken = credentials.token ?? ""
|
||||
self.gatewayPassword = credentials.password ?? ""
|
||||
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: trimmedInstanceId,
|
||||
targetStableID: stableID)
|
||||
}
|
||||
|
||||
func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) {
|
||||
@@ -196,12 +211,20 @@ extension SettingsProTab {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.pendingManualAuthOverride = nil
|
||||
self.syncSettingsState()
|
||||
self.pendingTargetSuppression.releaseAutoConnect(controller: self.gatewayController)
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
let supersededSetupLease = self.takeStagedGatewaySetupSuppression()
|
||||
defer {
|
||||
if let supersededSetupLease {
|
||||
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
|
||||
}
|
||||
}
|
||||
self.connectingGatewayID = gateway.id
|
||||
defer { self.connectingGatewayID = nil }
|
||||
self.manualGatewayEnabled = false
|
||||
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
|
||||
@@ -211,7 +234,10 @@ extension SettingsProTab {
|
||||
|
||||
func applySetupCodeAndConnect() async {
|
||||
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
|
||||
defer { self.finishGatewaySetupAttempt(attemptID) }
|
||||
defer {
|
||||
self.finishGatewaySetupAttempt(attemptID)
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
|
||||
}
|
||||
self.setupStatusText = nil
|
||||
guard await self.applySetupCode(attemptID: attemptID) else { return }
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -225,6 +251,12 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) {
|
||||
// Only the root-selected Gateway destination may destructively claim a
|
||||
// setup link; other Settings views can remain mounted behind onboarding.
|
||||
self.showQRScanner = false
|
||||
self.scannerResultHandoff.cancel()
|
||||
let lease = self.gatewayController.cancelPendingConnectionAttempts()
|
||||
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
self.stagedGatewaySetupLink = link
|
||||
@@ -246,6 +278,7 @@ extension SettingsProTab {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -267,34 +300,58 @@ extension SettingsProTab {
|
||||
self.manualGatewayTLS = link.tls
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId)
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(
|
||||
appModel: self.appModel,
|
||||
instanceId: instanceId,
|
||||
gatewayStableID: setupAuth.targetStableID)
|
||||
}
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId)
|
||||
}
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.gatewayToken = setupAuth.token
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.gatewayPassword = setupAuth.password
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: setupAuth.token,
|
||||
bootstrapToken: setupAuth.bootstrapToken,
|
||||
password: setupAuth.password,
|
||||
gatewayStableID: setupAuth.targetStableID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
self.gatewayToken = setupAuth.token
|
||||
self.gatewayPassword = setupAuth.password
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
}
|
||||
|
||||
func openGatewayQRScanner() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.invalidateGatewaySetupAttempt()
|
||||
let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
|
||||
self.scannerScanID = self.scannerResultHandoff.beginScan()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = "Opening QR scanner..."
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
|
||||
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
|
||||
self.setupStatusText = "QR loaded. Closing scanner..."
|
||||
self.showQRScanner = false
|
||||
}
|
||||
|
||||
func processQueuedScannerResult() {
|
||||
let delivery = self.scannerResultHandoff.processAfterDismissal { result in
|
||||
switch result {
|
||||
case let .gatewayLink(link):
|
||||
self.handleScannedGatewayLink(link)
|
||||
case let .setupCode(code):
|
||||
self.handleScannedSetupCode(code)
|
||||
}
|
||||
}
|
||||
if delivery == nil {
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.showQRScanner = false
|
||||
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
|
||||
@@ -309,10 +366,25 @@ extension SettingsProTab {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
|
||||
}
|
||||
|
||||
func clearStagedGatewaySetupLink() {
|
||||
guard self.stagedGatewaySetupLink != nil else { return }
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
|
||||
}
|
||||
|
||||
private func takeStagedGatewaySetupSuppression() -> GatewayConnectionController.AutoConnectSuppressionLease? {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
return self.pendingTargetSuppression.take(ifOwnedBy: .setupLink)
|
||||
}
|
||||
|
||||
func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
|
||||
defer { self.finishGatewaySetupAttempt(attemptID) }
|
||||
defer {
|
||||
self.finishGatewaySetupAttempt(attemptID)
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
|
||||
}
|
||||
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
|
||||
guard self.setupAttemptID == attemptID else { return }
|
||||
self.applyGatewayLink(link)
|
||||
@@ -332,6 +404,12 @@ extension SettingsProTab {
|
||||
} else {
|
||||
self.invalidateGatewaySetupAttempt()
|
||||
}
|
||||
let supersededSetupLease = self.takeStagedGatewaySetupSuppression()
|
||||
defer {
|
||||
if let supersededSetupLease {
|
||||
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
|
||||
}
|
||||
}
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.setupStatusText = "Failed: host required"
|
||||
@@ -341,19 +419,49 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard let port = self.resolvedManualPort(host: host) else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
||||
host: host,
|
||||
port: port)
|
||||
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
|
||||
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
|
||||
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
|
||||
{
|
||||
self.pendingManualAuthOverride = nil
|
||||
}
|
||||
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
|
||||
let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID
|
||||
? self.pendingManualAuthOverride
|
||||
: nil
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.gatewayToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.gatewayPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
token: fieldsMatchTarget ? self.gatewayToken : nil,
|
||||
pendingOverride: pendingOverride,
|
||||
password: fieldsMatchTarget ? self.gatewayPassword : nil,
|
||||
targetStableID: stableID)
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil {
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: authOverride?.token,
|
||||
bootstrapToken: authOverride?.bootstrapToken,
|
||||
password: authOverride?.password,
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
port: port,
|
||||
useTLS: self.manualGatewayTLS,
|
||||
authOverride: authOverride)
|
||||
// The controller now owns this attempt's immutable override. A later retry must reload
|
||||
// durable state so a spent bootstrap token cannot be resurrected from the live view.
|
||||
self.pendingManualAuthOverride = nil
|
||||
}
|
||||
|
||||
func preflightGateway(host: String) async -> Bool {
|
||||
@@ -376,6 +484,8 @@ extension SettingsProTab {
|
||||
defer { self.suppressCredentialPersist = false }
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
self.gatewayCredentialFieldStableID = nil
|
||||
self.pendingManualAuthOverride = nil
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.onboardingComplete = false
|
||||
self.hasConnectedOnce = false
|
||||
@@ -501,22 +611,88 @@ extension SettingsProTab {
|
||||
self.notificationStatus = SettingsNotificationStatus(status)
|
||||
}
|
||||
|
||||
var currentManualGatewayStableID: String? {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
|
||||
return GatewayConnectionController.ManualAuthOverride.manualStableID(
|
||||
host: host,
|
||||
port: port)
|
||||
}
|
||||
|
||||
var gatewayCredentialTargetStableID: String? {
|
||||
// Auth fields follow the selected route. Otherwise a discovered-gateway retry can save
|
||||
// credentials under the unrelated manual endpoint and immediately reload an empty bundle.
|
||||
self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID
|
||||
}
|
||||
|
||||
var manualGatewayEnabledBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.manualGatewayEnabled },
|
||||
set: { enabled in
|
||||
self.manualGatewayEnabled = enabled
|
||||
guard enabled, let stableID = self.currentManualGatewayStableID else { return }
|
||||
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
|
||||
})
|
||||
}
|
||||
|
||||
var gatewayTokenBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.gatewayToken },
|
||||
set: { self.persistGatewayToken($0) })
|
||||
}
|
||||
|
||||
var gatewayPasswordBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.gatewayPassword },
|
||||
set: { self.persistGatewayPassword($0) })
|
||||
}
|
||||
|
||||
var manualHostBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayHost },
|
||||
set: { value in
|
||||
let previousStableID = self.currentManualGatewayStableID
|
||||
self.manualGatewayHost = value
|
||||
if previousStableID != self.currentManualGatewayStableID {
|
||||
self.clearManualCredentialFields()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func persistGatewayToken(_ value: String) {
|
||||
self.gatewayToken = value
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
|
||||
self.gatewayCredentialFieldStableID = stableID
|
||||
let saved = GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: value,
|
||||
password: self.gatewayPassword,
|
||||
gatewayStableID: stableID,
|
||||
instanceId: instanceId)
|
||||
self.pendingManualAuthOverride = saved
|
||||
? GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: instanceId,
|
||||
targetStableID: stableID)
|
||||
: nil
|
||||
}
|
||||
|
||||
func persistGatewayPassword(_ value: String) {
|
||||
self.gatewayPassword = value
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
|
||||
self.gatewayCredentialFieldStableID = stableID
|
||||
let saved = GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: self.gatewayToken,
|
||||
password: value,
|
||||
gatewayStableID: stableID,
|
||||
instanceId: instanceId)
|
||||
self.pendingManualAuthOverride = saved
|
||||
? GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: instanceId,
|
||||
targetStableID: stableID)
|
||||
: nil
|
||||
}
|
||||
|
||||
func openNotificationSettings() {
|
||||
@@ -543,27 +719,54 @@ extension SettingsProTab {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let previousStableID = self.currentManualGatewayStableID
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
self.manualGatewayPortText = filtered
|
||||
self.manualGatewayPort = Int(filtered) ?? 0
|
||||
if previousStableID != self.currentManualGatewayStableID {
|
||||
self.clearManualCredentialFields()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func clearManualCredentialFields() {
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
self.gatewayCredentialFieldStableID = nil
|
||||
self.pendingManualAuthOverride = nil
|
||||
}
|
||||
|
||||
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if self.gatewayCredentialFieldStableID != stableID {
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceId,
|
||||
gatewayStableID: stableID)
|
||||
self.gatewayCredentialFieldStableID = stableID
|
||||
self.gatewayToken = credentials.token ?? ""
|
||||
self.gatewayPassword = credentials.password ?? ""
|
||||
}
|
||||
guard allowManualOverride else {
|
||||
self.pendingManualAuthOverride = nil
|
||||
return
|
||||
}
|
||||
// Each attempt consumes the in-memory override. Reload durable bootstrap auth even
|
||||
// when the endpoint fields did not change so retry never erases a one-time token.
|
||||
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: instanceId,
|
||||
targetStableID: stableID)
|
||||
}
|
||||
|
||||
var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
func resolvedManualPort(host: String) -> Int? {
|
||||
if self.manualGatewayPort > 0 {
|
||||
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
||||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
guard self.manualGatewayPortText.isEmpty || self.manualGatewayPort > 0 else { return nil }
|
||||
return GatewayConnectionController.resolvedManualPort(
|
||||
host: host,
|
||||
port: self.manualGatewayPort)
|
||||
}
|
||||
|
||||
var setupStatusLine: String? {
|
||||
|
||||
@@ -784,11 +784,11 @@ extension SettingsProTab {
|
||||
|
||||
var manualGatewayCard: some View {
|
||||
Section("Manual Gateway") {
|
||||
Toggle(isOn: self.$manualGatewayEnabled) {
|
||||
Toggle(isOn: self.manualGatewayEnabledBinding) {
|
||||
Text("Use Manual Gateway")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
TextField("Host", text: self.manualHostBinding)
|
||||
.font(OpenClawType.body)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
@@ -819,8 +819,8 @@ extension SettingsProTab {
|
||||
Text("Auto-connect on launch")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
self.gatewaySecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
self.gatewaySecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
self.gatewaySecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
|
||||
self.gatewaySecureField("Gateway Password", text: self.gatewayPasswordBinding)
|
||||
Button(role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
} label: {
|
||||
|
||||
@@ -7,8 +7,8 @@ import OpenClawKit
|
||||
/// - a `role=node` session for device capabilities (`node.invoke.*`)
|
||||
/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.)
|
||||
///
|
||||
/// Both sessions should derive all connection inputs from this config so we
|
||||
/// don't accidentally persist gateway-scoped state under different keys.
|
||||
/// Both sessions derive routing and authentication ownership from the route's
|
||||
/// `stableID`. TLS certificate pins prove transport trust but are not gateway identity.
|
||||
struct GatewayConnectConfig {
|
||||
let url: URL
|
||||
let stableID: String
|
||||
@@ -18,7 +18,7 @@ struct GatewayConnectConfig {
|
||||
let password: String?
|
||||
let nodeOptions: GatewayConnectOptions
|
||||
|
||||
/// Stable, non-empty identifier used for gateway-scoped persistence keys.
|
||||
/// Stable, non-empty route identifier used for UI/event ownership.
|
||||
/// If the caller doesn't provide a stableID, fall back to URL identity.
|
||||
var effectiveStableID: String {
|
||||
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -64,6 +64,8 @@ struct GatewayConnectConfig {
|
||||
lhs.clientDisplayName == rhs.clientDisplayName &&
|
||||
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
|
||||
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
|
||||
lhs.allowStoredDeviceAuth == rhs.allowStoredDeviceAuth &&
|
||||
lhs.deviceAuthGatewayID == rhs.deviceAuthGatewayID &&
|
||||
lhsScopes == rhsScopes &&
|
||||
lhsCaps == rhsCaps &&
|
||||
lhsCommands == rhsCommands &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
@@ -23,6 +24,38 @@ enum GatewaySettingsStore {
|
||||
private static let lastGatewayConnectionAccount = "lastConnection"
|
||||
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret
|
||||
|
||||
struct GatewayCredentialMetadata: Codable, Equatable {
|
||||
let gatewayStableID: String
|
||||
let suppressStoredDeviceAuth: Bool
|
||||
}
|
||||
|
||||
/// Credential ownership and secrets must move together. Separate Keychain
|
||||
/// entries can survive a partial update and bind one gateway's secret to another.
|
||||
private struct GatewayCredentialBundle: Codable {
|
||||
let gatewayStableID: String
|
||||
let suppressStoredDeviceAuth: Bool
|
||||
let token: String?
|
||||
let bootstrapToken: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
struct GatewayCredentials: Equatable {
|
||||
let token: String?
|
||||
let bootstrapToken: String?
|
||||
let password: String?
|
||||
let suppressStoredDeviceAuth: Bool
|
||||
|
||||
static let empty = GatewayCredentials(
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
suppressStoredDeviceAuth: false)
|
||||
|
||||
var hasCredentials: Bool {
|
||||
self.token != nil || self.bootstrapToken != nil || self.password != nil
|
||||
}
|
||||
}
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
self.ensureStableInstanceID()
|
||||
self.ensurePreferredGatewayStableID()
|
||||
@@ -107,75 +140,172 @@ enum GatewaySettingsStore {
|
||||
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadGatewayToken(instanceId: String) -> String? {
|
||||
let account = self.gatewayTokenAccount(instanceId: instanceId)
|
||||
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token?.isEmpty == false { return token }
|
||||
return nil
|
||||
static func loadGatewayCredentialMetadata(instanceId: String) -> GatewayCredentialMetadata? {
|
||||
guard let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId) else { return nil }
|
||||
return GatewayCredentialMetadata(
|
||||
gatewayStableID: bundle.gatewayStableID,
|
||||
suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth)
|
||||
}
|
||||
|
||||
static func saveGatewayToken(_ token: String, instanceId: String) {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: instanceId))
|
||||
return
|
||||
static func loadGatewayCredentials(instanceId: String, gatewayStableID: String) -> GatewayCredentials {
|
||||
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
||||
guard !stableID.isEmpty,
|
||||
let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId),
|
||||
bundle.gatewayStableID == stableID
|
||||
else { return .empty }
|
||||
return GatewayCredentials(
|
||||
token: bundle.token,
|
||||
bootstrapToken: bundle.bootstrapToken,
|
||||
password: bundle.password,
|
||||
suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveGatewayCredentials(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
gatewayStableID: String,
|
||||
suppressStoredDeviceAuth: Bool,
|
||||
instanceId: String) -> Bool
|
||||
{
|
||||
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
||||
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !stableID.isEmpty, !trimmedInstanceID.isEmpty else { return false }
|
||||
let bundle = GatewayCredentialBundle(
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: suppressStoredDeviceAuth,
|
||||
token: self.normalizedCredential(token),
|
||||
bootstrapToken: self.normalizedCredential(bootstrapToken),
|
||||
password: self.normalizedCredential(password))
|
||||
let account = self.gatewayCredentialBundleAccount(instanceId: trimmedInstanceID)
|
||||
let hasCredentials = bundle.token != nil || bundle.bootstrapToken != nil || bundle.password != nil
|
||||
guard hasCredentials || suppressStoredDeviceAuth else {
|
||||
let deleted = KeychainStore.delete(service: self.gatewayService, account: account)
|
||||
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
||||
return deleted || KeychainStore.loadString(service: self.gatewayService, account: account) == nil
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(bundle),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
_ = KeychainStore.delete(service: self.gatewayService, account: account)
|
||||
return false
|
||||
}
|
||||
guard KeychainStore.saveString(
|
||||
json,
|
||||
service: self.gatewayService,
|
||||
account: account)
|
||||
else {
|
||||
// The Keychain helper restores the prior item when replacement fails. Keep that
|
||||
// known-good bundle; callers already treat this attempted update as uncommitted.
|
||||
return false
|
||||
}
|
||||
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func updateGatewayCredentials(
|
||||
token: String?,
|
||||
password: String?,
|
||||
gatewayStableID: String,
|
||||
instanceId: String) -> Bool
|
||||
{
|
||||
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
||||
let existing = self.loadGatewayCredentialBundle(instanceId: instanceId)
|
||||
let sameOwner = existing?.gatewayStableID == stableID
|
||||
return self.saveGatewayCredentials(
|
||||
token: token,
|
||||
bootstrapToken: sameOwner ? existing?.bootstrapToken : nil,
|
||||
password: password,
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: sameOwner && existing?.suppressStoredDeviceAuth == true,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func completeGatewayCredentialHandoff(instanceId: String, gatewayStableID: String) -> Bool {
|
||||
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
||||
guard let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId),
|
||||
bundle.gatewayStableID == stableID,
|
||||
bundle.suppressStoredDeviceAuth
|
||||
else { return false }
|
||||
// Device-token issuance and bootstrap consumption are one durable handoff. A relaunch
|
||||
// must never observe a spent bootstrap token while stored device auth remains disabled.
|
||||
return self.saveGatewayCredentials(
|
||||
token: bundle.token,
|
||||
bootstrapToken: nil,
|
||||
password: bundle.password,
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: false,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
static func discardUnscopedGatewayCredentials(instanceId: String) {
|
||||
// The legacy UI saved fields before a successful connection, so the last route
|
||||
// cannot prove who owns these secrets. Re-entry is safer than cross-gateway reuse.
|
||||
self.deleteLegacyGatewayCredentials(instanceId: instanceId)
|
||||
}
|
||||
|
||||
/// Certificate pins prove transport trust for one route; they are not gateway identities.
|
||||
/// Wildcard certificates and reverse proxies may legitimately reuse a leaf certificate.
|
||||
static func authenticationOwnerID(routeStableID: String) -> String {
|
||||
routeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func migrateProvenRelayCredentials(
|
||||
instanceId: String,
|
||||
gatewayStableID: String,
|
||||
token: String?,
|
||||
password: String?) -> Bool
|
||||
{
|
||||
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceID.isEmpty, !stableID.isEmpty else { return false }
|
||||
let legacyAccounts = [
|
||||
self.gatewayTokenAccount(instanceId: trimmedInstanceID),
|
||||
self.gatewayBootstrapTokenAccount(instanceId: trimmedInstanceID),
|
||||
self.gatewayPasswordAccount(instanceId: trimmedInstanceID),
|
||||
]
|
||||
let hasLegacyCredentials = legacyAccounts.contains { account in
|
||||
self.normalizedCredential(KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: account)) != nil
|
||||
}
|
||||
guard hasLegacyCredentials else { return true }
|
||||
|
||||
// A canonical bundle already owns the fields atomically. Never replace it with
|
||||
// older relay data merely because legacy per-field entries still exist.
|
||||
if self.loadGatewayCredentialBundle(instanceId: trimmedInstanceID) != nil {
|
||||
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
||||
return true
|
||||
}
|
||||
|
||||
let relayToken = self.normalizedCredential(token)
|
||||
let relayPassword = self.normalizedCredential(password)
|
||||
guard relayToken != nil || relayPassword != nil else {
|
||||
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
||||
return true
|
||||
}
|
||||
// Relay config is written only after a successful connection and therefore proves
|
||||
// both the credential values and their gateway owner. Preserve it before cleanup.
|
||||
return self.saveGatewayCredentials(
|
||||
token: relayToken,
|
||||
bootstrapToken: nil,
|
||||
password: relayPassword,
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: false,
|
||||
instanceId: trimmedInstanceID)
|
||||
}
|
||||
|
||||
static func saveLegacyGatewayTokenForMigrationTest(_ token: String, instanceId: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
trimmed,
|
||||
token,
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func loadGatewayBootstrapToken(instanceId: String) -> String? {
|
||||
let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId)
|
||||
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token?.isEmpty == false { return token }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveGatewayBootstrapToken(_ token: String, instanceId: String) {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
self.clearGatewayBootstrapToken(instanceId: instanceId)
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(
|
||||
trimmed,
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func clearGatewayBootstrapToken(instanceId: String) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func loadGatewayPassword(instanceId: String) -> String? {
|
||||
KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveGatewayPassword(_ password: String, instanceId: String) {
|
||||
let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(
|
||||
trimmed,
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
enum LastGatewayConnection: Equatable {
|
||||
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
|
||||
case discovered(stableID: String, useTLS: Bool)
|
||||
@@ -308,13 +438,8 @@ enum GatewaySettingsStore {
|
||||
guard !trimmed.isEmpty else { return }
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: trimmed))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
||||
account: self.gatewayCredentialBundleAccount(instanceId: trimmed))
|
||||
self.deleteLegacyGatewayCredentials(instanceId: trimmed)
|
||||
}
|
||||
|
||||
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
@@ -373,6 +498,47 @@ enum GatewaySettingsStore {
|
||||
"gateway-password.\(instanceId)"
|
||||
}
|
||||
|
||||
private static func gatewayCredentialBundleAccount(instanceId: String) -> String {
|
||||
"gateway-credentials.\(instanceId)"
|
||||
}
|
||||
|
||||
private static func loadGatewayCredentialBundle(instanceId: String) -> GatewayCredentialBundle? {
|
||||
guard let json = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayCredentialBundleAccount(instanceId: instanceId)),
|
||||
let data = json.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data)
|
||||
else { return nil }
|
||||
let stableID = decoded.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
return GatewayCredentialBundle(
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: decoded.suppressStoredDeviceAuth,
|
||||
token: self.normalizedCredential(decoded.token),
|
||||
bootstrapToken: self.normalizedCredential(decoded.bootstrapToken),
|
||||
password: self.normalizedCredential(decoded.password))
|
||||
}
|
||||
|
||||
private static func normalizedCredential(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func deleteLegacyGatewayCredentials(instanceId: String) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: instanceId))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: "gateway-credential-metadata.\(instanceId)")
|
||||
}
|
||||
|
||||
private static func talkProviderApiKeyAccount(providerId: String) -> String {
|
||||
self.talkProviderApiKeyAccountPrefix + providerId
|
||||
}
|
||||
@@ -460,9 +626,10 @@ enum GatewayDiagnostics {
|
||||
|
||||
func failed(_ stage: String, error: Error) {
|
||||
let nsError = error as NSError
|
||||
let errorType = String(reflecting: type(of: error))
|
||||
self
|
||||
.stage(
|
||||
"\(stage) failed errorType=\(String(reflecting: type(of: error))) domain=\(nsError.domain) code=\(nsError.code)")
|
||||
"\(stage) failed errorType=\(errorType) domain=\(nsError.domain) code=\(nsError.code)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import SwiftUI
|
||||
|
||||
struct GatewayTrustPromptAlert: ViewModifier {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
let isEnabled: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert(
|
||||
"Trust this gateway?",
|
||||
isPresented: Binding(
|
||||
get: { self.gatewayController.pendingTrustPrompt != nil },
|
||||
get: { self.isEnabled && self.gatewayController.pendingTrustPrompt != nil },
|
||||
set: { _ in
|
||||
// Keep pending trust state until explicit user action.
|
||||
// SwiftUI may set presentation bindings during dismissal; clearing here can
|
||||
@@ -39,7 +40,7 @@ struct GatewayTrustPromptAlert: ViewModifier {
|
||||
}
|
||||
|
||||
extension View {
|
||||
func gatewayTrustPromptAlert() -> some View {
|
||||
self.modifier(GatewayTrustPromptAlert())
|
||||
func gatewayTrustPromptAlert(isEnabled: Bool = true) -> some View {
|
||||
self.modifier(GatewayTrustPromptAlert(isEnabled: isEnabled))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ extension NodeAppModel {
|
||||
normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
normalized.promptId = self.trimmedOrNil(params.promptId)
|
||||
normalized.sessionKey = self.trimmedOrNil(params.sessionKey)
|
||||
normalized.gatewayStableID = self.trimmedOrNil(params.gatewayStableID)
|
||||
normalized.kind = self.trimmedOrNil(params.kind)
|
||||
normalized.details = self.trimmedOrNil(params.details)
|
||||
normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,25 +6,16 @@ enum GatewayOnboardingReset {
|
||||
static func prepareForBootstrapPairing(
|
||||
appModel: NodeAppModel,
|
||||
instanceId: String,
|
||||
gatewayStableID: String,
|
||||
disconnectGateway: Bool = true,
|
||||
defaults: UserDefaults = .standard)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
|
||||
let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
|
||||
DeviceAuthStore.clearAll(profile: .shareExtension)
|
||||
|
||||
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
|
||||
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)
|
||||
GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults)
|
||||
GatewayTLSStore.clearAllFingerprints()
|
||||
defaults.set(false, forKey: "gateway.autoconnect")
|
||||
self.prepare(
|
||||
appModel: appModel,
|
||||
instanceId: instanceId,
|
||||
gatewayStableID: gatewayStableID,
|
||||
disconnectGateway: disconnectGateway,
|
||||
defaults: defaults)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -33,7 +24,12 @@ enum GatewayOnboardingReset {
|
||||
instanceId: String,
|
||||
defaults: UserDefaults = .standard)
|
||||
{
|
||||
self.prepareForBootstrapPairing(appModel: appModel, instanceId: instanceId, defaults: defaults)
|
||||
self.prepare(
|
||||
appModel: appModel,
|
||||
instanceId: instanceId,
|
||||
gatewayStableID: nil,
|
||||
disconnectGateway: true,
|
||||
defaults: defaults)
|
||||
OnboardingStateStore.reset(defaults: defaults)
|
||||
|
||||
defaults.set(false, forKey: "gateway.onboardingComplete")
|
||||
@@ -43,4 +39,55 @@ enum GatewayOnboardingReset {
|
||||
defaults.set("", forKey: "gateway.setupCode")
|
||||
defaults.set(defaults.integer(forKey: "onboarding.requestID") + 1, forKey: "onboarding.requestID")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func prepare(
|
||||
appModel: NodeAppModel,
|
||||
instanceId: String,
|
||||
gatewayStableID: String?,
|
||||
disconnectGateway: Bool,
|
||||
defaults: UserDefaults)
|
||||
{
|
||||
if disconnectGateway {
|
||||
appModel.disconnectGateway()
|
||||
}
|
||||
|
||||
let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
if let gatewayStableID {
|
||||
let authenticationOwnerID = GatewaySettingsStore.authenticationOwnerID(
|
||||
routeStableID: gatewayStableID)
|
||||
let shareDeviceId = DeviceIdentityStore.loadOrCreate(profile: .shareExtension).deviceId
|
||||
// Bootstrap replacement invalidates only the target. Other paired gateways remain
|
||||
// usable when the user switches back after reviewing or completing this setup.
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node", gatewayID: authenticationOwnerID)
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator", gatewayID: authenticationOwnerID)
|
||||
DeviceAuthStore.clearToken(
|
||||
deviceId: shareDeviceId,
|
||||
role: "node",
|
||||
gatewayID: authenticationOwnerID,
|
||||
profile: .shareExtension)
|
||||
DeviceAuthStore.clearToken(
|
||||
deviceId: shareDeviceId,
|
||||
role: "operator",
|
||||
gatewayID: authenticationOwnerID,
|
||||
profile: .shareExtension)
|
||||
GatewayTLSStore.clearFingerprint(stableID: gatewayStableID)
|
||||
} else {
|
||||
// Full onboarding reset is the only path that intentionally forgets every gateway.
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
|
||||
DeviceAuthStore.clearAll(profile: .shareExtension)
|
||||
GatewayTLSStore.clearAllFingerprints()
|
||||
}
|
||||
|
||||
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
|
||||
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)
|
||||
GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults)
|
||||
defaults.set(false, forKey: "gateway.autoconnect")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,26 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewaySetupLinkStaging {
|
||||
private(set) var link: GatewayConnectDeepLink?
|
||||
|
||||
mutating func stage(_ link: GatewayConnectDeepLink) {
|
||||
self.link = link
|
||||
}
|
||||
|
||||
mutating func take() -> GatewayConnectDeepLink? {
|
||||
defer { self.link = nil }
|
||||
return self.link
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func cancel() -> Bool {
|
||||
guard self.link != nil else { return false }
|
||||
self.link = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@@ -55,6 +75,7 @@ struct OnboardingWizardView: View {
|
||||
@State private var manualTLS: Bool = true
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var gatewayCredentialFieldStableID: String?
|
||||
@State private var connectMessage: String?
|
||||
@State private var statusLine: String = ""
|
||||
@State private var connectingGatewayID: String?
|
||||
@@ -64,10 +85,14 @@ struct OnboardingWizardView: View {
|
||||
@State private var discoveryRestartTask: Task<Void, Never>?
|
||||
@State private var showQRScanner: Bool = false
|
||||
@State private var scannerError: String?
|
||||
@State private var scannerResultHandoff = QRScannerResultHandoff()
|
||||
@State private var scannerScanID: UInt64 = 0
|
||||
@State private var pendingTargetSuppression = GatewayPendingTargetSuppression()
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var lastPairingAutoResumeAttemptAt: Date?
|
||||
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
@State private var setupLinkStaging = GatewaySetupLinkStaging()
|
||||
@State private var setupCode: String = ""
|
||||
@State private var setupCodeStatus: String?
|
||||
@State private var setupAttemptID: UUID?
|
||||
@@ -101,6 +126,18 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.lifecycleContent
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == ScenePhase.active else { return }
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
.onReceive(Self.pairingAutoResumeTicker) { _ in
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var lifecycleContent: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch self.step {
|
||||
@@ -189,73 +226,14 @@ struct OnboardingWizardView: View {
|
||||
Text(self.scannerError ?? "")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
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: .principal) {
|
||||
Text("Scan QR Code")
|
||||
.font(OpenClawType.headline)
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
self.showQRScanner = false
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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.fromSetupInput(message) {
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
if AppleReviewDemoMode.isSetupCode(message) {
|
||||
self.handleScannedSetupCode(message)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
isPresented: self.$showQRScanner,
|
||||
onDismiss: {
|
||||
self.processQueuedScannerResult()
|
||||
},
|
||||
content: {
|
||||
self.qrScannerSheet
|
||||
})
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
@@ -268,12 +246,15 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
|
||||
}
|
||||
.onDisappear {
|
||||
self.invalidateSetupAttempt()
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = nil
|
||||
self.scannerResultHandoff.cancel()
|
||||
self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController)
|
||||
}
|
||||
.onChange(of: self.discoveryDomain) { _, _ in
|
||||
self.scheduleDiscoveryRestart()
|
||||
@@ -296,11 +277,9 @@ struct OnboardingWizardView: View {
|
||||
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.setupCode) { _, newValue in
|
||||
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
self.clearStagedGatewaySetupLink()
|
||||
}
|
||||
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
||||
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
||||
@@ -308,8 +287,11 @@ struct OnboardingWizardView: View {
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
guard newValue != nil, self.setupLinkStaging.link == nil else { return }
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Connected."
|
||||
if !self.didMarkCompleted, let selectedMode {
|
||||
@@ -318,13 +300,76 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
self.step = .success
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == .active else { return }
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
.onReceive(Self.pairingAutoResumeTicker) { _ in
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
|
||||
private var qrScannerSheet: some View {
|
||||
let scanID = self.scannerScanID
|
||||
return NavigationStack {
|
||||
QRScannerView(
|
||||
onResult: { result in
|
||||
self.queueScannedResult(result, scanID: scanID)
|
||||
},
|
||||
onError: { error in
|
||||
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Scan QR Code")
|
||||
.font(OpenClawType.headline)
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
self.scannerResultHandoff.cancel()
|
||||
self.showQRScanner = false
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "Could not load the selected image."
|
||||
return
|
||||
}
|
||||
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
|
||||
if let message = self.detectQRCode(from: data) {
|
||||
if let link = GatewayConnectDeepLink.fromSetupInput(message) {
|
||||
self.queueScannedResult(.gatewayLink(link), scanID: scanID)
|
||||
return
|
||||
}
|
||||
if AppleReviewDemoMode.isSetupCode(message) {
|
||||
self.queueScannedResult(.setupCode(message), scanID: scanID)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var introStep: some View {
|
||||
@@ -443,13 +488,17 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
switch selectedMode {
|
||||
case .homeNetwork:
|
||||
self.homeNetworkConnectSection
|
||||
case .remoteDomain:
|
||||
self.remoteDomainConnectSection
|
||||
case .developerLocal:
|
||||
self.developerConnectSection
|
||||
if let stagedLink = self.setupLinkStaging.link {
|
||||
self.stagedGatewaySetupSection(stagedLink)
|
||||
} else {
|
||||
switch selectedMode {
|
||||
case .homeNetwork:
|
||||
self.homeNetworkConnectSection
|
||||
case .remoteDomain:
|
||||
self.remoteDomainConnectSection
|
||||
case .developerLocal:
|
||||
self.developerConnectSection
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
@@ -466,6 +515,50 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func stagedGatewaySetupSection(_ link: GatewayConnectDeepLink) -> some View {
|
||||
Section {
|
||||
self.onboardingLabeledContent("Host", value: link.host)
|
||||
self.onboardingLabeledContent("Port", value: String(link.port))
|
||||
self.onboardingLabeledContent("Security", value: link.tls ? "TLS" : "Plaintext (local network)")
|
||||
|
||||
Button {
|
||||
Task { await self.connectStagedGatewaySetupLink() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button {
|
||||
self.clearStagedGatewaySetupLink()
|
||||
} label: {
|
||||
Text("Use Manual Setup")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
} header: {
|
||||
Text("Setup Link")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
} footer: {
|
||||
Text(link.tls
|
||||
? "Review this endpoint. Credentials are applied only after you tap Connect."
|
||||
:
|
||||
"Plaintext may expose credentials. Continue only if you trust this local network and host.")
|
||||
.font(OpenClawType.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var homeNetworkConnectSection: some View {
|
||||
Group {
|
||||
Section {
|
||||
@@ -531,11 +624,11 @@ struct OnboardingWizardView: View {
|
||||
|
||||
private var developerConnectSection: some View {
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
TextField("Host", text: self.manualHostBinding)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(OpenClawType.subhead)
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
TextField("Port", text: self.manualPortTextBinding)
|
||||
.keyboardType(.numberPad)
|
||||
.font(OpenClawType.subhead)
|
||||
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
|
||||
@@ -552,10 +645,10 @@ struct OnboardingWizardView: View {
|
||||
private var authStep: some View {
|
||||
Group {
|
||||
Section {
|
||||
self.onboardingSecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
self.onboardingSecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding)
|
||||
|
||||
if let problem = self.currentProblem {
|
||||
GatewayProblemBanner(
|
||||
@@ -721,11 +814,11 @@ extension OnboardingWizardView {
|
||||
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
TextField("Host", text: self.manualHostBinding)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(OpenClawType.subhead)
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
TextField("Port", text: self.manualPortTextBinding)
|
||||
.keyboardType(.numberPad)
|
||||
.font(OpenClawType.subhead)
|
||||
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
|
||||
@@ -734,10 +827,10 @@ extension OnboardingWizardView {
|
||||
.autocorrectionDisabled()
|
||||
.font(OpenClawType.subhead)
|
||||
if self.selectedMode == .remoteDomain {
|
||||
self.onboardingSecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
self.onboardingSecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding)
|
||||
}
|
||||
self.manualConnectButton
|
||||
} header: {
|
||||
@@ -800,6 +893,7 @@ extension OnboardingWizardView {
|
||||
self.setupCodeStatus = "Paste a setup code to continue."
|
||||
return
|
||||
}
|
||||
self.clearStagedGatewaySetupLink()
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.setupCode = ""
|
||||
@@ -827,6 +921,26 @@ extension OnboardingWizardView {
|
||||
await self.connectManual(setupAttemptID: attemptID)
|
||||
}
|
||||
|
||||
private func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
|
||||
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
|
||||
self.statusLine = "QR loaded. Closing scanner..."
|
||||
self.showQRScanner = false
|
||||
}
|
||||
|
||||
private func processQueuedScannerResult() {
|
||||
let delivery = self.scannerResultHandoff.processAfterDismissal { result in
|
||||
switch result {
|
||||
case let .gatewayLink(link):
|
||||
self.handleScannedLink(link)
|
||||
case let .setupCode(code):
|
||||
self.handleScannedSetupCode(code)
|
||||
}
|
||||
}
|
||||
if delivery == nil {
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
|
||||
self.showQRScanner = false
|
||||
guard let attemptID = self.beginSetupAttempt() else { return }
|
||||
@@ -835,7 +949,10 @@ extension OnboardingWizardView {
|
||||
}
|
||||
|
||||
private func connectScannedLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
|
||||
defer { self.finishSetupAttempt(attemptID) }
|
||||
defer {
|
||||
self.finishSetupAttempt(attemptID)
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
|
||||
}
|
||||
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
|
||||
guard self.setupAttemptID == attemptID else { return }
|
||||
self.applyGatewayLink(link)
|
||||
@@ -845,26 +962,86 @@ extension OnboardingWizardView {
|
||||
await self.connectManual(setupAttemptID: attemptID)
|
||||
}
|
||||
|
||||
private func applyGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
private func applyPendingGatewaySetupLinkIfNeeded() {
|
||||
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
|
||||
self.showQRScanner = false
|
||||
self.scannerResultHandoff.cancel()
|
||||
self.showGatewayProblemDetails = false
|
||||
let lease = self.gatewayController.cancelPendingConnectionAttempts()
|
||||
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
|
||||
}
|
||||
self.setupLinkStaging.stage(link)
|
||||
self.setupCodeStatus = "Setup link loaded for \(link.host):\(link.port). Tap Connect to apply."
|
||||
self.connectMessage = nil
|
||||
self.statusLine = self.setupCodeStatus ?? ""
|
||||
self.step = .connect
|
||||
}
|
||||
|
||||
private func connectStagedGatewaySetupLink() async {
|
||||
guard self.connectingGatewayID == nil else { return }
|
||||
guard let link = self.setupLinkStaging.link else { return }
|
||||
guard link.isValidEndpoint else {
|
||||
let message = "Setup link has an invalid gateway endpoint."
|
||||
self.setupCodeStatus = message
|
||||
self.statusLine = message
|
||||
return
|
||||
}
|
||||
self.connectingGatewayID = "manual"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let lease = self.gatewayController.cancelPendingConnectionAttempts()
|
||||
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
|
||||
defer { self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) }
|
||||
await self.appModel.resetGatewaySessionsForTargetSwitch()
|
||||
guard self.setupLinkStaging.link == link else { return }
|
||||
_ = self.setupLinkStaging.take()
|
||||
self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)
|
||||
self.setupCodeStatus = "Setup link applied. Connecting..."
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(link.host)…"
|
||||
self.statusLine = "Connecting to \(link.host):\(link.port)…"
|
||||
await self.connectCurrentManualGateway(host: link.host, port: link.port, forceReconnect: false)
|
||||
}
|
||||
|
||||
private func clearStagedGatewaySetupLink() {
|
||||
guard self.setupLinkStaging.cancel() else { return }
|
||||
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
|
||||
let message = "Setup link cleared."
|
||||
self.setupCodeStatus = message
|
||||
self.statusLine = message
|
||||
}
|
||||
|
||||
private func applyGatewayLink(
|
||||
_ link: GatewayConnectDeepLink,
|
||||
disconnectExistingGatewayForBootstrap: Bool = true)
|
||||
{
|
||||
self.manualHost = link.host
|
||||
self.manualPort = link.port
|
||||
self.manualPortText = String(link.port)
|
||||
self.manualTLS = link.tls
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(
|
||||
appModel: self.appModel,
|
||||
instanceId: GatewaySettingsStore.currentInstanceID())
|
||||
}
|
||||
self.saveGatewayBootstrapToken(setupAuth.bootstrapToken)
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.gatewayToken = setupAuth.token
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.gatewayPassword = setupAuth.password
|
||||
instanceId: GatewaySettingsStore.currentInstanceID(),
|
||||
gatewayStableID: setupAuth.targetStableID,
|
||||
disconnectGateway: disconnectExistingGatewayForBootstrap)
|
||||
}
|
||||
self.gatewayToken = setupAuth.token
|
||||
self.gatewayPassword = setupAuth.password
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: setupAuth.token,
|
||||
bootstrapToken: setupAuth.bootstrapToken,
|
||||
password: setupAuth.password,
|
||||
gatewayStableID: setupAuth.targetStableID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
|
||||
}
|
||||
@@ -878,16 +1055,21 @@ extension OnboardingWizardView {
|
||||
self.statusLine = "Apple Review demo mode enabled."
|
||||
self.selectedMode = .homeNetwork
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
|
||||
}
|
||||
|
||||
private func openQRScannerFromOnboarding() {
|
||||
private func openQRScannerFromOnboarding(status: String = "Opening QR scanner…") {
|
||||
// Stop active reconnect loops before scanning new credentials.
|
||||
self.appModel.disconnectGateway()
|
||||
self.invalidateSetupAttempt()
|
||||
let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
||||
_ = self.setupLinkStaging.cancel()
|
||||
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
|
||||
self.scannerScanID = self.scannerResultHandoff.beginScan()
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.issue = .none
|
||||
self.pairingRequestId = nil
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.statusLine = status
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
@@ -1024,7 +1206,7 @@ extension OnboardingWizardView {
|
||||
|
||||
private var canConnectManual: Bool {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
||||
return !host.isEmpty && self.resolvedManualPort(host: host) != nil
|
||||
}
|
||||
|
||||
private var successEndpoint: String {
|
||||
@@ -1065,9 +1247,19 @@ extension OnboardingWizardView {
|
||||
}
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
if !trimmedInstanceId.isEmpty,
|
||||
let stableID = self.currentManualGatewayStableID
|
||||
{
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: trimmedInstanceId,
|
||||
gatewayStableID: stableID)
|
||||
let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth
|
||||
self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil
|
||||
self.gatewayToken = credentials.token ?? ""
|
||||
self.gatewayPassword = credentials.password ?? ""
|
||||
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: trimmedInstanceId,
|
||||
targetStableID: stableID)
|
||||
}
|
||||
|
||||
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
|
||||
@@ -1087,23 +1279,129 @@ extension OnboardingWizardView {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGatewayCredentials(token: String, password: String) {
|
||||
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||
private var currentManualGatewayStableID: String? {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
|
||||
return GatewayConnectionController.ManualAuthOverride.manualStableID(
|
||||
host: host,
|
||||
port: port)
|
||||
}
|
||||
|
||||
private func saveGatewayBootstrapToken(_ token: String?) {
|
||||
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||
private var gatewayCredentialTargetStableID: String? {
|
||||
// Auth fields follow the selected route. Otherwise a discovered-gateway retry can save
|
||||
// credentials under the unrelated manual endpoint and immediately reload an empty bundle.
|
||||
self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID
|
||||
}
|
||||
|
||||
private func resolvedManualPort(host: String) -> Int? {
|
||||
guard self.manualPortText.isEmpty || self.manualPort > 0 else { return nil }
|
||||
return GatewayConnectionController.resolvedManualPort(
|
||||
host: host,
|
||||
port: self.manualPort)
|
||||
}
|
||||
|
||||
private var gatewayTokenBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.gatewayToken },
|
||||
set: { self.persistGatewayToken($0) })
|
||||
}
|
||||
|
||||
private var gatewayPasswordBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.gatewayPassword },
|
||||
set: { self.persistGatewayPassword($0) })
|
||||
}
|
||||
|
||||
private var manualHostBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualHost },
|
||||
set: { value in
|
||||
let previousStableID = self.currentManualGatewayStableID
|
||||
self.manualHost = value
|
||||
if previousStableID != self.currentManualGatewayStableID {
|
||||
self.clearManualCredentialFields()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var manualPortTextBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualPortText },
|
||||
set: { value in
|
||||
let previousStableID = self.currentManualGatewayStableID
|
||||
let digits = value.filter(\.isNumber)
|
||||
self.manualPortText = digits
|
||||
self.manualPort = min(Int(digits) ?? 0, 65535)
|
||||
if previousStableID != self.currentManualGatewayStableID {
|
||||
self.clearManualCredentialFields()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func persistGatewayToken(_ value: String) {
|
||||
self.gatewayToken = value
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
|
||||
self.gatewayCredentialFieldStableID = stableID
|
||||
let saved = GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: value,
|
||||
password: self.gatewayPassword,
|
||||
gatewayStableID: stableID,
|
||||
instanceId: instanceId)
|
||||
self.pendingManualAuthOverride = saved
|
||||
? GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: instanceId,
|
||||
targetStableID: stableID)
|
||||
: nil
|
||||
}
|
||||
|
||||
private func persistGatewayPassword(_ value: String) {
|
||||
self.gatewayPassword = value
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
|
||||
self.gatewayCredentialFieldStableID = stableID
|
||||
let saved = GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: self.gatewayToken,
|
||||
password: value,
|
||||
gatewayStableID: stableID,
|
||||
instanceId: instanceId)
|
||||
self.pendingManualAuthOverride = saved
|
||||
? GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: instanceId,
|
||||
targetStableID: stableID)
|
||||
: nil
|
||||
}
|
||||
|
||||
private func clearManualCredentialFields() {
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
self.gatewayCredentialFieldStableID = nil
|
||||
self.pendingManualAuthOverride = nil
|
||||
}
|
||||
|
||||
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if self.gatewayCredentialFieldStableID != stableID {
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceId,
|
||||
gatewayStableID: stableID)
|
||||
self.gatewayCredentialFieldStableID = stableID
|
||||
self.gatewayToken = credentials.token ?? ""
|
||||
self.gatewayPassword = credentials.password ?? ""
|
||||
}
|
||||
guard allowManualOverride else {
|
||||
self.pendingManualAuthOverride = nil
|
||||
return
|
||||
}
|
||||
// Each attempt consumes the in-memory override. Reload durable bootstrap auth even
|
||||
// when the endpoint fields did not change so retry never erases a one-time token.
|
||||
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
|
||||
instanceId: instanceId,
|
||||
targetStableID: stableID)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
|
||||
self.connectingGatewayID = gateway.id
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(gateway.name)…"
|
||||
@@ -1118,6 +1416,12 @@ extension OnboardingWizardView {
|
||||
}
|
||||
|
||||
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
|
||||
let previousStableID = self.currentManualGatewayStableID
|
||||
defer {
|
||||
if previousStableID != self.currentManualGatewayStableID {
|
||||
self.clearManualCredentialFields()
|
||||
}
|
||||
}
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
|
||||
|
||||
@@ -1151,27 +1455,53 @@ extension OnboardingWizardView {
|
||||
self.invalidateSetupAttempt()
|
||||
}
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
|
||||
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return }
|
||||
self.connectingGatewayID = "manual"
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(host)…"
|
||||
self.statusLine = "Connecting to \(host):\(self.manualPort)…"
|
||||
self.statusLine = "Connecting to \(host):\(port)…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.connectCurrentManualGateway(host: host, forceReconnect: false)
|
||||
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: false)
|
||||
}
|
||||
|
||||
private func connectCurrentManualGateway(host: String, forceReconnect: Bool) async {
|
||||
private func connectCurrentManualGateway(host: String, port: Int, forceReconnect: Bool) async {
|
||||
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
||||
host: host,
|
||||
port: port)
|
||||
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
|
||||
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
|
||||
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
|
||||
{
|
||||
self.pendingManualAuthOverride = nil
|
||||
}
|
||||
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
|
||||
let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID
|
||||
? self.pendingManualAuthOverride
|
||||
: nil
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.gatewayToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.gatewayPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
token: fieldsMatchTarget ? self.gatewayToken : nil,
|
||||
pendingOverride: pendingOverride,
|
||||
password: fieldsMatchTarget ? self.gatewayPassword : nil,
|
||||
targetStableID: stableID)
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil {
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: authOverride?.token,
|
||||
bootstrapToken: authOverride?.bootstrapToken,
|
||||
password: authOverride?.password,
|
||||
gatewayStableID: stableID,
|
||||
suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualPort,
|
||||
port: port,
|
||||
useTLS: self.manualTLS,
|
||||
authOverride: authOverride,
|
||||
forceReconnect: forceReconnect)
|
||||
// The controller now owns this attempt's immutable override. A later retry must reload
|
||||
// durable state so a spent bootstrap token cannot be resurrected from the live view.
|
||||
self.pendingManualAuthOverride = nil
|
||||
}
|
||||
|
||||
private func retryLastAttempt(silent: Bool = false) async {
|
||||
@@ -1192,8 +1522,8 @@ extension OnboardingWizardView {
|
||||
// a missing stored connection would silently do nothing. Manual
|
||||
// retries must dial the current form input instead.
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 {
|
||||
await self.connectCurrentManualGateway(host: host, forceReconnect: true)
|
||||
if !host.isEmpty, let port = self.resolvedManualPort(host: host) {
|
||||
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: true)
|
||||
return
|
||||
}
|
||||
if !silent {
|
||||
@@ -1215,13 +1545,14 @@ extension OnboardingWizardView {
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
self.gatewayCredentialFieldStableID = nil
|
||||
self.pendingManualAuthOverride = nil
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.issue = .none
|
||||
self.pairingRequestId = nil
|
||||
self.statusLine = "Scan a fresh setup QR code from this gateway."
|
||||
self.step = .connect
|
||||
self.showQRScanner = true
|
||||
self.openQRScannerFromOnboarding(status: "Scan a fresh setup QR code from this gateway.")
|
||||
return
|
||||
}
|
||||
if problem.canTrustRotatedCertificate {
|
||||
|
||||
@@ -2,9 +2,112 @@ import OpenClawKit
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
|
||||
enum QRScannerResult: Equatable {
|
||||
case gatewayLink(GatewayConnectDeepLink)
|
||||
case setupCode(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct GatewayPendingTargetSuppression {
|
||||
enum Owner: Equatable {
|
||||
case qrScanner
|
||||
case setupLink
|
||||
}
|
||||
|
||||
private var value: (owner: Owner, lease: GatewayConnectionController.AutoConnectSuppressionLease)?
|
||||
|
||||
mutating func replace(
|
||||
owner: Owner,
|
||||
lease: GatewayConnectionController.AutoConnectSuppressionLease)
|
||||
{
|
||||
self.value = (owner, lease)
|
||||
}
|
||||
|
||||
mutating func take(ifOwnedBy owner: Owner? = nil) -> GatewayConnectionController.AutoConnectSuppressionLease? {
|
||||
guard let value = self.value else { return nil }
|
||||
if let owner, value.owner != owner { return nil }
|
||||
self.value = nil
|
||||
return value.lease
|
||||
}
|
||||
|
||||
mutating func resumeAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) {
|
||||
guard let lease = self.take(ifOwnedBy: owner) else { return }
|
||||
controller.resumeAutoConnect(after: lease)
|
||||
}
|
||||
|
||||
mutating func releaseAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) {
|
||||
guard let lease = self.take(ifOwnedBy: owner) else { return }
|
||||
controller.releaseAutoConnectSuppression(after: lease)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class QRScannerResultHandoff {
|
||||
/// SwiftUI's onDismiss can precede VisionKit's AV capture teardown. Delay
|
||||
/// pairing UI briefly so it cannot race the scanner's camera shutdown.
|
||||
static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000
|
||||
|
||||
private let settlingNanoseconds: UInt64
|
||||
private var pendingResult: QRScannerResult?
|
||||
private var deliveryTask: Task<Void, Never>?
|
||||
private var activeScanID: UInt64 = 0
|
||||
|
||||
init(settlingNanoseconds: UInt64 = QRScannerResultHandoff.defaultSettlingNanoseconds) {
|
||||
self.settlingNanoseconds = settlingNanoseconds
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func beginScan() -> UInt64 {
|
||||
self.cancel()
|
||||
return self.activeScanID
|
||||
}
|
||||
|
||||
func isActive(scanID: UInt64) -> Bool {
|
||||
scanID == self.activeScanID && self.pendingResult == nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func queue(_ result: QRScannerResult, scanID: UInt64) -> Bool {
|
||||
// Camera and Photos can finish together; the first valid result owns this scan.
|
||||
guard self.isActive(scanID: scanID) else { return false }
|
||||
self.pendingResult = result
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func processAfterDismissal(
|
||||
_ process: @escaping @MainActor (QRScannerResult) -> Void) -> Task<Void, Never>?
|
||||
{
|
||||
guard let result = self.pendingResult else {
|
||||
self.cancel()
|
||||
return nil
|
||||
}
|
||||
self.pendingResult = nil
|
||||
self.activeScanID &+= 1
|
||||
self.deliveryTask?.cancel()
|
||||
let settlingNanoseconds = self.settlingNanoseconds
|
||||
let task = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: settlingNanoseconds)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
process(result)
|
||||
}
|
||||
self.deliveryTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
self.activeScanID &+= 1
|
||||
self.deliveryTask?.cancel()
|
||||
self.deliveryTask = nil
|
||||
self.pendingResult = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onGatewayLink: (GatewayConnectDeepLink) -> Void
|
||||
let onSetupCode: (String) -> Void
|
||||
let onResult: (QRScannerResult) -> Void
|
||||
let onError: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@@ -59,7 +162,11 @@ struct QRScannerView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
|
||||
func dataScanner(
|
||||
_ scanner: DataScannerViewController,
|
||||
didAdd items: [RecognizedItem],
|
||||
allItems _: [RecognizedItem])
|
||||
{
|
||||
guard !self.handled else { return }
|
||||
for item in items {
|
||||
guard case let .barcode(barcode) = item,
|
||||
@@ -67,22 +174,26 @@ struct QRScannerView: UIViewControllerRepresentable {
|
||||
else { continue }
|
||||
|
||||
if let link = GatewayConnectDeepLink.fromSetupInput(payload) {
|
||||
self.handled = true
|
||||
Task { @MainActor in
|
||||
self.parent.onGatewayLink(link)
|
||||
}
|
||||
self.deliver(.gatewayLink(link), scanner: scanner)
|
||||
return
|
||||
}
|
||||
if AppleReviewDemoMode.isSetupCode(payload) {
|
||||
self.handled = true
|
||||
Task { @MainActor in
|
||||
self.parent.onSetupCode(payload)
|
||||
}
|
||||
self.deliver(.setupCode(payload), scanner: scanner)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ result: QRScannerResult, scanner: DataScannerViewController) {
|
||||
self.handled = true
|
||||
// DataScannerViewController has no teardown-completion callback. Stop capture
|
||||
// before owners dismiss the sheet and later present pairing UI.
|
||||
scanner.stopScanning()
|
||||
Task { @MainActor in
|
||||
self.parent.onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
|
||||
|
||||
func dataScanner(
|
||||
|
||||
@@ -43,13 +43,14 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
|
||||
private var pendingExecApprovalRequestedPushIDs: [String] = []
|
||||
private var pendingExecApprovalResolvedPushIDs: [String] = []
|
||||
private var pendingExecApprovalRequestedPushes: [ExecApprovalNotificationPrompt] = []
|
||||
private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = []
|
||||
private var pendingOpenURLs: [URL] = []
|
||||
|
||||
weak var appModel: NodeAppModel? {
|
||||
didSet {
|
||||
guard let model = self.resolvedAppModel() else { return }
|
||||
if let token = self.pendingAPNsDeviceToken {
|
||||
guard let model = resolvedAppModel() else { return }
|
||||
if let token = pendingAPNsDeviceToken {
|
||||
self.pendingAPNsDeviceToken = nil
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
@@ -78,21 +79,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingExecApprovalRequestedPushIDs.isEmpty {
|
||||
let pending = self.pendingExecApprovalRequestedPushIDs
|
||||
self.pendingExecApprovalRequestedPushIDs.removeAll()
|
||||
if !self.pendingExecApprovalRequestedPushes.isEmpty {
|
||||
let pending = self.pendingExecApprovalRequestedPushes
|
||||
self.pendingExecApprovalRequestedPushes.removeAll()
|
||||
Task { @MainActor in
|
||||
for approvalId in pending {
|
||||
_ = await model.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
||||
for push in pending {
|
||||
_ = await model.handleExecApprovalRequestedRemotePush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingExecApprovalResolvedPushIDs.isEmpty {
|
||||
let pending = self.pendingExecApprovalResolvedPushIDs
|
||||
self.pendingExecApprovalResolvedPushIDs.removeAll()
|
||||
if !self.pendingExecApprovalResolvedPushes.isEmpty {
|
||||
let pending = self.pendingExecApprovalResolvedPushes
|
||||
self.pendingExecApprovalResolvedPushes.removeAll()
|
||||
Task { @MainActor in
|
||||
for approvalId in pending {
|
||||
await model.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
for push in pending {
|
||||
_ = await model.handleExecApprovalResolvedRemotePush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingOpenURLs.isEmpty {
|
||||
let pending = self.pendingOpenURLs
|
||||
self.pendingOpenURLs.removeAll()
|
||||
Task { @MainActor in
|
||||
for url in pending {
|
||||
await self.handleOpenURL(url, model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +125,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
{
|
||||
GatewayDiagnostics.log("app delegate: didFinishLaunching")
|
||||
if self.appModel == nil {
|
||||
@@ -131,6 +141,33 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool
|
||||
{
|
||||
guard DeepLinkParser.parse(url) != nil else { return false }
|
||||
guard let model = resolvedAppModel() else {
|
||||
self.pendingOpenURLs.append(url)
|
||||
return true
|
||||
}
|
||||
Task { @MainActor in
|
||||
await self.handleOpenURL(url, model: model)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL, model: NodeAppModel) async {
|
||||
guard let route = DeepLinkParser.parse(url) else { return }
|
||||
|
||||
switch route {
|
||||
case .agent, .dashboard:
|
||||
await model.handleDeepLink(url: url)
|
||||
case let .gateway(link):
|
||||
model.stageGatewaySetupLink(link)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard await Self.isNotificationAuthorizationAllowed() else { return }
|
||||
@@ -149,8 +186,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
if let appModel = resolvedAppModel() {
|
||||
Task { @MainActor in
|
||||
appModel.updateAPNsDeviceToken(deviceToken)
|
||||
}
|
||||
@@ -160,38 +197,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
self.pendingAPNsDeviceToken = deviceToken
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
|
||||
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
|
||||
self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
_: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
|
||||
{
|
||||
self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)")
|
||||
Task { @MainActor in
|
||||
let notificationCenter = LiveNotificationCenter()
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: userInfo,
|
||||
notificationCenter: notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
await appModel.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
} else {
|
||||
self.pendingExecApprovalResolvedPushIDs.append(approvalId)
|
||||
}
|
||||
if let push = ExecApprovalNotificationBridge.parseResolvedPush(userInfo: userInfo) {
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
let handled = await appModel.handleExecApprovalResolvedRemotePush(push)
|
||||
completionHandler(handled ? .newData : .noData)
|
||||
} else {
|
||||
self.pendingExecApprovalResolvedPushes.append(push)
|
||||
completionHandler(.newData)
|
||||
}
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
|
||||
== ExecApprovalNotificationBridge.requestedKind,
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
{
|
||||
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
|
||||
if let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) {
|
||||
self.pendingExecApprovalRequestedPushes.append(push)
|
||||
}
|
||||
self.logger.info("APNs wake skipped: appModel unavailable")
|
||||
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
|
||||
@@ -340,7 +369,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
guard let appModel = resolvedAppModel() else {
|
||||
self.pendingWatchPromptActions.append(action)
|
||||
return
|
||||
}
|
||||
@@ -354,7 +383,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
guard let appModel = resolvedAppModel() else {
|
||||
self.pendingExecApprovalPrompts.append(prompt)
|
||||
return
|
||||
}
|
||||
@@ -364,7 +393,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
_: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
|
||||
{
|
||||
@@ -379,7 +408,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
_: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void)
|
||||
{
|
||||
@@ -451,18 +480,18 @@ enum WatchPromptNotificationBridge {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
var categoryIdentifier = ""
|
||||
if !displayedActions.isEmpty {
|
||||
let categoryID = "\(self.categoryPrefix)\(invokeID)"
|
||||
let categoryID = "\(categoryPrefix)\(invokeID)"
|
||||
let category = UNNotificationCategory(
|
||||
identifier: categoryID,
|
||||
actions: self.categoryActions(displayedActions),
|
||||
actions: categoryActions(displayedActions),
|
||||
intentIdentifiers: [],
|
||||
options: [])
|
||||
await self.upsertNotificationCategory(category, center: center)
|
||||
await upsertNotificationCategory(category, center: center)
|
||||
categoryIdentifier = categoryID
|
||||
}
|
||||
|
||||
var userInfo: [AnyHashable: Any] = [
|
||||
self.typeKey: self.typeValue,
|
||||
typeKey: typeValue,
|
||||
]
|
||||
if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty {
|
||||
userInfo[self.promptIDKey] = promptId
|
||||
@@ -510,7 +539,7 @@ enum WatchPromptNotificationBridge {
|
||||
identifier: "watch.prompt.\(invokeID)",
|
||||
content: content,
|
||||
trigger: nil)
|
||||
try? await self.addNotificationRequest(request, center: center)
|
||||
try? await addNotificationRequest(request, center: center)
|
||||
}
|
||||
|
||||
static func actionIDKey(index: Int) -> String {
|
||||
@@ -552,7 +581,7 @@ enum WatchPromptNotificationBridge {
|
||||
|
||||
private static func isNotificationAuthorizationAllowed() async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = await self.notificationAuthorizationStatus(center: center)
|
||||
let status = await notificationAuthorizationStatus(center: center)
|
||||
return self.isAuthorizationStatusAllowed(status)
|
||||
}
|
||||
|
||||
@@ -629,7 +658,7 @@ extension NodeAppModel {
|
||||
note: "source=ios.notification",
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
transport: "ios.notification")
|
||||
await self._bridgeConsumeMirroredWatchReply(event)
|
||||
await _bridgeConsumeMirroredWatchReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,7 +722,9 @@ struct OpenClawApp: App {
|
||||
OpenClawType.refreshUIKitAppearance(in: Self.connectedWindows())
|
||||
})
|
||||
.onOpenURL { url in
|
||||
Task { await self.handleOpenURL(url) }
|
||||
// SwiftUI owns normal scene delivery; the delegate also queues URLs
|
||||
// that arrive before the scene has installed its model.
|
||||
Task { await self.appDelegate.handleOpenURL(url, model: self.appModel) }
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.appModel.setScenePhase(newValue)
|
||||
@@ -736,18 +767,6 @@ struct OpenClawApp: App {
|
||||
}
|
||||
|
||||
extension OpenClawApp {
|
||||
@MainActor
|
||||
private func handleOpenURL(_ url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else { return }
|
||||
|
||||
switch route {
|
||||
case .agent, .dashboard:
|
||||
await self.appModel.handleDeepLink(url: url)
|
||||
case let .gateway(link):
|
||||
self.appModel.stageGatewaySetupLink(link)
|
||||
}
|
||||
}
|
||||
|
||||
private static func installUncaughtExceptionLogger() {
|
||||
NSLog("OpenClaw: installing uncaught exception handler")
|
||||
NSSetUncaughtExceptionHandler { exception in
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
struct ExecApprovalNotificationPrompt: Equatable {
|
||||
struct ExecApprovalNotificationPrompt: Codable, Equatable, Hashable {
|
||||
let approvalId: String
|
||||
let gatewayDeviceId: String?
|
||||
}
|
||||
|
||||
enum ExecApprovalNotificationBridge {
|
||||
@@ -15,10 +16,10 @@ enum ExecApprovalNotificationBridge {
|
||||
|
||||
static func registerCategory(center: UNUserNotificationCenter = .current()) {
|
||||
let category = UNNotificationCategory(
|
||||
identifier: self.categoryIdentifier,
|
||||
identifier: categoryIdentifier,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: self.reviewActionIdentifier,
|
||||
identifier: reviewActionIdentifier,
|
||||
title: "Review",
|
||||
options: [.foreground]),
|
||||
],
|
||||
@@ -33,7 +34,7 @@ enum ExecApprovalNotificationBridge {
|
||||
}
|
||||
|
||||
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
||||
self.payloadKind(userInfo: userInfo) == self.requestedKind
|
||||
self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind) != nil
|
||||
}
|
||||
|
||||
static func parsePrompt(
|
||||
@@ -45,40 +46,43 @@ enum ExecApprovalNotificationBridge {
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
|
||||
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
|
||||
return ExecApprovalNotificationPrompt(approvalId: approvalId)
|
||||
return self.parseRequestedPush(userInfo: userInfo)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func handleResolvedPushIfNeeded(
|
||||
userInfo: [AnyHashable: Any],
|
||||
notificationCenter: NotificationCentering) async -> Bool
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||
let approvalId = self.approvalID(from: userInfo)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? {
|
||||
self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind)
|
||||
}
|
||||
|
||||
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
|
||||
return true
|
||||
static func parseResolvedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? {
|
||||
self.parsePush(userInfo: userInfo, expectedKind: self.resolvedKind)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func removeNotifications(
|
||||
forApprovalID approvalId: String,
|
||||
notificationCenter: NotificationCentering) async
|
||||
for push: ExecApprovalNotificationPrompt,
|
||||
notificationCenter: NotificationCentering,
|
||||
includingLegacyOwnerless: Bool = false) async
|
||||
{
|
||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
|
||||
var pendingIdentifiers = [self.localRequestIdentifier(for: push)]
|
||||
if includingLegacyOwnerless {
|
||||
pendingIdentifiers.append("\(self.localRequestPrefix)\(push.approvalId)")
|
||||
pendingIdentifiers.append(self.localRequestIdentifier(for: ExecApprovalNotificationPrompt(
|
||||
approvalId: push.approvalId,
|
||||
gatewayDeviceId: nil)))
|
||||
}
|
||||
var seenPendingIdentifiers = Set<String>()
|
||||
pendingIdentifiers = pendingIdentifiers.filter { seenPendingIdentifiers.insert($0).inserted }
|
||||
await notificationCenter.removePendingNotificationRequests(
|
||||
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
|
||||
withIdentifiers: pendingIdentifiers)
|
||||
|
||||
let delivered = await notificationCenter.deliveredNotifications()
|
||||
let identifiers = delivered.compactMap { snapshot -> String? in
|
||||
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
|
||||
guard let requestedPush = self.parseRequestedPush(userInfo: snapshot.userInfo) else { return nil }
|
||||
let matchesCurrentOwner = requestedPush == push
|
||||
let matchesLegacyOwnerless = includingLegacyOwnerless &&
|
||||
requestedPush.approvalId == push.approvalId &&
|
||||
requestedPush.gatewayDeviceId == nil
|
||||
guard matchesCurrentOwner || matchesLegacyOwnerless else { return nil }
|
||||
return snapshot.identifier
|
||||
}
|
||||
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
@@ -90,8 +94,29 @@ enum ExecApprovalNotificationBridge {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func localRequestIdentifier(for approvalId: String) -> String {
|
||||
"\(self.localRequestPrefix)\(approvalId)"
|
||||
private static func gatewayDeviceID(from userInfo: [AnyHashable: Any]) -> String? {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["gatewayDeviceId"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func parsePush(
|
||||
userInfo: [AnyHashable: Any],
|
||||
expectedKind: String) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == expectedKind,
|
||||
let approvalId = approvalID(from: userInfo)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return ExecApprovalNotificationPrompt(
|
||||
approvalId: approvalId,
|
||||
gatewayDeviceId: self.gatewayDeviceID(from: userInfo))
|
||||
}
|
||||
|
||||
private static func localRequestIdentifier(for push: ExecApprovalNotificationPrompt) -> String {
|
||||
let owner = push.gatewayDeviceId ?? "legacy"
|
||||
return "\(self.localRequestPrefix)\(owner).\(push.approvalId)"
|
||||
}
|
||||
|
||||
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
|
||||
|
||||
@@ -202,6 +202,9 @@ struct RootTabs: View {
|
||||
|
||||
SettingsProTab(
|
||||
initialRoute: self.selectedSettingsRoute,
|
||||
acceptsGatewaySetupRequests: !self.showOnboarding &&
|
||||
self.selectedTab == .settings &&
|
||||
self.selectedSettingsRoute == .gateway,
|
||||
onRouteChange: self.handleSettingsRouteChange,
|
||||
gatewaySetupRequest: self.gatewaySetupRequest,
|
||||
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
|
||||
@@ -512,28 +515,29 @@ struct RootTabs: View {
|
||||
directRoute: selectedSettingsRoute,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
ownsNavigationStack: false,
|
||||
navigateToRoute: self.pushSidebarSettingsRoute,
|
||||
onRouteChange: self.handleSettingsRouteChange,
|
||||
navigateToRoute: pushSidebarSettingsRoute,
|
||||
onRouteChange: handleSettingsRouteChange,
|
||||
gatewaySetupRequest: self.gatewaySetupRequest,
|
||||
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
|
||||
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
|
||||
} else {
|
||||
SettingsProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
ownsNavigationStack: false,
|
||||
navigateToRoute: self.pushSidebarSettingsRoute,
|
||||
onRouteChange: self.handleSettingsRouteChange,
|
||||
navigateToRoute: pushSidebarSettingsRoute,
|
||||
onRouteChange: handleSettingsRouteChange,
|
||||
gatewaySetupRequest: self.gatewaySetupRequest,
|
||||
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
|
||||
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
|
||||
}
|
||||
case .gateway:
|
||||
SettingsProTab(
|
||||
directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
||||
acceptsGatewaySetupRequests: !self.showOnboarding,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
ownsNavigationStack: false,
|
||||
navigateToRoute: self.pushSidebarSettingsRoute,
|
||||
onRouteChange: self.handleSettingsRouteChange,
|
||||
navigateToRoute: pushSidebarSettingsRoute,
|
||||
onRouteChange: handleSettingsRouteChange,
|
||||
gatewaySetupRequest: self.gatewaySetupRequest,
|
||||
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
|
||||
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +681,7 @@ struct RootTabs: View {
|
||||
private var activeGatewayProblemToast: GatewayConnectionProblem? {
|
||||
// Operator-scope auth/pairing failures can coexist with a connected node.
|
||||
// The problem itself, not aggregate gateway status, owns toast visibility.
|
||||
guard let problem = self.appModel.lastGatewayProblem,
|
||||
guard let problem = appModel.lastGatewayProblem,
|
||||
!self.isGatewayToastSwipeDismissed
|
||||
else { return nil }
|
||||
return problem
|
||||
@@ -690,7 +694,7 @@ struct RootTabs: View {
|
||||
private func gatewayProblemToast(_ problem: GatewayConnectionProblem) -> some View {
|
||||
GatewayProblemBanner(
|
||||
problem: problem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
|
||||
primaryActionTitle: gatewayProblemPrimaryActionTitle(problem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(problem)
|
||||
},
|
||||
@@ -921,7 +925,7 @@ struct RootTabs: View {
|
||||
.environment(self.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)
|
||||
.deepLinkAgentPromptAlert()
|
||||
.execApprovalPromptDialog(
|
||||
suppressedApprovalID: self.activeExecApprovalPromptSuppressionID)
|
||||
@@ -965,8 +969,8 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private func makeHomeCanvasPayload() -> RootTabsHomeCanvasPayload {
|
||||
let gatewayName = self.normalized(self.appModel.gatewayServerName)
|
||||
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
let gatewayName = normalized(appModel.gatewayServerName)
|
||||
let gatewayAddress = normalized(appModel.gatewayRemoteAddress)
|
||||
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
|
||||
let activeAgentID = self.resolveActiveAgentID()
|
||||
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
|
||||
@@ -1019,7 +1023,7 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private func resolveActiveAgentID() -> String {
|
||||
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
|
||||
let selected = normalized(appModel.selectedAgentId) ?? ""
|
||||
if !selected.isEmpty {
|
||||
return selected
|
||||
}
|
||||
@@ -1027,7 +1031,7 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func homeCanvasAgents(activeAgentID: String) -> [RootTabsHomeCanvasAgentCard] {
|
||||
@@ -1052,7 +1056,7 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private func homeCanvasName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
normalized(agent.name) ?? agent.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,7 +1127,7 @@ extension RootTabs {
|
||||
}
|
||||
|
||||
private func requestPhoneControlNavigation(_ target: PhoneControlNavigationRequest.Target) {
|
||||
let requestID = (self.phoneControlNavigationRequest?.id ?? 0) &+ 1
|
||||
let requestID = (phoneControlNavigationRequest?.id ?? 0) &+ 1
|
||||
self.phoneControlNavigationRequest = PhoneControlNavigationRequest(id: requestID, target: target)
|
||||
}
|
||||
|
||||
@@ -1300,7 +1304,9 @@ extension RootTabs {
|
||||
private func maybeOpenSettingsForGatewaySetup() {
|
||||
let requestID = self.appModel.gatewaySetupRequestID
|
||||
guard requestID != 0, requestID != self.gatewaySetupRequest?.id else { return }
|
||||
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
|
||||
// The presented onboarding flow owns setup-link staging until it dismisses.
|
||||
guard !self.showOnboarding else { return }
|
||||
guard let link = appModel.consumePendingGatewaySetupLink() else { return }
|
||||
self.showOnboarding = false
|
||||
self.presentedSheet = nil
|
||||
self.didAutoOpenSettings = true
|
||||
|
||||
@@ -93,9 +93,10 @@ enum WatchMessageKind: String, Codable, Equatable {
|
||||
case quickReply
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveEvent: Equatable {
|
||||
struct WatchExecApprovalResolveEvent: Codable, Equatable {
|
||||
var replyId: String
|
||||
var approvalId: String
|
||||
var gatewayStableID: String?
|
||||
var decision: OpenClawWatchExecApprovalDecision
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
|
||||
@@ -32,6 +32,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
private let snapshotContextLock = NSLock()
|
||||
private var callbacks = WatchConnectivityTransportCallbacks()
|
||||
|
||||
override init() {
|
||||
@@ -145,7 +146,12 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
}
|
||||
|
||||
do {
|
||||
try session.updateApplicationContext(payload)
|
||||
try self.snapshotContextLock.withLock {
|
||||
let context = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext(
|
||||
payload,
|
||||
merging: session.applicationContext)
|
||||
try session.updateApplicationContext(context)
|
||||
}
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
|
||||
@@ -2,6 +2,11 @@ import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum WatchMessagingPayloadCodec {
|
||||
private static let durableSnapshotTypes = [
|
||||
OpenClawWatchPayloadType.appSnapshot.rawValue,
|
||||
OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||
]
|
||||
|
||||
static let completedChatReplyTextLimit = 4000
|
||||
|
||||
static func nowMs() -> Int {
|
||||
@@ -68,6 +73,9 @@ enum WatchMessagingPayloadCodec {
|
||||
"commandText": item.commandText,
|
||||
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
|
||||
]
|
||||
if let gatewayStableID = nonEmpty(item.gatewayStableID) {
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let commandPreview = nonEmpty(item.commandPreview) {
|
||||
payload["commandPreview"] = commandPreview
|
||||
}
|
||||
@@ -115,6 +123,9 @@ enum WatchMessagingPayloadCodec {
|
||||
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
|
||||
"approvalId": message.approvalId,
|
||||
]
|
||||
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let decision = message.decision {
|
||||
payload["decision"] = decision.rawValue
|
||||
}
|
||||
@@ -135,6 +146,9 @@ enum WatchMessagingPayloadCodec {
|
||||
"approvalId": message.approvalId,
|
||||
"reason": message.reason.rawValue,
|
||||
]
|
||||
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let expiredAtMs = message.expiredAtMs {
|
||||
payload["expiredAtMs"] = expiredAtMs
|
||||
}
|
||||
@@ -148,6 +162,9 @@ enum WatchMessagingPayloadCodec {
|
||||
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||
"approvals": message.approvals.map(self.encodeExecApprovalItem),
|
||||
]
|
||||
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
@@ -206,6 +223,31 @@ enum WatchMessagingPayloadCodec {
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeSnapshotApplicationContext(
|
||||
_ payload: [String: Any],
|
||||
merging existingContext: [String: Any]) -> [String: Any]
|
||||
{
|
||||
guard let payloadType = payload["type"] as? String,
|
||||
self.durableSnapshotTypes.contains(payloadType)
|
||||
else {
|
||||
return payload
|
||||
}
|
||||
|
||||
// updateApplicationContext retains one dictionary. Nest both logical snapshots while
|
||||
// keeping the newest one at the top level for older Watch app versions.
|
||||
var context = payload
|
||||
for snapshotType in self.durableSnapshotTypes {
|
||||
if snapshotType == payloadType {
|
||||
context[snapshotType] = payload
|
||||
} else if let previous = existingContext[snapshotType] as? [String: Any] {
|
||||
context[snapshotType] = previous
|
||||
} else if existingContext["type"] as? String == snapshotType {
|
||||
context[snapshotType] = existingContext
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
static func encodeChatCompletionPayload(
|
||||
_ message: OpenClawWatchChatCompletionMessage) -> [String: Any]
|
||||
{
|
||||
@@ -266,10 +308,12 @@ enum WatchMessagingPayloadCodec {
|
||||
return nil
|
||||
}
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalResolveEvent(
|
||||
replyId: replyId,
|
||||
approvalId: approvalId,
|
||||
gatewayStableID: gatewayStableID,
|
||||
decision: decision,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
|
||||
@@ -23,7 +23,7 @@ struct TerminalHubScreen: View {
|
||||
|
||||
var body: some View {
|
||||
let config = self.appModel.activeGatewayConnectConfig
|
||||
let storedOperatorToken = config == nil ? nil : Self.storedOperatorToken()
|
||||
let storedOperatorToken = Self.storedOperatorToken(config: config)
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
if let url = Self.terminalURL(config: config) {
|
||||
@@ -105,7 +105,9 @@ struct TerminalHubScreen: View {
|
||||
/// (the same mechanism the macOS Dashboard window uses), so the token never
|
||||
/// appears in the page URL, WebKit history, or gateway request logs.
|
||||
static func terminalAuthUserScript(config: GatewayConnectConfig?) -> String? {
|
||||
self.terminalAuthUserScript(config: config, storedOperatorToken: self.storedOperatorToken())
|
||||
self.terminalAuthUserScript(
|
||||
config: config,
|
||||
storedOperatorToken: self.storedOperatorToken(config: config))
|
||||
}
|
||||
|
||||
static func terminalAuthUserScript(
|
||||
@@ -160,9 +162,17 @@ struct TerminalHubScreen: View {
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
private static func storedOperatorToken() -> String? {
|
||||
private static func storedOperatorToken(config: GatewayConnectConfig?) -> String? {
|
||||
guard let config else { return nil }
|
||||
// Endpoint handoffs may explicitly suppress device-token reuse; every auth surface
|
||||
// must honor that boundary or a stale token can override the supplied password.
|
||||
guard config.nodeOptions.allowStoredDeviceAuth else { return nil }
|
||||
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
return DeviceAuthStore.loadToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
gatewayID: gatewayID)?
|
||||
.token
|
||||
}
|
||||
|
||||
@@ -201,7 +211,7 @@ private struct TerminalWebView: UIViewRepresentable {
|
||||
// Ephemeral store: credentials arrive per load via the auth user
|
||||
// script; nothing needs to persist across loads.
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
if let authScript = self.authScript {
|
||||
if let authScript {
|
||||
config.userContentController.addUserScript(WKUserScript(
|
||||
source: authScript,
|
||||
injectionTime: .atDocumentStart,
|
||||
|
||||
@@ -114,6 +114,7 @@ final class TalkModeManager: NSObject {
|
||||
private var realtimeRelayStartInFlight = false
|
||||
private var prefetchedRealtimeSession: TalkRealtimeClientSession?
|
||||
private var realtimePrefetchTask: Task<Void, Never>?
|
||||
private var realtimePrefetchGeneration: UInt64 = 0
|
||||
|
||||
private var lastHeard: Date?
|
||||
private var lastTranscript: String = ""
|
||||
@@ -335,6 +336,7 @@ final class TalkModeManager: NSObject {
|
||||
if self.isEnabled, !self.isSpeaking {
|
||||
self.statusText = "Offline"
|
||||
}
|
||||
self.realtimePrefetchGeneration &+= 1
|
||||
self.realtimePrefetchTask?.cancel()
|
||||
self.realtimePrefetchTask = nil
|
||||
self.prefetchedRealtimeSession = nil
|
||||
@@ -1383,7 +1385,10 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func prefetchRealtimeSessionIfReady(reason: String) async {
|
||||
func prefetchRealtimeSessionIfReady(
|
||||
reason: String,
|
||||
shouldApply: @escaping @MainActor @Sendable () -> Bool = { true }) async
|
||||
{
|
||||
guard self.gatewayConnected,
|
||||
self.realtimeSession == nil,
|
||||
self.realtimeRelaySession == nil,
|
||||
@@ -1395,15 +1400,27 @@ final class TalkModeManager: NSObject {
|
||||
guard self.realtimePrefetchTask == nil else { return }
|
||||
|
||||
GatewayDiagnostics.log("talk.timeline realtime prefetch scheduled reason=\(reason)")
|
||||
self.realtimePrefetchGeneration &+= 1
|
||||
let prefetchGeneration = self.realtimePrefetchGeneration
|
||||
self.realtimePrefetchTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
if self.realtimePrefetchGeneration == prefetchGeneration {
|
||||
self.realtimePrefetchTask = nil
|
||||
}
|
||||
}
|
||||
let startedAt = Self.nowSeconds()
|
||||
do {
|
||||
guard !Task.isCancelled, shouldApply(), let gateway = self.gateway else { return }
|
||||
guard let route = await gateway.currentRoute() else { return }
|
||||
guard !Task.isCancelled, shouldApply() else { return }
|
||||
let session = try await self.createRealtimeClientSession(
|
||||
gateway: gateway,
|
||||
route: route,
|
||||
provider: self.realtimeProvider,
|
||||
model: self.realtimeModelId,
|
||||
voice: self.realtimeVoiceId)
|
||||
guard !Task.isCancelled else { return }
|
||||
guard !Task.isCancelled, shouldApply() else { return }
|
||||
self.prefetchedRealtimeSession = session
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime prefetch ready elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
@@ -1414,24 +1431,24 @@ final class TalkModeManager: NSObject {
|
||||
"talk.timeline realtime prefetch failed elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "error=\(error.localizedDescription)")
|
||||
}
|
||||
self.realtimePrefetchTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func createRealtimeClientSession(
|
||||
gateway: GatewayNodeSession,
|
||||
route: GatewayNodeSessionRoute,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?) async throws -> TalkRealtimeClientSession
|
||||
{
|
||||
guard let gateway else {
|
||||
throw NSError(domain: "TalkMode", code: 8, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway not connected",
|
||||
])
|
||||
}
|
||||
let params = TalkRealtimeClientCreateParams(provider: provider, model: model, voice: voice)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await gateway.request(method: "talk.client.create", paramsJSON: json, timeoutSeconds: 12)
|
||||
let res = try await gateway.request(
|
||||
method: "talk.client.create",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 12,
|
||||
ifCurrentRoute: route)
|
||||
return try JSONDecoder().decode(TalkRealtimeClientSession.self, from: res)
|
||||
}
|
||||
|
||||
@@ -2826,12 +2843,12 @@ extension TalkModeManager {
|
||||
+ "permission=\(self.gatewayTalkPermissionState.statusLabel)")
|
||||
}
|
||||
|
||||
func reloadConfig() async {
|
||||
func reloadConfig(shouldApply: @MainActor @Sendable () -> Bool = { true }) async {
|
||||
guard let gateway else { return }
|
||||
self.pcmFormatUnavailable = false
|
||||
self.prefetchedRealtimeSession = nil
|
||||
do {
|
||||
guard let loaded = try await loadTalkConfig(from: gateway) else { return }
|
||||
guard let loaded = try await loadTalkConfig(from: gateway), shouldApply() else { return }
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: loaded.config,
|
||||
defaultProvider: Self.defaultTalkProvider,
|
||||
@@ -2844,6 +2861,7 @@ extension TalkModeManager {
|
||||
}
|
||||
self.applyLoadedTalkConfig(parsed, redactedFallbackMissingScope: loaded.redactedFallbackMissingScope)
|
||||
} catch {
|
||||
guard shouldApply() else { return }
|
||||
self.applyTalkConfigLoadFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,16 @@ private func agentAction(
|
||||
#expect(DeepLinkParser.parse(url) == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkRejectsInvalidPort() {
|
||||
let url = URL(string: "openclaw://gateway?host=gateway.example.com&port=70000&tls=1")!
|
||||
#expect(DeepLinkParser.parse(url) == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkRejectsMalformedPort() {
|
||||
let url = URL(string: "openclaw://gateway?host=gateway.example.com&port=not-a-port&tls=1")!
|
||||
#expect(DeepLinkParser.parse(url) == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||
let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
@@ -138,6 +148,24 @@ private func agentAction(
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInvalidPort() {
|
||||
let payload = #"{"host":"gateway.example.com","port":70000,"tls":true}"#
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
|
||||
}
|
||||
|
||||
@Test func invalidPortHasNoWebSocketURL() {
|
||||
let link = GatewayConnectDeepLink(
|
||||
host: "gateway.example.com",
|
||||
port: -1,
|
||||
tls: true,
|
||||
bootstrapToken: nil,
|
||||
token: nil,
|
||||
password: nil)
|
||||
|
||||
#expect(link.websocketURL == nil)
|
||||
#expect(!link.isValidEndpoint)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
||||
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
|
||||
@@ -32,33 +32,39 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
|
||||
@Test func parsePromptMapsDefaultNotificationTap() {
|
||||
@Test func `parse prompt maps default notification tap`() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-123",
|
||||
"gatewayDeviceId": "gateway-a",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(
|
||||
approvalId: "approval-123",
|
||||
gatewayDeviceId: "gateway-a"))
|
||||
}
|
||||
|
||||
@Test func parsePromptMapsReviewAction() {
|
||||
@Test func `parse prompt maps review action`() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-456",
|
||||
"gatewayDeviceId": "gateway-b",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456"))
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(
|
||||
approvalId: "approval-456",
|
||||
gatewayDeviceId: "gateway-b"))
|
||||
}
|
||||
|
||||
@Test func parsePromptIgnoresUnexpectedActionIdentifiers() {
|
||||
@Test func `parse prompt ignores unexpected action identifiers`() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: "openclaw.exec-approval.allow-once",
|
||||
userInfo: [
|
||||
@@ -71,7 +77,7 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
|
||||
#expect(prompt == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
|
||||
@Test @MainActor func `handle resolved push removes matching notifications`() async {
|
||||
let center = MockNotificationCenter()
|
||||
center.delivered = [
|
||||
NotificationSnapshot(
|
||||
@@ -80,6 +86,7 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-123",
|
||||
"gatewayDeviceId": "gateway-a",
|
||||
],
|
||||
]),
|
||||
NotificationSnapshot(
|
||||
@@ -87,22 +94,73 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-999",
|
||||
"approvalId": "approval-123",
|
||||
"gatewayDeviceId": "gateway-b",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.resolvedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
],
|
||||
let push = ExecApprovalNotificationPrompt(
|
||||
approvalId: "approval-123",
|
||||
gatewayDeviceId: "gateway-a")
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
for: push,
|
||||
notificationCenter: center)
|
||||
|
||||
#expect(handled)
|
||||
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
|
||||
#expect(center.pendingRemovedIdentifiers == [["exec.approval.gateway-a.approval-123"]])
|
||||
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
|
||||
}
|
||||
|
||||
@Test func `legacy ownerless approval pushes remain parseable for authenticated route validation`() {
|
||||
let userInfo: [AnyHashable: Any] = [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-ownerless",
|
||||
],
|
||||
]
|
||||
|
||||
#expect(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) ==
|
||||
ExecApprovalNotificationPrompt(
|
||||
approvalId: "approval-ownerless",
|
||||
gatewayDeviceId: nil))
|
||||
#expect(ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo))
|
||||
}
|
||||
|
||||
@Test @MainActor func `validated cleanup removes legacy ownerless alerts but preserves other owners`() async {
|
||||
let center = MockNotificationCenter()
|
||||
center.delivered = [
|
||||
NotificationSnapshot(
|
||||
identifier: "legacy-ownerless",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-shared",
|
||||
],
|
||||
]),
|
||||
NotificationSnapshot(
|
||||
identifier: "other-owner",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-shared",
|
||||
"gatewayDeviceId": "gateway-b",
|
||||
],
|
||||
]),
|
||||
]
|
||||
let push = ExecApprovalNotificationPrompt(
|
||||
approvalId: "approval-shared",
|
||||
gatewayDeviceId: "gateway-a")
|
||||
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
for: push,
|
||||
notificationCenter: center,
|
||||
includingLegacyOwnerless: true)
|
||||
|
||||
#expect(center.pendingRemovedIdentifiers == [[
|
||||
"exec.approval.gateway-a.approval-shared",
|
||||
"exec.approval.approval-shared",
|
||||
"exec.approval.legacy.approval-shared",
|
||||
]])
|
||||
#expect(center.deliveredRemovedIdentifiers == [["legacy-ownerless"]])
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -210,6 +210,29 @@ import Testing
|
||||
#expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint")
|
||||
}
|
||||
|
||||
@Test @MainActor func staleTrustAcceptanceReleasesAutoConnectSuppression() async {
|
||||
let host = "gateway-\(UUID().uuidString).example.com"
|
||||
let port = 18789
|
||||
let stableID = "manual|\(host.lowercased())|\(port)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: false,
|
||||
tcpReachabilityProbe: { _, _, _, _ in true },
|
||||
tlsFingerprintProbe: { _ in .fingerprint("abc123") })
|
||||
|
||||
await controller.connectManual(host: host, port: port, useTLS: true)
|
||||
_ = appModel.beginGatewayConnectAttempt()
|
||||
await controller.acceptPendingTrustPrompt()
|
||||
|
||||
#expect(controller.pendingTrustPrompt == nil)
|
||||
#expect(appModel.activeGatewayConnectConfig == nil)
|
||||
#expect(!controller._test_isAutoConnectSuppressed())
|
||||
}
|
||||
|
||||
@Test @MainActor func `manual first use TLS probe skips TLS when TCP is unreachable`() async {
|
||||
let host = "gateway-\(UUID().uuidString).example.com"
|
||||
let port = 18789
|
||||
@@ -284,13 +307,21 @@ import Testing
|
||||
GatewayTLSFingerprintProbeResult.fingerprint("abc123"),
|
||||
.failure(.tlsHandshakeTimeout),
|
||||
])
|
||||
let resolverStarted = AsyncStream<Void>.makeStream()
|
||||
let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream()
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: false,
|
||||
tcpReachabilityProbe: { _, _, _, _ in true },
|
||||
tlsFingerprintProbe: { _ in tlsResults.withLock { $0.removeFirst() } },
|
||||
serviceEndpointResolver: { _ in (host: discoveredHost, port: discoveredPort) })
|
||||
serviceEndpointResolver: { _ in
|
||||
resolverStarted.continuation.yield()
|
||||
for await result in resolverResults.stream {
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
await controller.connectManual(host: staleHost, port: stalePort, useTLS: true)
|
||||
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123")
|
||||
@@ -301,14 +332,67 @@ import Testing
|
||||
tailnetDns: discoveredHost,
|
||||
gatewayPort: discoveredPort,
|
||||
fingerprint: nil)
|
||||
let message = await controller.connectWithDiagnostics(gateway)
|
||||
var startedIterator = resolverStarted.stream.makeAsyncIterator()
|
||||
let connectTask = Task { await controller.connectWithDiagnostics(gateway) }
|
||||
_ = await startedIterator.next()
|
||||
|
||||
#expect(controller.pendingTrustPrompt == nil)
|
||||
resolverResults.continuation.yield((host: discoveredHost, port: discoveredPort))
|
||||
resolverResults.continuation.finish()
|
||||
let message = await connectTask.value
|
||||
|
||||
#expect(message?.contains("TLS fingerprint verification timed out") == true)
|
||||
#expect(message?.contains("\(discoveredHost):\(discoveredPort)") == true)
|
||||
#expect(appModel.gatewayStatusText == message)
|
||||
}
|
||||
|
||||
@Test @MainActor func targetSwitchCancelsSuspendedDiscoveryResolution() async {
|
||||
let defaults = UserDefaults.standard
|
||||
let previousInstanceID = defaults.string(forKey: "node.instanceId")
|
||||
defer {
|
||||
if let previousInstanceID {
|
||||
defaults.set(previousInstanceID, forKey: "node.instanceId")
|
||||
} else {
|
||||
defaults.removeObject(forKey: "node.instanceId")
|
||||
}
|
||||
}
|
||||
defaults.set("ios-test", forKey: "node.instanceId")
|
||||
let resolverStarted = AsyncStream<Void>.makeStream()
|
||||
let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream()
|
||||
let appModel = NodeAppModel()
|
||||
defer { appModel.disconnectGateway() }
|
||||
let controller = GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: false,
|
||||
serviceEndpointResolver: { _ in
|
||||
resolverStarted.continuation.yield()
|
||||
for await result in resolverResults.stream {
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
})
|
||||
let stableID = "discovered|\(UUID().uuidString)"
|
||||
defer { self.clearTLSFingerprint(stableID: stableID) }
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
fingerprint: nil)
|
||||
var startedIterator = resolverStarted.stream.makeAsyncIterator()
|
||||
|
||||
let connectTask = Task { await controller.connectWithDiagnostics(gateway) }
|
||||
_ = await startedIterator.next()
|
||||
controller.cancelPendingConnectionAttempts()
|
||||
resolverResults.continuation.yield((host: "stale.gateway.invalid", port: 443))
|
||||
resolverResults.continuation.finish()
|
||||
let message = await connectTask.value
|
||||
|
||||
#expect(message == nil)
|
||||
#expect(appModel.activeGatewayConnectConfig == nil)
|
||||
#expect(controller.pendingTrustPrompt == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func `clear all TLS fingerprints removes stored pins`() {
|
||||
let stableID1 = "test|\(UUID().uuidString)"
|
||||
let stableID2 = "test|\(UUID().uuidString)"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -94,7 +95,272 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewaySettingsStoreTests {
|
||||
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||
@Test func `credentials stay bound to their gateway`() {
|
||||
let instanceID = "credential-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let firstGatewayID = "manual|first.example.com|443"
|
||||
let secondGatewayID = "manual|second.example.com|443"
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: "first-token",
|
||||
bootstrapToken: nil,
|
||||
password: "first-password",
|
||||
gatewayStableID: firstGatewayID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceID)
|
||||
|
||||
let first = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: firstGatewayID)
|
||||
#expect(first.token == "first-token")
|
||||
#expect(first.password == "first-password")
|
||||
#expect(first.suppressStoredDeviceAuth)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: secondGatewayID) == .empty)
|
||||
|
||||
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: secondGatewayID) == .empty)
|
||||
}
|
||||
|
||||
@Test func `shared tls certificate does not alias distinct routes`() {
|
||||
let instanceID = "tls-owner-\(UUID().uuidString)"
|
||||
let discoveredID = "bonjour|_openclaw._tcp|local|gateway-\(UUID().uuidString)"
|
||||
let manualID = "manual|gateway-\(UUID().uuidString).local|443"
|
||||
let fingerprint = "AA:BB:CC:DD"
|
||||
defer {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID)
|
||||
GatewayTLSStore.clearFingerprint(stableID: discoveredID)
|
||||
GatewayTLSStore.clearFingerprint(stableID: manualID)
|
||||
}
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: "shared-token",
|
||||
bootstrapToken: nil,
|
||||
password: "shared-password",
|
||||
gatewayStableID: discoveredID,
|
||||
suppressStoredDeviceAuth: false,
|
||||
instanceId: instanceID)
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: discoveredID)
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: manualID)
|
||||
|
||||
let manualCredentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: manualID)
|
||||
#expect(manualCredentials == .empty)
|
||||
#expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: discoveredID) == discoveredID)
|
||||
#expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: manualID) == manualID)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID)?.gatewayStableID ==
|
||||
discoveredID)
|
||||
}
|
||||
|
||||
@Test func `ambiguous legacy credentials are discarded`() {
|
||||
let instanceID = "legacy-credential-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let firstGatewayID = "manual|first.example.com|443"
|
||||
let secondGatewayID = "manual|second.example.com|443"
|
||||
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest("legacy-token", instanceId: instanceID)
|
||||
|
||||
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
|
||||
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: firstGatewayID) == .empty)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: secondGatewayID) == .empty)
|
||||
#expect(KeychainStore.loadString(
|
||||
service: gatewayService,
|
||||
account: "gateway-token.\(instanceID)") == nil)
|
||||
#expect(KeychainStore.loadString(
|
||||
service: gatewayService,
|
||||
account: "gateway-credentials.\(instanceID)") == nil)
|
||||
}
|
||||
|
||||
@Test func `proven relay migration does not overwrite a canonical credential bundle`() {
|
||||
let instanceID = "relay-migration-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let gatewayID = "manual|gateway.example.com|443"
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: "current-token",
|
||||
bootstrapToken: "current-bootstrap",
|
||||
password: "current-password",
|
||||
gatewayStableID: gatewayID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceID)
|
||||
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
|
||||
"obsolete-token",
|
||||
instanceId: instanceID)
|
||||
|
||||
#expect(GatewaySettingsStore.migrateProvenRelayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID,
|
||||
token: "stale-relay-token",
|
||||
password: "stale-relay-password"))
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID)
|
||||
#expect(credentials.token == "current-token")
|
||||
#expect(credentials.bootstrapToken == "current-bootstrap")
|
||||
#expect(credentials.password == "current-password")
|
||||
#expect(credentials.suppressStoredDeviceAuth)
|
||||
#expect(KeychainStore.loadString(
|
||||
service: gatewayService,
|
||||
account: "gateway-token.\(instanceID)") == nil)
|
||||
}
|
||||
|
||||
@Test func `proven relay credentials are not reimported after legacy cleanup`() {
|
||||
let instanceID = "completed-relay-migration-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let gatewayID = "manual|gateway.example.com|443"
|
||||
|
||||
#expect(GatewaySettingsStore.migrateProvenRelayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID,
|
||||
token: "stale-relay-token",
|
||||
password: "stale-relay-password"))
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID) == .empty)
|
||||
}
|
||||
|
||||
@Test func `credentialless setup suppresses stored auth until handoff completes`() {
|
||||
let instanceID = "credentialless-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let gatewayID = "manual|gateway.example.com|443"
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
gatewayStableID: gatewayID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceID)
|
||||
|
||||
let pending = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID)
|
||||
#expect(!pending.hasCredentials)
|
||||
#expect(pending.suppressStoredDeviceAuth)
|
||||
|
||||
GatewaySettingsStore.completeGatewayCredentialHandoff(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID) == .empty)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil)
|
||||
}
|
||||
|
||||
@Test func `bootstrap handoff clears bootstrap while enabling stored auth`() {
|
||||
let instanceID = "bootstrap-handoff-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let gatewayID = "manual|gateway.example.com|443"
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: "shared-token",
|
||||
bootstrapToken: "one-time-bootstrap",
|
||||
password: nil,
|
||||
gatewayStableID: gatewayID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceID)
|
||||
|
||||
#expect(GatewaySettingsStore.completeGatewayCredentialHandoff(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID))
|
||||
let completed = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID)
|
||||
#expect(completed.token == "shared-token")
|
||||
#expect(completed.bootstrapToken == nil)
|
||||
#expect(!completed.suppressStoredDeviceAuth)
|
||||
}
|
||||
|
||||
@Test func `field edits preserve pending bootstrap handoff for the same gateway`() {
|
||||
let instanceID = "edited-credential-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let gatewayID = "manual|gateway.example.com|443"
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: nil,
|
||||
bootstrapToken: "bootstrap-token",
|
||||
password: nil,
|
||||
gatewayStableID: gatewayID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceID)
|
||||
GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: "edited-token",
|
||||
password: "edited-password",
|
||||
gatewayStableID: gatewayID,
|
||||
instanceId: instanceID)
|
||||
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID)
|
||||
#expect(credentials.token == "edited-token")
|
||||
#expect(credentials.bootstrapToken == "bootstrap-token")
|
||||
#expect(credentials.password == "edited-password")
|
||||
#expect(credentials.suppressStoredDeviceAuth)
|
||||
}
|
||||
|
||||
@Test func `field edits do not carry pending handoff to another gateway`() {
|
||||
let instanceID = "switched-credential-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let firstGatewayID = "manual|first.example.com|443"
|
||||
let secondGatewayID = "manual|second.example.com|443"
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: "first-token",
|
||||
bootstrapToken: "first-bootstrap-token",
|
||||
password: "first-password",
|
||||
gatewayStableID: firstGatewayID,
|
||||
suppressStoredDeviceAuth: true,
|
||||
instanceId: instanceID)
|
||||
GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: "second-token",
|
||||
password: nil,
|
||||
gatewayStableID: secondGatewayID,
|
||||
instanceId: instanceID)
|
||||
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: firstGatewayID) == .empty)
|
||||
let second = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: secondGatewayID)
|
||||
#expect(second.token == "second-token")
|
||||
#expect(second.bootstrapToken == nil)
|
||||
#expect(second.password == nil)
|
||||
#expect(!second.suppressStoredDeviceAuth)
|
||||
}
|
||||
|
||||
@Test func `clearing ordinary credentials removes their owner metadata`() {
|
||||
let instanceID = "cleared-credential-owner-\(UUID().uuidString)"
|
||||
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
|
||||
let gatewayID = "manual|gateway.example.com|443"
|
||||
|
||||
GatewaySettingsStore.saveGatewayCredentials(
|
||||
token: "one-time-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
gatewayStableID: gatewayID,
|
||||
suppressStoredDeviceAuth: false,
|
||||
instanceId: instanceID)
|
||||
GatewaySettingsStore.updateGatewayCredentials(
|
||||
token: nil,
|
||||
password: nil,
|
||||
gatewayStableID: gatewayID,
|
||||
instanceId: instanceID)
|
||||
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil)
|
||||
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: instanceID,
|
||||
gatewayStableID: gatewayID) == .empty)
|
||||
}
|
||||
|
||||
@Test func `bootstrap copies defaults to keychain when missing`() {
|
||||
withBootstrapSnapshots {
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
@@ -115,7 +381,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||
@Test func `bootstrap copies keychain to defaults when missing`() {
|
||||
withBootstrapSnapshots {
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
@@ -137,7 +403,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
@Test func `last gateway manual round trip`() {
|
||||
withLastGatewaySnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
@@ -150,7 +416,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredOverwritesManual() {
|
||||
@Test func `last gateway discovered overwrites manual`() {
|
||||
withLastGatewaySnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "10.0.0.99",
|
||||
@@ -164,7 +430,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_migratesFromUserDefaults() {
|
||||
@Test func `last gateway migrates from user defaults`() {
|
||||
withLastGatewaySnapshot {
|
||||
// Clear Keychain entry and plant legacy UserDefaults values.
|
||||
applyKeychain([lastGatewayKeychainEntry: nil])
|
||||
@@ -177,7 +443,11 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
])
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
#expect(loaded == .manual(
|
||||
host: "example.org",
|
||||
port: 18789,
|
||||
useTLS: false,
|
||||
stableID: "manual|example.org|18789"))
|
||||
|
||||
// Legacy keys should be cleaned up after migration.
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OpenClawAppDelegateTests {
|
||||
@@ -31,4 +33,57 @@ import Testing
|
||||
|
||||
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
|
||||
}
|
||||
|
||||
@Test @MainActor func `stages a gateway URL when the model is ready`() async throws {
|
||||
OpenClawAppModelRegistry.appModel = nil
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
let model = NodeAppModel()
|
||||
let delegate = OpenClawAppDelegate()
|
||||
delegate.appModel = model
|
||||
let url = try #require(URL(
|
||||
string: "openclaw://gateway?host=gateway.example.com&port=443&tls=1&token=tok"))
|
||||
|
||||
#expect(delegate.application(UIApplication.shared, open: url))
|
||||
let link = await Self.waitForGatewaySetup(in: model)
|
||||
|
||||
#expect(link?.host == "gateway.example.com")
|
||||
#expect(link?.port == 443)
|
||||
#expect(link?.tls == true)
|
||||
#expect(link?.token == "tok")
|
||||
}
|
||||
|
||||
@Test @MainActor func `replays a gateway URL received before the model is ready`() async throws {
|
||||
OpenClawAppModelRegistry.appModel = nil
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
let delegate = OpenClawAppDelegate()
|
||||
let url = try #require(URL(
|
||||
string: "openclaw://gateway?host=gateway.example.com&port=443&tls=1&token=tok"))
|
||||
|
||||
#expect(delegate.application(UIApplication.shared, open: url))
|
||||
|
||||
let model = NodeAppModel()
|
||||
delegate.appModel = model
|
||||
let link = await Self.waitForGatewaySetup(in: model)
|
||||
|
||||
#expect(link?.host == "gateway.example.com")
|
||||
#expect(link?.token == "tok")
|
||||
}
|
||||
|
||||
@Test @MainActor func `rejects an invalid URL`() throws {
|
||||
let delegate = OpenClawAppDelegate()
|
||||
let url = try #require(URL(string: "https://example.com/gateway"))
|
||||
|
||||
#expect(!delegate.application(UIApplication.shared, open: url))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func waitForGatewaySetup(in model: NodeAppModel) async -> GatewayConnectDeepLink? {
|
||||
for _ in 0..<20 {
|
||||
if model.gatewaySetupRequestID > 0 {
|
||||
return model.consumePendingGatewaySetupLink()
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct QRScannerResultHandoffTests {
|
||||
@Test func `queued result is delivered once after dismissal`() async throws {
|
||||
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
|
||||
var deliveredResult: QRScannerResult?
|
||||
|
||||
let scanID = handoff.beginScan()
|
||||
handoff.queue(.setupCode("review-demo"), scanID: scanID)
|
||||
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
|
||||
await task.value
|
||||
|
||||
#expect(deliveredResult == .setupCode("review-demo"))
|
||||
#expect(handoff.processAfterDismissal { _ in } == nil)
|
||||
}
|
||||
|
||||
@Test func `cancel prevents queued delivery`() async throws {
|
||||
let handoff = QRScannerResultHandoff(settlingNanoseconds: 1_000_000_000)
|
||||
var deliveredResult: QRScannerResult?
|
||||
|
||||
let scanID = handoff.beginScan()
|
||||
handoff.queue(.setupCode("review-demo"), scanID: scanID)
|
||||
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
|
||||
handoff.cancel()
|
||||
await task.value
|
||||
|
||||
#expect(deliveredResult == nil)
|
||||
}
|
||||
|
||||
@Test func `beginning another scan clears stale result`() {
|
||||
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
|
||||
|
||||
let staleScanID = handoff.beginScan()
|
||||
handoff.queue(.setupCode("stale"), scanID: staleScanID)
|
||||
handoff.beginScan()
|
||||
|
||||
#expect(handoff.processAfterDismissal { _ in } == nil)
|
||||
}
|
||||
|
||||
@Test func `late result from cancelled scan cannot replace newer input`() async throws {
|
||||
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
|
||||
let staleScanID = handoff.beginScan()
|
||||
handoff.cancel()
|
||||
let currentScanID = handoff.beginScan()
|
||||
var deliveredResult: QRScannerResult?
|
||||
|
||||
#expect(!handoff.queue(.setupCode("stale"), scanID: staleScanID))
|
||||
#expect(handoff.queue(.setupCode("current"), scanID: currentScanID))
|
||||
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
|
||||
await task.value
|
||||
|
||||
#expect(deliveredResult == .setupCode("current"))
|
||||
}
|
||||
|
||||
@Test func `first producer claims the active scan`() async throws {
|
||||
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
|
||||
let scanID = handoff.beginScan()
|
||||
var deliveredResult: QRScannerResult?
|
||||
|
||||
#expect(handoff.queue(.setupCode("camera"), scanID: scanID))
|
||||
#expect(!handoff.isActive(scanID: scanID))
|
||||
#expect(!handoff.queue(.setupCode("photo"), scanID: scanID))
|
||||
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
|
||||
await task.value
|
||||
|
||||
#expect(deliveredResult == .setupCode("camera"))
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewaySetupLinkStagingTests {
|
||||
private static func link() -> GatewayConnectDeepLink {
|
||||
GatewayConnectDeepLink(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
bootstrapToken: "bootstrap",
|
||||
token: "token",
|
||||
password: "password")
|
||||
}
|
||||
|
||||
@Test func `staged link is consumed once`() {
|
||||
var staging = GatewaySetupLinkStaging()
|
||||
let link = Self.link()
|
||||
|
||||
staging.stage(link)
|
||||
|
||||
#expect(staging.take() == link)
|
||||
#expect(staging.take() == nil)
|
||||
}
|
||||
|
||||
@Test func `cancel discards staged credentials`() {
|
||||
var staging = GatewaySetupLinkStaging()
|
||||
staging.stage(Self.link())
|
||||
|
||||
let cancelled = staging.cancel()
|
||||
|
||||
#expect(cancelled)
|
||||
#expect(staging.link == nil)
|
||||
let cancelledAgain = staging.cancel()
|
||||
#expect(!cancelledAgain)
|
||||
}
|
||||
|
||||
@Test func `new setup link replaces the pending candidate`() {
|
||||
var staging = GatewaySetupLinkStaging()
|
||||
let replacement = GatewayConnectDeepLink(
|
||||
host: "replacement.example.com",
|
||||
port: 8443,
|
||||
tls: true,
|
||||
bootstrapToken: nil,
|
||||
token: nil,
|
||||
password: nil)
|
||||
staging.stage(Self.link())
|
||||
staging.stage(replacement)
|
||||
|
||||
#expect(staging.take() == replacement)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct GatewayPendingTargetSuppressionTests {
|
||||
@Test func `new setup target cannot be released by stale scanner dismissal`() {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
var pending = GatewayPendingTargetSuppression()
|
||||
let scannerLease = controller.cancelPendingConnectionAttempts()
|
||||
pending.replace(owner: .qrScanner, lease: scannerLease)
|
||||
let setupLease = controller.cancelPendingConnectionAttempts()
|
||||
pending.replace(owner: .setupLink, lease: setupLease)
|
||||
|
||||
#expect(pending.take(ifOwnedBy: .qrScanner) == nil)
|
||||
let activeLease = pending.take(ifOwnedBy: .setupLink)
|
||||
#expect(activeLease != nil)
|
||||
if let activeLease {
|
||||
controller.releaseAutoConnectSuppression(after: activeLease)
|
||||
}
|
||||
#expect(!controller._test_isAutoConnectSuppressed())
|
||||
}
|
||||
}
|
||||
@@ -705,7 +705,7 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(rootSource.contains("case .settings:"))
|
||||
#expect(rootSource
|
||||
.matches(
|
||||
of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: self\.handleSettingsRouteChange/)
|
||||
of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: handleSettingsRouteChange/)
|
||||
.count >= 1)
|
||||
#expect(rootSource
|
||||
.contains(
|
||||
@@ -727,8 +727,8 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(rootSource.contains("self.selectedSettingsRoute = nil"))
|
||||
#expect(rootSource.contains("self.selectedSidebarDestination = .settings"))
|
||||
#expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId"))
|
||||
#expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange"))
|
||||
#expect(rootSource.contains("navigateToRoute: self.pushSidebarSettingsRoute"))
|
||||
#expect(rootSource.contains("onRouteChange: handleSettingsRouteChange"))
|
||||
#expect(rootSource.contains("navigateToRoute: pushSidebarSettingsRoute"))
|
||||
#expect(rootSource.contains("private func pushSidebarSettingsRoute(_ route: SettingsRoute)"))
|
||||
#expect(rootSource.contains("self.sidebarNavigationPath.append(route)"))
|
||||
#expect(settingsTabSource.contains("let navigateToRoute: ((SettingsRoute) -> Void)?"))
|
||||
@@ -768,12 +768,95 @@ struct RootTabsSourceGuardTests {
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
|
||||
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let scannerSource = try String(contentsOf: Self.qrScannerSourceURL(), encoding: .utf8)
|
||||
let settingsScannerSheet = try Self.extract(
|
||||
settingsSource,
|
||||
from: "isPresented: self.$showQRScanner,",
|
||||
to: ".sheet(isPresented: self.$showNotificationRelayDisclosure)")
|
||||
let settingsOnDismiss = try #require(settingsScannerSheet.range(of: "onDismiss: {"))
|
||||
let settingsProcessing = try #require(settingsScannerSheet.range(of: "self.processQueuedScannerResult()"))
|
||||
let settingsContent = try #require(settingsScannerSheet.range(of: "content: {"))
|
||||
let settingsPendingSetupHandler = try Self.extract(
|
||||
actionsSource,
|
||||
from: "func applyGatewaySetupLink(_ link: GatewayConnectDeepLink)",
|
||||
to: "@discardableResult\n func applySetupCode(attemptID: UUID)")
|
||||
let settingsScannerCancel = try #require(
|
||||
settingsPendingSetupHandler.range(of: "self.scannerResultHandoff.cancel()"))
|
||||
let settingsSetupStaging = try #require(
|
||||
settingsPendingSetupHandler.range(of: "self.stagedGatewaySetupLink = link"))
|
||||
let scannerDelivery = try Self.extract(
|
||||
scannerSource,
|
||||
from: "private func deliver(_ result: QRScannerResult",
|
||||
to: "func dataScanner(_: DataScannerViewController, didRemove")
|
||||
let stopScanning = try #require(scannerDelivery.range(of: "scanner.stopScanning()"))
|
||||
let deliverResult = try #require(scannerDelivery.range(of: "self.parent.onResult(result)"))
|
||||
#expect(scannerSource.contains("static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000"))
|
||||
let activeProblemToast = try Self.extract(
|
||||
rootSource,
|
||||
from: "private var activeGatewayProblemToast: GatewayConnectionProblem?",
|
||||
to: "private var gatewayToastAnimation: Animation?")
|
||||
let gatewaySetupSource = try Self.extract(
|
||||
rootSource,
|
||||
from: "private func maybeOpenSettingsForGatewaySetup()",
|
||||
to: "private func maybeRequestLocalNetworkAccess")
|
||||
let consumedGatewaySetup = try #require(
|
||||
gatewaySetupSource.range(of: "appModel.consumePendingGatewaySetupLink()"))
|
||||
let onboardingSetupOwnerGuard = try #require(
|
||||
gatewaySetupSource.range(of: "guard !self.showOnboarding else { return }"))
|
||||
let deliveredGatewaySetup = try #require(
|
||||
gatewaySetupSource.range(of: "self.gatewaySetupRequest = GatewaySetupRequest"))
|
||||
let pendingSetupHandler = try Self.extract(
|
||||
onboardingSource,
|
||||
from: "private func applyPendingGatewaySetupLinkIfNeeded()",
|
||||
to: "private func connectStagedGatewaySetupLink()")
|
||||
let stagedSetupConnect = try Self.extract(
|
||||
onboardingSource,
|
||||
from: "private func connectStagedGatewaySetupLink()",
|
||||
to: "private func clearStagedGatewaySetupLink()")
|
||||
let stagedValidation = try #require(stagedSetupConnect.range(of: "guard link.isValidEndpoint"))
|
||||
let stagedConsumption = try #require(stagedSetupConnect.range(of: "self.setupLinkStaging.take()"))
|
||||
let stagedReset = try #require(
|
||||
stagedSetupConnect.range(of: "await self.appModel.resetGatewaySessionsForTargetSwitch()"))
|
||||
let backgroundReconnect = try Self.extract(
|
||||
modelSource,
|
||||
from: "private func performBackgroundAliveBeaconIfNeeded(",
|
||||
to: "private func publishBackgroundAliveBeacon(")
|
||||
let disconnectGateway = try Self.extract(
|
||||
modelSource,
|
||||
from: "func disconnectGateway()",
|
||||
to: "private func disableGatewayAutoReconnect()")
|
||||
let operatorGatewayLoop = try Self.extract(
|
||||
modelSource,
|
||||
from: "private func startOperatorGatewayLoop(",
|
||||
to: "private func startNodeGatewayLoop(")
|
||||
let nodeGatewayLoop = try Self.extract(
|
||||
modelSource,
|
||||
from: "private func startNodeGatewayLoop(",
|
||||
to: "private func makeOperatorConnectOptions(")
|
||||
let wakeWordRefresh = try Self.extract(
|
||||
modelSource,
|
||||
from: "private func refreshWakeWordsFromGateway(",
|
||||
to: "private func isGatewayHealthMonitorDisabled()")
|
||||
let onboardingGatewayLink = try Self.extract(
|
||||
onboardingSource,
|
||||
from: "private func applyGatewayLink(",
|
||||
to: "private func handleScannedSetupCode(")
|
||||
let settingsGatewayLink = try Self.extract(
|
||||
actionsSource,
|
||||
from: "func applyGatewayLink(",
|
||||
to: "func openGatewayQRScanner()")
|
||||
let onboardingManualConnect = try Self.extract(
|
||||
onboardingSource,
|
||||
from: "private func connectCurrentManualGateway(",
|
||||
to: "private func retryLastAttempt(")
|
||||
let settingsManualConnect = try Self.extract(
|
||||
actionsSource,
|
||||
from: "func connectManual(setupAttemptID: UUID? = nil) async",
|
||||
to: "func preflightGateway(host: String)")
|
||||
|
||||
#expect(sectionsSource.contains("var gatewayDestination: some View"))
|
||||
#expect(sectionsSource.contains("self.gatewayActions"))
|
||||
@@ -798,7 +881,7 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(rootSource.contains(".gesture(self.gatewayToastSwipeGesture)"))
|
||||
// Operator auth/pairing problems can coexist with a connected node, so the
|
||||
// root's only remediation surface must not depend on aggregate status.
|
||||
#expect(activeProblemToast.contains("self.appModel.lastGatewayProblem"))
|
||||
#expect(activeProblemToast.contains("appModel.lastGatewayProblem"))
|
||||
#expect(!activeProblemToast.contains("gatewayStatus"))
|
||||
// Every problem report re-surfaces a swiped-away toast or shakes the
|
||||
// visible one; value equality alone must not keep the toast hidden.
|
||||
@@ -818,6 +901,10 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(settingsSource.contains("self.resetOnboarding()"))
|
||||
#expect(settingsSource.contains(".onChange(of: self.onboardingRequestID)"))
|
||||
#expect(settingsSource.contains("self.syncAfterOnboardingReset()"))
|
||||
#expect(settingsSource.contains("let acceptsGatewaySetupRequests: Bool"))
|
||||
#expect(settingsSource.contains("guard self.acceptsGatewaySetupRequests else { return }"))
|
||||
#expect(settingsSource.contains(".onChange(of: self.acceptsGatewaySetupRequests)"))
|
||||
#expect(rootSource.matches(of: /acceptsGatewaySetupRequests: !self\.showOnboarding/).count == 2)
|
||||
#expect(actionsSource.contains("func syncAfterOnboardingReset()"))
|
||||
#expect(actionsSource.contains("self.pendingManualAuthOverride = nil"))
|
||||
// The root toast is the only gateway problem surface outside covers, so it
|
||||
@@ -829,11 +916,223 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(rootSource.contains("await self.gatewayController.connectLastKnown()"))
|
||||
|
||||
#expect(rootSource.contains("GatewayProblemDetailsSheet("))
|
||||
#expect(onboardingSetupOwnerGuard.lowerBound < consumedGatewaySetup.lowerBound)
|
||||
#expect(consumedGatewaySetup.lowerBound < deliveredGatewaySetup.lowerBound)
|
||||
#expect(settingsSource.contains("QRScannerView("))
|
||||
#expect(settingsOnDismiss.lowerBound < settingsProcessing.lowerBound)
|
||||
#expect(settingsProcessing.lowerBound < settingsContent.lowerBound)
|
||||
#expect(settingsPendingSetupHandler.contains("self.showQRScanner = false"))
|
||||
#expect(settingsScannerCancel.lowerBound < settingsSetupStaging.lowerBound)
|
||||
#expect(settingsPendingSetupHandler.contains(
|
||||
"self.gatewayController.cancelPendingConnectionAttempts()"))
|
||||
#expect(!settingsSource.contains(".onChange(of: self.showQRScanner)"))
|
||||
#expect(actionsSource.contains("case let .gatewayLink(link):"))
|
||||
#expect(actionsSource.contains("case let .setupCode(code):"))
|
||||
#expect(stopScanning.lowerBound < deliverResult.lowerBound)
|
||||
#expect(trustSource.contains("Trust this gateway?"))
|
||||
#expect(trustSource.contains("Trust and connect"))
|
||||
#expect(trustSource.contains("let isEnabled: Bool"))
|
||||
#expect(rootSource.contains(".gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)"))
|
||||
#expect(onboardingSource.contains(".gatewayTrustPromptAlert()"))
|
||||
#expect(onboardingSource.contains("self.applyPendingGatewaySetupLinkIfNeeded()"))
|
||||
#expect(onboardingSource.contains(".onChange(of: self.appModel.gatewaySetupRequestID)"))
|
||||
#expect(onboardingSource.contains("self.appModel.consumePendingGatewaySetupLink()"))
|
||||
#expect(onboardingSource.contains("self.scannerResultHandoff.cancel()"))
|
||||
#expect(!onboardingSource.contains("pendingScannerResult"))
|
||||
#expect(onboardingSource.contains("self.setupLinkStaging.stage(link)"))
|
||||
#expect(pendingSetupHandler.contains("self.gatewayController.cancelPendingConnectionAttempts()"))
|
||||
#expect(pendingSetupHandler.contains("if self.selectedMode == nil"))
|
||||
#expect(onboardingSource.contains("Tap Connect to apply."))
|
||||
#expect(onboardingSource.contains("self.connectStagedGatewaySetupLink()"))
|
||||
#expect(onboardingSource.contains("Credentials are applied only after you tap Connect."))
|
||||
#expect(onboardingSource.contains("Plaintext (local network)"))
|
||||
#expect(onboardingSource.contains("self.statusLine = message"))
|
||||
#expect(!pendingSetupHandler.contains("self.manualHost ="))
|
||||
#expect(!pendingSetupHandler.contains("self.manualPort ="))
|
||||
#expect(!pendingSetupHandler.contains("self.manualTLS ="))
|
||||
#expect(!pendingSetupHandler.contains("self.applyGatewayLink(link)"))
|
||||
#expect(!pendingSetupHandler.contains("self.handleScannedLink(link)"))
|
||||
#expect(!pendingSetupHandler.contains("self.connectManual()"))
|
||||
#expect(stagedValidation.lowerBound < stagedConsumption.lowerBound)
|
||||
#expect(stagedReset.lowerBound < stagedConsumption.lowerBound)
|
||||
#expect(!stagedSetupConnect.contains("self.appModel.disconnectGateway()"))
|
||||
#expect(stagedSetupConnect.contains(
|
||||
"self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)"))
|
||||
#expect(stagedSetupConnect.contains("guard self.connectingGatewayID == nil else { return }"))
|
||||
#expect(onboardingSource.contains("self.setupLinkStaging.link == nil else { return }"))
|
||||
#expect(onboardingGatewayLink.contains("self.gatewayToken = setupAuth.token"))
|
||||
#expect(onboardingGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
|
||||
#expect(settingsGatewayLink.contains("self.gatewayToken = setupAuth.token"))
|
||||
#expect(settingsGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
|
||||
#expect(onboardingManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
|
||||
#expect(onboardingManualConnect.contains("self.pendingManualAuthOverride = nil"))
|
||||
#expect(onboardingManualConnect.contains("targetStableID: stableID"))
|
||||
#expect(settingsManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
|
||||
#expect(settingsManualConnect.contains("self.pendingManualAuthOverride = nil"))
|
||||
#expect(settingsManualConnect.contains("targetStableID: stableID"))
|
||||
#expect(!controllerSource.contains("shouldApplyTokenField"))
|
||||
#expect(!controllerSource.contains("shouldApplyPasswordField"))
|
||||
#expect(controllerSource.contains("allowStoredDeviceAuth: !suppressStoredDeviceAuth"))
|
||||
#expect(controllerSource.contains(
|
||||
"deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID("))
|
||||
#expect(controllerSource.contains("DeviceAuthStore.migrateUnscopedToken("))
|
||||
#expect(controllerSource.contains("DeviceAuthStore.discardUnscopedTokens("))
|
||||
#expect(onboardingSource.contains(
|
||||
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
|
||||
#expect(actionsSource.contains(
|
||||
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
|
||||
#expect(onboardingSource.contains(
|
||||
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
|
||||
#expect(actionsSource.contains(
|
||||
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
|
||||
#expect(disconnectGateway.contains("self.beginGatewaySessionReset(chainingAfterExisting: true)"))
|
||||
#expect(!disconnectGateway.contains("Task {"))
|
||||
#expect(modelSource.contains(
|
||||
"private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool"))
|
||||
#expect(modelSource.matches(
|
||||
of: /self\.isCurrentGatewayRoute\(generation: routeGeneration, stableID: stableID\)/).count >= 2)
|
||||
#expect(operatorGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"operator_loop\")"))
|
||||
#expect(nodeGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"node_loop\")"))
|
||||
#expect(modelSource.contains("refreshWakeWordsFromGateway(shouldApply: shouldContinue)"))
|
||||
#expect(wakeWordRefresh.matches(of: /guard shouldApply\(\) else \{ return \}/).count >= 2)
|
||||
#expect(modelSource.contains("if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused"))
|
||||
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
#expect(controllerSource.contains("allowAutoReconnect: false"))
|
||||
#expect(controllerSource.contains("guard allowAutoReconnect else { return }"))
|
||||
#expect(controllerSource.contains("guard self.autoConnectSuppressionGeneration == nil else { return }"))
|
||||
#expect(backgroundReconnect.contains("let generation = self.gatewayConnectGeneration"))
|
||||
#expect(backgroundReconnect.contains("await self.resetGatewaySessionsForForcedReconnect()"))
|
||||
#expect(backgroundReconnect.contains("expectedGeneration: generation"))
|
||||
#expect(modelSource.contains("expectedGeneration: UInt64)"))
|
||||
#expect(!modelSource.contains("expectedGeneration: UInt64?"))
|
||||
}
|
||||
|
||||
@Test func `gateway credential fields update before endpoint persistence is available`() throws {
|
||||
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
for source in [onboardingSource, settingsSource] {
|
||||
let tokenSetter = try Self.extract(
|
||||
source,
|
||||
from: "func persistGatewayToken(_ value: String)",
|
||||
to: "func persistGatewayPassword(_ value: String)")
|
||||
let passwordSetter = try Self.extract(
|
||||
source,
|
||||
from: "func persistGatewayPassword(_ value: String)",
|
||||
to: "func clearManualCredentialFields()")
|
||||
let tokenAssignment = try #require(tokenSetter.range(of: "self.gatewayToken = value"))
|
||||
let tokenEndpointGuard = try #require(
|
||||
tokenSetter.range(of: "let stableID = self.gatewayCredentialTargetStableID"))
|
||||
let passwordAssignment = try #require(passwordSetter.range(of: "self.gatewayPassword = value"))
|
||||
let passwordEndpointGuard = try #require(
|
||||
passwordSetter.range(of: "let stableID = self.gatewayCredentialTargetStableID"))
|
||||
|
||||
#expect(tokenAssignment.lowerBound < tokenEndpointGuard.lowerBound)
|
||||
#expect(passwordAssignment.lowerBound < passwordEndpointGuard.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `onboarding mode defaults clear credentials after endpoint changes`() throws {
|
||||
let source = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
|
||||
let modeDefaults = try Self.extract(
|
||||
source,
|
||||
from: "private func applyModeDefaults(_ mode: OnboardingConnectionMode)",
|
||||
to: "private func gatewayHasResolvableHost")
|
||||
|
||||
#expect(modeDefaults.contains("let previousStableID = self.currentManualGatewayStableID"))
|
||||
#expect(modeDefaults.contains("previousStableID != self.currentManualGatewayStableID"))
|
||||
#expect(modeDefaults.contains("self.clearManualCredentialFields()"))
|
||||
}
|
||||
|
||||
@Test func `watch snapshot bundle applies owner before approvals and clears old chat`() throws {
|
||||
let receiverSource = try String(contentsOf: Self.watchConnectivityReceiverSourceURL(), encoding: .utf8)
|
||||
let storeSource = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
|
||||
let consumePayload = try Self.extract(
|
||||
receiverSource,
|
||||
from: "private func consumeIncomingPayload(_ payload: [String: Any], transport: String)",
|
||||
to: "}\n}")
|
||||
let appSnapshotConsume = try #require(
|
||||
consumePayload.range(of: "self.store.consume(appSnapshot: appSnapshot)"))
|
||||
let approvalSnapshotConsume = try #require(
|
||||
consumePayload.range(of: "self.store.consume(execApprovalSnapshot: execApprovalSnapshot"))
|
||||
let consumeAppSnapshot = try Self.extract(
|
||||
storeSource,
|
||||
from: "func consume(appSnapshot message: WatchAppSnapshotMessage)",
|
||||
to: "func markAppSnapshotRequestStarted()")
|
||||
|
||||
#expect(appSnapshotConsume.lowerBound < approvalSnapshotConsume.lowerBound)
|
||||
#expect(consumeAppSnapshot.contains("if hasExistingAppSnapshot, previousGatewayID == nextGatewayID"))
|
||||
let ownerMatchedMerge = try Self.extract(
|
||||
consumeAppSnapshot,
|
||||
from: "if hasExistingAppSnapshot, previousGatewayID == nextGatewayID",
|
||||
to: "self.appSnapshot = merged")
|
||||
#expect(ownerMatchedMerge.contains("merged.chatItems = self.appSnapshot?.chatItems"))
|
||||
#expect(ownerMatchedMerge.contains("merged.chatStatusText = self.appSnapshot?.chatStatusText"))
|
||||
}
|
||||
|
||||
@Test func `watch generic prompts wait for the active gateway owner`() throws {
|
||||
let receiverSource = try String(contentsOf: Self.watchConnectivityReceiverSourceURL(), encoding: .utf8)
|
||||
let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
|
||||
let consumeMessage = try Self.extract(
|
||||
source,
|
||||
from: "func consume(message: WatchNotifyMessage, transport: String)",
|
||||
to: "func consume(\n execApprovalPrompt")
|
||||
let consumeAppSnapshot = try Self.extract(
|
||||
source,
|
||||
from: "func consume(appSnapshot message: WatchAppSnapshotMessage)",
|
||||
to: "func markAppSnapshotRequestStarted()")
|
||||
|
||||
let replay = try Self.extract(
|
||||
source,
|
||||
from: "func replayDeferredGatewayPayloads()",
|
||||
to: "private func clearMessagePrompt()")
|
||||
let routeGatewayPayload = try Self.extract(
|
||||
source,
|
||||
from: "private func routeGatewayPayload(_ payload: DeferredGatewayPayload)",
|
||||
to: "private func acceptsGatewayOwner")
|
||||
let acceptsGatewayOwner = try Self.extract(
|
||||
source,
|
||||
from: "private func acceptsGatewayOwner(_ gatewayStableID: String?)",
|
||||
to: "func replayDeferredGatewayPayloads()")
|
||||
|
||||
#expect(consumeMessage.contains("self.routeGatewayPayload(.notification"))
|
||||
#expect(consumeAppSnapshot.contains("self.clearMessagePrompt()"))
|
||||
#expect(consumeAppSnapshot.contains("if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID"))
|
||||
#expect(source.contains("private var deferredGatewayPayloads: [DeferredGatewayPayload]"))
|
||||
#expect(routeGatewayPayload.contains("guard let activeSnapshot = appSnapshot else { return true }"))
|
||||
#expect(acceptsGatewayOwner.contains("guard let activeSnapshot = appSnapshot else { return true }"))
|
||||
#expect(acceptsGatewayOwner.contains("else { return false }"))
|
||||
#expect(replay.contains("WatchDeferredPayloadOrdering.indicesOldestFirst"))
|
||||
#expect(replay.contains("WatchDeferredPayloadOrdering.isExpired"))
|
||||
#expect(replay.contains("WatchDeferredPayloadOrdering.isNewerThanSnapshot"))
|
||||
#expect(replay.contains("WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot"))
|
||||
#expect(replay.contains("case let .notification(message, transport):"))
|
||||
#expect(replay.contains("approvalSnapshotGatewayID == activeGatewayID"))
|
||||
#expect(replay.contains("payload.isFullyRepresentedByExecApprovalSnapshot"))
|
||||
#expect(replay.contains("let approval = payload.approvalPrompt"))
|
||||
#expect(source.contains("if hasSameSnapshotOwner"))
|
||||
#expect(source.contains("if let sentAtMs = message.sentAtMs"))
|
||||
#expect(receiverSource.contains("self.store.replayDeferredGatewayPayloads()"))
|
||||
}
|
||||
|
||||
@Test func `watch approval notifications include their gateway owner`() throws {
|
||||
let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
|
||||
let identifier = try Self.extract(
|
||||
source,
|
||||
from: "private static func execApprovalNotificationIdentifier(",
|
||||
to: "private func pruneExpiredExecApprovals")
|
||||
let routeChange = try Self.extract(
|
||||
source,
|
||||
from: "func consume(appSnapshot message: WatchAppSnapshotMessage)",
|
||||
to: "func markAppSnapshotRequestStarted()")
|
||||
|
||||
#expect(identifier.contains("gatewayStableID.utf8.count"))
|
||||
#expect(identifier.contains("gatewayStableID)\\(approvalID)"))
|
||||
#expect(routeChange.contains("removeExecApprovalNotifications(approvals: invalidatedApprovals)"))
|
||||
#expect(!source.contains("identifier: \"watch.execApproval.\\(message.approval.id)\""))
|
||||
#expect(source.contains("let ownerlessApprovals = state.execApprovals.filter"))
|
||||
#expect(source.contains("self.lastExecApprovalSnapshotID = nil"))
|
||||
#expect(source.contains("\"watch.execApproval.\\(approvalID)\""))
|
||||
}
|
||||
|
||||
@Test func `setup route probes yield to newer manual actions`() throws {
|
||||
@@ -872,13 +1171,29 @@ struct RootTabsSourceGuardTests {
|
||||
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
let onboardingScannerSheet = try Self.extract(
|
||||
onboardingSource,
|
||||
from: "isPresented: self.$showQRScanner,",
|
||||
to: ".sheet(isPresented: self.$showGatewayProblemDetails)")
|
||||
let onboardingOnDismiss = try #require(onboardingScannerSheet.range(of: "onDismiss: {"))
|
||||
let onboardingProcessing = try #require(onboardingScannerSheet.range(of: "self.processQueuedScannerResult()"))
|
||||
let onboardingContent = try #require(onboardingScannerSheet.range(of: "content: {"))
|
||||
|
||||
#expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true"))
|
||||
#expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)"))
|
||||
#expect(appSource.contains("func application(\n _ app: UIApplication,\n open url: URL,"))
|
||||
#expect(appSource.contains("self.pendingOpenURLs.append(url)"))
|
||||
#expect(appSource.contains("model.stageGatewaySetupLink(link)"))
|
||||
#expect(appSource.contains(".onOpenURL"))
|
||||
#expect(appSource.contains("self.appDelegate.handleOpenURL(url, model: self.appModel)"))
|
||||
#expect(controllerSource.contains(
|
||||
"func requestLocalNetworkAccess(reason: String, allowAutoReconnect: Bool = true)"))
|
||||
#expect(controllerSource.contains("guard self.localNetworkAccessRequested else"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")"))
|
||||
#expect(controllerSource.contains(
|
||||
"self.requestLocalNetworkAccess(reason: \"connect_manual\", allowAutoReconnect: false)"))
|
||||
#expect(controllerSource.contains(
|
||||
"self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\", allowAutoReconnect: false)"))
|
||||
#expect(controllerSource.contains(
|
||||
"self.requestLocalNetworkAccess(reason: \"connect_last_known\", allowAutoReconnect: false)"))
|
||||
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")"))
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")"))
|
||||
@@ -889,6 +1204,12 @@ struct RootTabsSourceGuardTests {
|
||||
|
||||
#expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")"))
|
||||
#expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")"))
|
||||
#expect(onboardingSource.contains(
|
||||
"self.applyPendingGatewaySetupLinkIfNeeded()\n self.attemptAutomaticPairingResumeIfNeeded()"))
|
||||
#expect(onboardingOnDismiss.lowerBound < onboardingProcessing.lowerBound)
|
||||
#expect(onboardingProcessing.lowerBound < onboardingContent.lowerBound)
|
||||
#expect(!onboardingSource.contains(".onChange(of: self.showQRScanner)"))
|
||||
#expect(onboardingSource.matches(of: /self\.showQRScanner = true/).count == 1)
|
||||
#expect(actionsSource
|
||||
.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
}
|
||||
@@ -913,14 +1234,49 @@ struct RootTabsSourceGuardTests {
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
let transportSource = try String(contentsOf: Self.iOSGatewayChatTransportSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
|
||||
#expect(appModelSource.contains("ifCurrentRoute: operatorRoute"))
|
||||
#expect(transportSource.matches(of: /ifCurrentRoute: expectedRoute/).count == 3)
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
#expect(channelsSource.contains("label: \"ClickClack\""))
|
||||
#expect(channelsSource.contains("Self-hosted chat bot routing."))
|
||||
}
|
||||
|
||||
@Test func `deferred gateway mutations retain their source gateway`() throws {
|
||||
let source = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
let pendingActions = try Self.extract(
|
||||
source,
|
||||
from: "private func resumePendingForegroundNodeActionsIfNeeded(",
|
||||
to: "private func handleWatchQuickReply(")
|
||||
let resolvedState = try Self.extract(
|
||||
source,
|
||||
from: "private func handleExecApprovalResolvedForCurrentGateway(",
|
||||
to: "func handleExecApprovalResolvedRemotePush(")
|
||||
let resolvedPushes = try Self.extract(
|
||||
source,
|
||||
from: "func handleExecApprovalResolvedRemotePush(",
|
||||
to: "func handleSilentPushWake(")
|
||||
|
||||
#expect(pendingActions.contains("ifCurrentRoute: nodeRoute"))
|
||||
#expect(pendingActions.contains("ifCurrentRoute: expectedRoute"))
|
||||
#expect(pendingActions.contains("isCurrentGatewaySessionRoute"))
|
||||
#expect(pendingActions.contains("pendingForegroundActionDrainRequested = true"))
|
||||
#expect(pendingActions.contains("trigger: \"coalesced\""))
|
||||
#expect(pendingActions.contains("pendingForegroundActionDrainInFlight = false"))
|
||||
#expect(pendingActions.contains("completedPendingForegroundActionIDsByGateway"))
|
||||
#expect(pendingActions.contains("presentIn: decoded.actions"))
|
||||
#expect(pendingActions.contains("let currentRoute = await self.nodeGateway.currentRoute()"))
|
||||
#expect(pendingActions.contains("ifCurrentRoute: expectedRoute"))
|
||||
#expect(resolvedState.matches(of: /canApplyExecApprovalResolvedState/).count >= 4)
|
||||
#expect(resolvedState.contains("routeContext: routeContext"))
|
||||
#expect(resolvedPushes.contains("applyValidatedExecApprovalResolvedPush(push, context: context)"))
|
||||
#expect(resolvedPushes.contains("session: self.operatorGateway"))
|
||||
#expect(resolvedPushes.contains("generation: context.routeGeneration"))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
@@ -935,6 +1291,13 @@ struct RootTabsSourceGuardTests {
|
||||
.appendingPathComponent("Sources/Model/NodeAppModel.swift")
|
||||
}
|
||||
|
||||
private static func iOSGatewayChatTransportSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Chat/IOSGatewayChatTransport.swift")
|
||||
}
|
||||
|
||||
private static func phoneHubSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
@@ -1099,6 +1462,13 @@ struct RootTabsSourceGuardTests {
|
||||
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
|
||||
}
|
||||
|
||||
private static func qrScannerSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Onboarding/QRScannerView.swift")
|
||||
}
|
||||
|
||||
private static func openClawAppSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
@@ -1141,6 +1511,20 @@ struct RootTabsSourceGuardTests {
|
||||
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
|
||||
}
|
||||
|
||||
private static func watchConnectivityReceiverSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("WatchApp/Sources/WatchConnectivityReceiver.swift")
|
||||
}
|
||||
|
||||
private static func watchInboxStoreSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("WatchApp/Sources/WatchInboxStore.swift")
|
||||
}
|
||||
|
||||
private static func channelsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
@@ -7,7 +7,9 @@ struct TerminalHubScreenTests {
|
||||
private static func makeConfig(
|
||||
url: URL,
|
||||
token: String? = nil,
|
||||
password: String? = nil) -> GatewayConnectConfig
|
||||
password: String? = nil,
|
||||
allowStoredDeviceAuth: Bool = true,
|
||||
deviceAuthGatewayID: String? = nil) -> GatewayConnectConfig
|
||||
{
|
||||
GatewayConnectConfig(
|
||||
url: url,
|
||||
@@ -24,7 +26,9 @@ struct TerminalHubScreenTests {
|
||||
permissions: [:],
|
||||
clientId: "ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "Phone"))
|
||||
clientDisplayName: "Phone",
|
||||
allowStoredDeviceAuth: allowStoredDeviceAuth,
|
||||
deviceAuthGatewayID: deviceAuthGatewayID))
|
||||
}
|
||||
|
||||
@Test func `terminal URL flips scheme and carries only view parameter`() throws {
|
||||
@@ -77,6 +81,55 @@ struct TerminalHubScreenTests {
|
||||
#expect(script?.contains("\"token\":\"stored-token\"") == true)
|
||||
}
|
||||
|
||||
@Test func `auth user script loads the active gateway scoped operator token`() throws {
|
||||
let gatewayID = "manual|terminal-\(UUID().uuidString)|443"
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
defer {
|
||||
DeviceAuthStore.clearToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
gatewayID: gatewayID)
|
||||
}
|
||||
#expect(DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "scoped-terminal-token",
|
||||
gatewayID: gatewayID).token == "scoped-terminal-token")
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "wss://gateway.example.com:8443")),
|
||||
deviceAuthGatewayID: gatewayID)
|
||||
|
||||
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
|
||||
|
||||
#expect(script?.contains("\"token\":\"scoped-terminal-token\"") == true)
|
||||
}
|
||||
|
||||
@Test func `auth user script honors stored device auth suppression`() throws {
|
||||
let gatewayID = "manual|terminal-suppressed-\(UUID().uuidString)|443"
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
defer {
|
||||
DeviceAuthStore.clearToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
gatewayID: gatewayID)
|
||||
}
|
||||
#expect(DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "stale-terminal-token",
|
||||
gatewayID: gatewayID).token == "stale-terminal-token")
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "wss://gateway.example.com:8443")),
|
||||
password: "replacement-password",
|
||||
allowStoredDeviceAuth: false,
|
||||
deviceAuthGatewayID: gatewayID)
|
||||
|
||||
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
|
||||
|
||||
#expect(script?.contains("stale-terminal-token") == false)
|
||||
#expect(script?.contains("\"password\":\"replacement-password\"") == true)
|
||||
}
|
||||
|
||||
@Test func `web content identity changes with stored operator token`() throws {
|
||||
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Testing
|
||||
|
||||
struct WatchDeferredPayloadOrderingTests {
|
||||
@Test func `expired payload is not replayable`() {
|
||||
#expect(WatchDeferredPayloadOrdering.isExpired(expiresAtMs: 100, nowMs: 100))
|
||||
#expect(!WatchDeferredPayloadOrdering.isExpired(expiresAtMs: 101, nowMs: 100))
|
||||
#expect(!WatchDeferredPayloadOrdering.isExpired(expiresAtMs: nil, nowMs: 100))
|
||||
}
|
||||
|
||||
@Test func `ownerless snapshot retains only payloads it cannot supersede`() {
|
||||
#expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: 201, snapshotSentAtMs: 200))
|
||||
#expect(!WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: 200, snapshotSentAtMs: 200))
|
||||
#expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: nil, snapshotSentAtMs: 200))
|
||||
}
|
||||
|
||||
@Test func `snapshot freshness treats an undated payload as preexisting`() {
|
||||
#expect(WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: 100, snapshotSentAtMs: 100))
|
||||
#expect(!WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: 101, snapshotSentAtMs: 100))
|
||||
#expect(WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: nil, snapshotSentAtMs: 100))
|
||||
}
|
||||
|
||||
@Test func `replays reversed deliveries in event order`() {
|
||||
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [200, 100]) == [1, 0])
|
||||
}
|
||||
|
||||
@Test func `replays missing timestamps first in receipt order`() {
|
||||
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1])
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,13 @@ struct OpenClawWatchApp: App {
|
||||
self.inboxStore.markReplyResult(result, actionLabel: action.label)
|
||||
}
|
||||
},
|
||||
onExecApprovalDecision: { approvalId, decision in
|
||||
onExecApprovalDecision: { approvalId, gatewayStableID, decision in
|
||||
guard let receiver = self.receiver else { return }
|
||||
self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision)
|
||||
Task { @MainActor in
|
||||
let result = await receiver.sendExecApprovalResolve(
|
||||
approvalId: approvalId,
|
||||
gatewayStableID: gatewayStableID,
|
||||
decision: decision)
|
||||
self.inboxStore.markExecApprovalSendResult(
|
||||
approvalId: approvalId,
|
||||
@@ -144,10 +145,11 @@ struct OpenClawWatchApp: App {
|
||||
extension WatchInboxStore {
|
||||
fileprivate func configureScreenshotFixture() {
|
||||
let sentAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.greetingTextOverride = "Good morning"
|
||||
greetingTextOverride = "Good morning"
|
||||
self.consume(
|
||||
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
|
||||
approvals: [],
|
||||
gatewayStableID: "watch-screenshot-gateway",
|
||||
sentAtMs: sentAtMs,
|
||||
snapshotId: nil),
|
||||
transport: "screenshot")
|
||||
|
||||
@@ -130,6 +130,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
|
||||
func sendExecApprovalResolve(
|
||||
approvalId: String,
|
||||
gatewayStableID: String?,
|
||||
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
|
||||
{
|
||||
await self.ensureActivated()
|
||||
@@ -144,6 +145,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
let payload = Self.encodeExecApprovalResolvePayload(
|
||||
WatchExecApprovalResolveMessage(
|
||||
approvalId: approvalId,
|
||||
gatewayStableID: gatewayStableID,
|
||||
decision: decision,
|
||||
replyId: UUID().uuidString,
|
||||
sentAtMs: Self.nowMs()))
|
||||
@@ -302,6 +304,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
|
||||
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let risk = WatchRiskLevel(rawValue: riskRaw)
|
||||
@@ -310,6 +314,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
}
|
||||
return WatchExecApprovalItem(
|
||||
id: id,
|
||||
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
|
||||
commandText: commandText,
|
||||
commandPreview: commandPreview,
|
||||
host: host,
|
||||
@@ -350,11 +355,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !approvalId.isEmpty else { return nil }
|
||||
let decision = Self.parseExecApprovalDecision(payload["decision"])
|
||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
|
||||
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
|
||||
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WatchExecApprovalResolvedMessage(
|
||||
approvalId: approvalId,
|
||||
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
|
||||
decision: decision,
|
||||
resolvedAtMs: resolvedAtMs,
|
||||
source: source)
|
||||
@@ -376,8 +384,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
return nil
|
||||
}
|
||||
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
|
||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WatchExecApprovalExpiredMessage(
|
||||
approvalId: approvalId,
|
||||
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
|
||||
reason: reason,
|
||||
expiredAtMs: expiredAtMs)
|
||||
}
|
||||
@@ -393,10 +404,13 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in
|
||||
Self.parseExecApprovalItem(item)
|
||||
}
|
||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WatchExecApprovalSnapshotMessage(
|
||||
approvals: approvals,
|
||||
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
|
||||
sentAtMs: sentAtMs,
|
||||
snapshotId: snapshotId)
|
||||
}
|
||||
@@ -557,6 +571,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
"decision": message.decision.rawValue,
|
||||
"replyId": message.replyId,
|
||||
]
|
||||
if let gatewayStableID = message.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!gatewayStableID.isEmpty
|
||||
{
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
@@ -602,6 +621,26 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||
}
|
||||
|
||||
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
|
||||
let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any])
|
||||
.flatMap(Self.parseAppSnapshotPayload)
|
||||
let execApprovalSnapshot =
|
||||
(payload[WatchPayloadType.execApprovalSnapshot.rawValue] as? [String: Any])
|
||||
.flatMap(Self.parseExecApprovalSnapshotPayload)
|
||||
if appSnapshot != nil || execApprovalSnapshot != nil {
|
||||
// Owner state must land first so approvals are filtered against this context's route.
|
||||
Task { @MainActor in
|
||||
if let appSnapshot {
|
||||
self.store.consume(appSnapshot: appSnapshot)
|
||||
}
|
||||
if let execApprovalSnapshot {
|
||||
self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport)
|
||||
}
|
||||
if appSnapshot != nil {
|
||||
self.store.replayDeferredGatewayPayloads()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if let incoming = Self.parseNotificationPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(message: incoming, transport: transport)
|
||||
@@ -635,6 +674,7 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||
if let snapshot = Self.parseAppSnapshotPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(appSnapshot: snapshot)
|
||||
self.store.replayDeferredGatewayPayloads()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
enum WatchDeferredPayloadOrdering {
|
||||
static func isExpired(expiresAtMs: Int?, nowMs: Int) -> Bool {
|
||||
expiresAtMs.map { $0 <= nowMs } == true
|
||||
}
|
||||
|
||||
static func isNewerThanSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
|
||||
guard let payloadSentAtMs, let snapshotSentAtMs else { return true }
|
||||
return payloadSentAtMs > snapshotSentAtMs
|
||||
}
|
||||
|
||||
static func isAtOrBeforeSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
|
||||
guard let snapshotSentAtMs else { return false }
|
||||
return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true
|
||||
}
|
||||
|
||||
static func indicesOldestFirst(for timestamps: [Int?]) -> [Int] {
|
||||
timestamps.indices.sorted { lhs, rhs in
|
||||
let lhsTimestamp = timestamps[lhs] ?? .min
|
||||
let rhsTimestamp = timestamps[rhs] ?? .min
|
||||
if lhsTimestamp != rhsTimestamp {
|
||||
return lhsTimestamp < rhsTimestamp
|
||||
}
|
||||
return lhs < rhs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ enum WatchExecApprovalCloseReason: String, Codable, Equatable {
|
||||
|
||||
struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var gatewayStableID: String?
|
||||
var commandText: String
|
||||
var commandPreview: String?
|
||||
var host: String?
|
||||
@@ -58,6 +59,7 @@ struct WatchExecApprovalPromptMessage: Codable, Equatable {
|
||||
|
||||
struct WatchExecApprovalResolvedMessage: Codable, Equatable {
|
||||
var approvalId: String
|
||||
var gatewayStableID: String?
|
||||
var decision: WatchExecApprovalDecision?
|
||||
var resolvedAtMs: Int?
|
||||
var source: String?
|
||||
@@ -65,12 +67,14 @@ struct WatchExecApprovalResolvedMessage: Codable, Equatable {
|
||||
|
||||
struct WatchExecApprovalExpiredMessage: Codable, Equatable {
|
||||
var approvalId: String
|
||||
var gatewayStableID: String?
|
||||
var reason: WatchExecApprovalCloseReason
|
||||
var expiredAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
|
||||
var approvals: [WatchExecApprovalItem]
|
||||
var gatewayStableID: String?
|
||||
var sentAtMs: Int?
|
||||
var snapshotId: String?
|
||||
}
|
||||
@@ -82,6 +86,7 @@ struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
|
||||
|
||||
struct WatchExecApprovalResolveMessage: Codable, Equatable {
|
||||
var approvalId: String
|
||||
var gatewayStableID: String?
|
||||
var decision: WatchExecApprovalDecision
|
||||
var replyId: String
|
||||
var sentAtMs: Int?
|
||||
@@ -147,7 +152,7 @@ struct WatchPromptAction: Codable, Equatable, Identifiable {
|
||||
var style: String?
|
||||
}
|
||||
|
||||
struct WatchNotifyMessage {
|
||||
struct WatchNotifyMessage: Codable {
|
||||
var id: String?
|
||||
var title: String
|
||||
var body: String
|
||||
@@ -177,6 +182,73 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor @Observable final class WatchInboxStore {
|
||||
private enum DeferredGatewayPayload: Codable {
|
||||
case notification(message: WatchNotifyMessage, transport: String)
|
||||
case execApprovalPrompt(message: WatchExecApprovalPromptMessage, transport: String)
|
||||
case execApprovalResolved(message: WatchExecApprovalResolvedMessage)
|
||||
case execApprovalExpired(message: WatchExecApprovalExpiredMessage)
|
||||
case execApprovalSnapshot(message: WatchExecApprovalSnapshotMessage, transport: String)
|
||||
|
||||
var gatewayStableID: String? {
|
||||
switch self {
|
||||
case let .notification(message, _):
|
||||
message.gatewayStableID
|
||||
case let .execApprovalPrompt(message, _):
|
||||
message.approval.gatewayStableID
|
||||
case let .execApprovalResolved(message):
|
||||
message.gatewayStableID
|
||||
case let .execApprovalExpired(message):
|
||||
message.gatewayStableID
|
||||
case let .execApprovalSnapshot(message, _):
|
||||
if let gatewayStableID = WatchInboxStore.normalizedGatewayID(message.gatewayStableID) {
|
||||
gatewayStableID
|
||||
} else {
|
||||
WatchInboxStore.onlyGatewayStableID(in: message.approvals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sentAtMs: Int? {
|
||||
switch self {
|
||||
case let .notification(message, _):
|
||||
message.sentAtMs
|
||||
case let .execApprovalPrompt(message, _):
|
||||
message.sentAtMs
|
||||
case let .execApprovalResolved(message):
|
||||
message.resolvedAtMs
|
||||
case let .execApprovalExpired(message):
|
||||
message.expiredAtMs
|
||||
case let .execApprovalSnapshot(message, _):
|
||||
message.sentAtMs
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAtMs: Int? {
|
||||
switch self {
|
||||
case let .notification(message, _):
|
||||
message.expiresAtMs
|
||||
case let .execApprovalPrompt(message, _):
|
||||
message.approval.expiresAtMs
|
||||
case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
var approvalPrompt: WatchExecApprovalItem? {
|
||||
guard case let .execApprovalPrompt(message, _) = self else { return nil }
|
||||
return message.approval
|
||||
}
|
||||
|
||||
var isFullyRepresentedByExecApprovalSnapshot: Bool {
|
||||
switch self {
|
||||
case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot:
|
||||
true
|
||||
case .notification, .execApprovalPrompt:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PersistedState: Codable {
|
||||
var title: String
|
||||
var body: String
|
||||
@@ -196,15 +268,19 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
var execApprovals: [WatchExecApprovalRecord]
|
||||
var selectedExecApprovalID: String?
|
||||
var lastExecApprovalSnapshotID: String?
|
||||
var lastExecApprovalSnapshotGatewayStableID: String?
|
||||
var lastExecApprovalSnapshotSentAtMs: Int?
|
||||
var lastExecApprovalOutcomeText: String?
|
||||
var lastExecApprovalOutcomeAt: Date?
|
||||
var appSnapshot: WatchAppSnapshotMessage?
|
||||
var appSnapshotUpdatedAt: Date?
|
||||
var appSnapshotStatusText: String?
|
||||
var appCommandStatusText: String?
|
||||
var deferredGatewayPayloads: [DeferredGatewayPayload]?
|
||||
}
|
||||
|
||||
private static let persistedStateKey = "watch.inbox.state.v2"
|
||||
private static let maxDeferredGatewayPayloads = 32
|
||||
private static let defaultTitle = "OpenClaw"
|
||||
private static let defaultBody = "Waiting for messages from your iPhone."
|
||||
private let defaults: UserDefaults
|
||||
@@ -238,8 +314,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
var execApprovalReviewStatusText: String?
|
||||
var execApprovalReviewStatusAt: Date?
|
||||
private var lastExecApprovalSnapshotID: String?
|
||||
private var lastExecApprovalSnapshotGatewayStableID: String?
|
||||
private var lastExecApprovalSnapshotSentAtMs: Int?
|
||||
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
||||
private var lastDeliveryKey: String?
|
||||
/// WatchConnectivity does not order application-context updates against user-info
|
||||
/// transfers. Persist a bounded handoff queue so a new route's alert is not lost
|
||||
/// before its owner snapshot arrives.
|
||||
private var deferredGatewayPayloads: [DeferredGatewayPayload] = []
|
||||
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
@@ -347,6 +429,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
}
|
||||
|
||||
func consume(message: WatchNotifyMessage, transport: String) {
|
||||
guard self.routeGatewayPayload(.notification(message: message, transport: transport)) else { return }
|
||||
let messageID = message.id?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let deliveryKey = self.deliveryKey(
|
||||
@@ -381,7 +464,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
identifier: deliveryKey,
|
||||
title: normalizedTitle,
|
||||
body: message.body,
|
||||
risk: message.risk)
|
||||
risk: message.risk,
|
||||
stillCurrent: { self.lastDeliveryKey == deliveryKey })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,22 +473,33 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
execApprovalPrompt message: WatchExecApprovalPromptMessage,
|
||||
transport: String)
|
||||
{
|
||||
guard self.routeGatewayPayload(.execApprovalPrompt(message: message, transport: transport)) else { return }
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
self.upsertExecApproval(
|
||||
message.approval,
|
||||
transport: transport,
|
||||
keepSelectionIfPossible: true,
|
||||
resetResolvingState: message.resetResolvingState == true)
|
||||
let approvalID = message.approval.id
|
||||
let approvalGatewayID = message.approval.gatewayStableID
|
||||
guard let notificationIdentifier = Self.execApprovalNotificationIdentifier(for: message.approval) else {
|
||||
return
|
||||
}
|
||||
self.markExecApprovalReviewLoaded()
|
||||
self.lastExecApprovalOutcomeText = nil
|
||||
self.lastExecApprovalOutcomeAt = nil
|
||||
|
||||
Task {
|
||||
await self.postLocalNotification(
|
||||
identifier: "watch.execApproval.\(message.approval.id)",
|
||||
identifier: notificationIdentifier,
|
||||
title: "Exec approval required",
|
||||
body: message.approval.commandPreview ?? message.approval.commandText,
|
||||
risk: message.approval.risk?.rawValue)
|
||||
risk: message.approval.risk?.rawValue,
|
||||
stillCurrent: {
|
||||
self.execApprovals.contains { record in
|
||||
record.id == approvalID && record.approval.gatewayStableID == approvalGatewayID
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,20 +507,55 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
execApprovalSnapshot message: WatchExecApprovalSnapshotMessage,
|
||||
transport: String)
|
||||
{
|
||||
let deferredPayload = DeferredGatewayPayload.execApprovalSnapshot(
|
||||
message: message,
|
||||
transport: transport)
|
||||
if deferredPayload.gatewayStableID != nil {
|
||||
guard self.routeGatewayPayload(deferredPayload) else { return }
|
||||
}
|
||||
let snapshotGatewayID = Self.normalizedGatewayID(deferredPayload.gatewayStableID)
|
||||
let previousSnapshotGatewayID = Self.normalizedGatewayID(
|
||||
self.lastExecApprovalSnapshotGatewayStableID)
|
||||
let hasSameSnapshotOwner = snapshotGatewayID == previousSnapshotGatewayID
|
||||
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let snapshotID, !snapshotID.isEmpty, snapshotID == lastExecApprovalSnapshotID {
|
||||
if hasSameSnapshotOwner,
|
||||
let snapshotID,
|
||||
!snapshotID.isEmpty,
|
||||
snapshotID == lastExecApprovalSnapshotID
|
||||
{
|
||||
return
|
||||
}
|
||||
if hasSameSnapshotOwner,
|
||||
let sentAtMs = message.sentAtMs,
|
||||
let lastSentAtMs = lastExecApprovalSnapshotSentAtMs,
|
||||
sentAtMs < lastSentAtMs
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
let existingRecords = self.execApprovals
|
||||
let existingRecordsByID = Dictionary(
|
||||
uniqueKeysWithValues: execApprovals.map { ($0.id, $0) })
|
||||
self.execApprovals = message.approvals.map { approval in
|
||||
uniqueKeysWithValues: existingRecords.map { ($0.id, $0) })
|
||||
self.execApprovals = message.approvals.filter { approval in
|
||||
self.acceptsGatewayOwner(approval.gatewayStableID)
|
||||
}.map { approval in
|
||||
self.mergedExecApprovalRecord(
|
||||
approval: approval,
|
||||
transport: transport,
|
||||
existingRecord: existingRecordsByID[approval.id])
|
||||
}
|
||||
self.lastExecApprovalSnapshotID = snapshotID
|
||||
if hasSameSnapshotOwner {
|
||||
if let snapshotID, !snapshotID.isEmpty {
|
||||
self.lastExecApprovalSnapshotID = snapshotID
|
||||
}
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
self.lastExecApprovalSnapshotSentAtMs = sentAtMs
|
||||
}
|
||||
} else {
|
||||
self.lastExecApprovalSnapshotID = snapshotID
|
||||
self.lastExecApprovalSnapshotSentAtMs = message.sentAtMs
|
||||
}
|
||||
self.lastExecApprovalSnapshotGatewayStableID = snapshotGatewayID
|
||||
self.hasCompletedExecApprovalSnapshotRefreshInSession = true
|
||||
if let selectedExecApprovalID,
|
||||
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
|
||||
@@ -435,6 +565,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
let currentNotificationIdentifiers = Set(execApprovals.compactMap { record in
|
||||
Self.execApprovalNotificationIdentifier(for: record.approval)
|
||||
})
|
||||
let removedApprovals = existingRecords.map(\.approval).filter { approval in
|
||||
guard let identifier = Self.execApprovalNotificationIdentifier(for: approval) else { return false }
|
||||
return !currentNotificationIdentifiers.contains(identifier)
|
||||
}
|
||||
self.removeExecApprovalNotifications(approvals: removedApprovals)
|
||||
self.markExecApprovalReviewLoaded()
|
||||
self.persistState()
|
||||
}
|
||||
@@ -444,16 +582,48 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
if let snapshotID, !snapshotID.isEmpty, snapshotID == appSnapshot?.snapshotId {
|
||||
return
|
||||
}
|
||||
var merged = message
|
||||
if merged.chatItems == nil {
|
||||
merged.chatItems = self.appSnapshot?.chatItems
|
||||
if let sentAtMs = message.sentAtMs,
|
||||
let currentSentAtMs = appSnapshot?.sentAtMs,
|
||||
sentAtMs < currentSentAtMs
|
||||
{
|
||||
return
|
||||
}
|
||||
if merged.chatStatusText == nil {
|
||||
merged.chatStatusText = self.appSnapshot?.chatStatusText
|
||||
let hasExistingAppSnapshot = self.appSnapshot != nil
|
||||
let previousGatewayID = Self.normalizedGatewayID(self.appSnapshot?.gatewayStableID)
|
||||
let nextGatewayID = Self.normalizedGatewayID(message.gatewayStableID)
|
||||
var merged = message
|
||||
if hasExistingAppSnapshot, previousGatewayID == nextGatewayID {
|
||||
if merged.chatItems == nil {
|
||||
merged.chatItems = self.appSnapshot?.chatItems
|
||||
}
|
||||
if merged.chatStatusText == nil {
|
||||
merged.chatStatusText = self.appSnapshot?.chatStatusText
|
||||
}
|
||||
}
|
||||
self.appSnapshot = merged
|
||||
self.appSnapshotUpdatedAt = Date()
|
||||
self.appSnapshotStatusText = nil
|
||||
if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID {
|
||||
if Self.normalizedGatewayID(self.gatewayStableID) != nextGatewayID {
|
||||
self.clearMessagePrompt()
|
||||
}
|
||||
let invalidatedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
||||
guard let nextGatewayID else { return record.approval }
|
||||
return Self.normalizedGatewayID(record.approval.gatewayStableID) == nextGatewayID
|
||||
? nil
|
||||
: record.approval
|
||||
}
|
||||
self.execApprovals.removeAll { record in
|
||||
guard let nextGatewayID else { return true }
|
||||
return Self.normalizedGatewayID(record.approval.gatewayStableID) != nextGatewayID
|
||||
}
|
||||
self.removeExecApprovalNotifications(approvals: invalidatedApprovals)
|
||||
if let selectedExecApprovalID,
|
||||
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
|
||||
{
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
}
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
@@ -520,7 +690,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
}
|
||||
|
||||
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
|
||||
self.removeExecApproval(id: message.approvalId)
|
||||
guard self.routeGatewayPayload(.execApprovalResolved(message: message)) else { return }
|
||||
self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID)
|
||||
let statusText = switch message.decision {
|
||||
case .allowOnce:
|
||||
"Allowed once"
|
||||
@@ -535,7 +706,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
}
|
||||
|
||||
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
|
||||
self.removeExecApproval(id: message.approvalId)
|
||||
guard self.routeGatewayPayload(.execApprovalExpired(message: message)) else { return }
|
||||
self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID)
|
||||
let statusText = switch message.reason {
|
||||
case .expired:
|
||||
"Approval expired"
|
||||
@@ -643,21 +815,204 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
statusAt: statusAt)
|
||||
}
|
||||
|
||||
private func removeExecApproval(id: String) {
|
||||
private func removeExecApproval(id: String, gatewayStableID: String?) {
|
||||
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
self.execApprovals.removeAll { $0.id == normalizedID }
|
||||
let normalizedGatewayID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let removedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
||||
guard record.id == normalizedID else { return nil }
|
||||
// Legacy ownerless lifecycle messages may only close legacy ownerless prompts.
|
||||
return record.approval.gatewayStableID == normalizedGatewayID ? record.approval : nil
|
||||
}
|
||||
self.execApprovals.removeAll { record in
|
||||
guard record.id == normalizedID else { return false }
|
||||
// Legacy ownerless lifecycle messages may only close legacy ownerless prompts.
|
||||
return record.approval.gatewayStableID == normalizedGatewayID
|
||||
}
|
||||
self.removeExecApprovalNotifications(approvals: removedApprovals)
|
||||
if self.selectedExecApprovalID == normalizedID {
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func routeGatewayPayload(_ payload: DeferredGatewayPayload) -> Bool {
|
||||
guard let incomingGatewayID = Self.normalizedGatewayID(payload.gatewayStableID) else {
|
||||
return false
|
||||
}
|
||||
guard let activeSnapshot = appSnapshot else { return true }
|
||||
let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID)
|
||||
guard incomingGatewayID != activeGatewayID else { return true }
|
||||
if let payloadSentAtMs = payload.sentAtMs,
|
||||
let snapshotSentAtMs = activeSnapshot.sentAtMs,
|
||||
payloadSentAtMs <= snapshotSentAtMs
|
||||
{
|
||||
return false
|
||||
}
|
||||
if WatchDeferredPayloadOrdering.isExpired(
|
||||
expiresAtMs: payload.expiresAtMs,
|
||||
nowMs: Self.nowMs())
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
self.deferredGatewayPayloads.append(payload)
|
||||
if self.deferredGatewayPayloads.count > Self.maxDeferredGatewayPayloads {
|
||||
self.deferredGatewayPayloads.removeFirst(
|
||||
self.deferredGatewayPayloads.count - Self.maxDeferredGatewayPayloads)
|
||||
}
|
||||
self.persistState()
|
||||
return false
|
||||
}
|
||||
|
||||
private func acceptsGatewayOwner(_ gatewayStableID: String?) -> Bool {
|
||||
guard let incomingGatewayID = Self.normalizedGatewayID(gatewayStableID) else { return false }
|
||||
guard let activeSnapshot = appSnapshot else { return true }
|
||||
guard let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) else { return false }
|
||||
return incomingGatewayID == activeGatewayID
|
||||
}
|
||||
|
||||
func replayDeferredGatewayPayloads() {
|
||||
guard let activeGatewayID = Self.normalizedGatewayID(appSnapshot?.gatewayStableID) else {
|
||||
let snapshotSentAtMs = self.appSnapshot?.sentAtMs
|
||||
let nowMs = Self.nowMs()
|
||||
self.deferredGatewayPayloads.removeAll { payload in
|
||||
WatchDeferredPayloadOrdering.isExpired(
|
||||
expiresAtMs: payload.expiresAtMs,
|
||||
nowMs: nowMs)
|
||||
|| !WatchDeferredPayloadOrdering.isNewerThanSnapshot(
|
||||
payloadSentAtMs: payload.sentAtMs,
|
||||
snapshotSentAtMs: snapshotSentAtMs)
|
||||
}
|
||||
self.persistState()
|
||||
return
|
||||
}
|
||||
|
||||
let snapshotSentAtMs = self.appSnapshot?.sentAtMs
|
||||
let approvalSnapshotGatewayID = Self.normalizedGatewayID(
|
||||
self.lastExecApprovalSnapshotGatewayStableID)
|
||||
let nowMs = Self.nowMs()
|
||||
var ready: [DeferredGatewayPayload] = []
|
||||
var future: [DeferredGatewayPayload] = []
|
||||
for payload in self.deferredGatewayPayloads {
|
||||
if WatchDeferredPayloadOrdering.isExpired(
|
||||
expiresAtMs: payload.expiresAtMs,
|
||||
nowMs: nowMs)
|
||||
{
|
||||
continue
|
||||
}
|
||||
if Self.normalizedGatewayID(payload.gatewayStableID) == activeGatewayID {
|
||||
let isPreexistingApprovalPayload = approvalSnapshotGatewayID == activeGatewayID
|
||||
&& WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(
|
||||
payloadSentAtMs: payload.sentAtMs,
|
||||
snapshotSentAtMs: self.lastExecApprovalSnapshotSentAtMs)
|
||||
if isPreexistingApprovalPayload,
|
||||
payload.isFullyRepresentedByExecApprovalSnapshot
|
||||
{
|
||||
continue
|
||||
}
|
||||
if isPreexistingApprovalPayload,
|
||||
let approval = payload.approvalPrompt,
|
||||
!self.execApprovals.contains(where: { record in
|
||||
record.id == approval.id
|
||||
&& Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID
|
||||
})
|
||||
{
|
||||
continue
|
||||
}
|
||||
ready.append(payload)
|
||||
} else if let payloadSentAtMs = payload.sentAtMs,
|
||||
let snapshotSentAtMs,
|
||||
payloadSentAtMs > snapshotSentAtMs
|
||||
{
|
||||
future.append(payload)
|
||||
}
|
||||
}
|
||||
self.deferredGatewayPayloads = future
|
||||
self.persistState()
|
||||
|
||||
let replayOrder = WatchDeferredPayloadOrdering.indicesOldestFirst(
|
||||
for: ready.map(\.sentAtMs))
|
||||
for index in replayOrder {
|
||||
let payload = ready[index]
|
||||
switch payload {
|
||||
case let .notification(message, transport):
|
||||
self.consume(message: message, transport: transport)
|
||||
case let .execApprovalPrompt(message, transport):
|
||||
self.consume(execApprovalPrompt: message, transport: transport)
|
||||
case let .execApprovalResolved(message):
|
||||
self.consume(execApprovalResolved: message)
|
||||
case let .execApprovalExpired(message):
|
||||
self.consume(execApprovalExpired: message)
|
||||
case let .execApprovalSnapshot(message, transport):
|
||||
self.consume(execApprovalSnapshot: message, transport: transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearMessagePrompt() {
|
||||
let notificationIdentifier = self.lastDeliveryKey
|
||||
self.title = Self.defaultTitle
|
||||
self.body = Self.defaultBody
|
||||
self.transport = "none"
|
||||
self.updatedAt = nil
|
||||
self.lastDeliveryKey = nil
|
||||
self.promptId = nil
|
||||
self.sessionKey = nil
|
||||
self.gatewayStableID = nil
|
||||
self.kind = nil
|
||||
self.details = nil
|
||||
self.expiresAtMs = nil
|
||||
self.risk = nil
|
||||
self.actions = []
|
||||
self.replyStatusText = nil
|
||||
self.replyStatusAt = nil
|
||||
self.isReplySending = false
|
||||
|
||||
guard let notificationIdentifier else { return }
|
||||
self.removeLocalNotifications(identifiers: [notificationIdentifier])
|
||||
}
|
||||
|
||||
private func removeExecApprovalNotifications(approvals: [WatchExecApprovalItem]) {
|
||||
self.removeLocalNotifications(identifiers: approvals.compactMap { approval in
|
||||
Self.execApprovalNotificationIdentifier(for: approval)
|
||||
})
|
||||
}
|
||||
|
||||
private func removeLocalNotifications(identifiers: [String]) {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||
center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedGatewayID(_ gatewayStableID: String?) -> String? {
|
||||
let normalized = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
private nonisolated static func onlyGatewayStableID(in approvals: [WatchExecApprovalItem]) -> String? {
|
||||
let gatewayIDs = Set(approvals.compactMap { self.normalizedGatewayID($0.gatewayStableID) })
|
||||
return gatewayIDs.count == 1 ? gatewayIDs.first : nil
|
||||
}
|
||||
|
||||
private static func execApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? {
|
||||
guard let gatewayStableID = normalizedGatewayID(approval.gatewayStableID) else { return nil }
|
||||
let approvalID = approval.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !approvalID.isEmpty else { return nil }
|
||||
return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)"
|
||||
}
|
||||
|
||||
private func pruneExpiredExecApprovals(nowMs: Int) {
|
||||
let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
||||
guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil }
|
||||
return record.approval
|
||||
}
|
||||
self.execApprovals.removeAll { record in
|
||||
guard let expiresAtMs = record.approval.expiresAtMs else { return false }
|
||||
return expiresAtMs <= nowMs
|
||||
}
|
||||
self.removeExecApprovalNotifications(approvals: expiredApprovals)
|
||||
if let selectedExecApprovalID,
|
||||
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
|
||||
{
|
||||
@@ -688,15 +1043,64 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
self.actions = state.actions ?? []
|
||||
self.replyStatusText = state.replyStatusText
|
||||
self.replyStatusAt = state.replyStatusAt
|
||||
self.execApprovals = state.execApprovals
|
||||
self.selectedExecApprovalID = state.selectedExecApprovalID
|
||||
let ownerlessApprovals = state.execApprovals.filter { record in
|
||||
Self.normalizedGatewayID(record.approval.gatewayStableID) == nil
|
||||
}
|
||||
let taggedApprovals = state.execApprovals.filter { record in
|
||||
Self.normalizedGatewayID(record.approval.gatewayStableID) != nil
|
||||
}
|
||||
let activeGatewayID = state.appSnapshot.flatMap { snapshot in
|
||||
Self.normalizedGatewayID(snapshot.gatewayStableID)
|
||||
}
|
||||
let invalidatedApprovals: [WatchExecApprovalRecord]
|
||||
if state.appSnapshot != nil {
|
||||
self.execApprovals = taggedApprovals.filter { record in
|
||||
Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID
|
||||
}
|
||||
invalidatedApprovals = taggedApprovals.filter { record in
|
||||
Self.normalizedGatewayID(record.approval.gatewayStableID) != activeGatewayID
|
||||
}
|
||||
} else {
|
||||
self.execApprovals = taggedApprovals
|
||||
invalidatedApprovals = []
|
||||
}
|
||||
selectedExecApprovalID = state.selectedExecApprovalID
|
||||
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
|
||||
self.lastExecApprovalSnapshotGatewayStableID = state.lastExecApprovalSnapshotGatewayStableID
|
||||
self.lastExecApprovalSnapshotSentAtMs = state.lastExecApprovalSnapshotSentAtMs
|
||||
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
|
||||
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
|
||||
self.appSnapshot = state.appSnapshot
|
||||
self.appSnapshotUpdatedAt = state.appSnapshotUpdatedAt
|
||||
self.appSnapshotStatusText = state.appSnapshotStatusText
|
||||
self.appCommandStatusText = state.appCommandStatusText
|
||||
self.deferredGatewayPayloads = Array(
|
||||
(state.deferredGatewayPayloads ?? []).suffix(Self.maxDeferredGatewayPayloads))
|
||||
|
||||
if state.appSnapshot != nil,
|
||||
Self.normalizedGatewayID(self.lastExecApprovalSnapshotGatewayStableID) != activeGatewayID
|
||||
{
|
||||
self.lastExecApprovalSnapshotID = nil
|
||||
self.lastExecApprovalSnapshotGatewayStableID = nil
|
||||
self.lastExecApprovalSnapshotSentAtMs = nil
|
||||
}
|
||||
if let selectedExecApprovalID,
|
||||
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
|
||||
{
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
self.removeExecApprovalNotifications(approvals: invalidatedApprovals.map(\.approval))
|
||||
|
||||
guard !ownerlessApprovals.isEmpty else { return }
|
||||
// Older Watch state has no gateway owner and cannot be resolved safely after
|
||||
// gateway switches. Drop it, clear its old alert keys, and force a fresh snapshot.
|
||||
self.lastExecApprovalSnapshotID = nil
|
||||
self.lastExecApprovalSnapshotGatewayStableID = nil
|
||||
self.lastExecApprovalSnapshotSentAtMs = nil
|
||||
self.removeLocalNotifications(identifiers: ownerlessApprovals.compactMap { record in
|
||||
let approvalID = record.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return approvalID.isEmpty ? nil : "watch.execApproval.\(approvalID)"
|
||||
})
|
||||
}
|
||||
|
||||
private func persistState() {
|
||||
@@ -720,12 +1124,15 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
execApprovals: execApprovals,
|
||||
selectedExecApprovalID: selectedExecApprovalID,
|
||||
lastExecApprovalSnapshotID: lastExecApprovalSnapshotID,
|
||||
lastExecApprovalSnapshotGatewayStableID: lastExecApprovalSnapshotGatewayStableID,
|
||||
lastExecApprovalSnapshotSentAtMs: lastExecApprovalSnapshotSentAtMs,
|
||||
lastExecApprovalOutcomeText: lastExecApprovalOutcomeText,
|
||||
lastExecApprovalOutcomeAt: lastExecApprovalOutcomeAt,
|
||||
appSnapshot: appSnapshot,
|
||||
appSnapshotUpdatedAt: appSnapshotUpdatedAt,
|
||||
appSnapshotStatusText: appSnapshotStatusText,
|
||||
appCommandStatusText: appCommandStatusText)
|
||||
appCommandStatusText: appCommandStatusText,
|
||||
deferredGatewayPayloads: deferredGatewayPayloads)
|
||||
guard let data = try? JSONEncoder().encode(state) else { return }
|
||||
self.defaults.set(data, forKey: Self.persistedStateKey)
|
||||
}
|
||||
@@ -794,7 +1201,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async {
|
||||
private func postLocalNotification(
|
||||
identifier: String,
|
||||
title: String,
|
||||
body: String,
|
||||
risk: String?,
|
||||
stillCurrent: @MainActor @Sendable () -> Bool = { true }) async
|
||||
{
|
||||
guard stillCurrent() else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
@@ -806,7 +1220,12 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false))
|
||||
|
||||
_ = try? await UNUserNotificationCenter.current().add(request)
|
||||
let center = UNUserNotificationCenter.current()
|
||||
_ = try? await center.add(request)
|
||||
guard stillCurrent() else {
|
||||
self.removeLocalNotifications(identifiers: [identifier])
|
||||
return
|
||||
}
|
||||
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import WatchKit
|
||||
struct WatchInboxView: View {
|
||||
var store: WatchInboxStore
|
||||
var onAction: ((WatchPromptAction) -> Void)?
|
||||
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
||||
var onRefreshExecApprovalReview: (() -> Void)?
|
||||
var onRefreshAppSnapshot: (() -> Void)?
|
||||
var onAppCommand: ((WatchAppCommand) -> Void)?
|
||||
@@ -28,7 +28,7 @@ struct WatchInboxView: View {
|
||||
private struct WatchControlSurfaceView: View {
|
||||
var store: WatchInboxStore
|
||||
var onAction: ((WatchPromptAction) -> Void)?
|
||||
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
||||
var onRefreshExecApprovalReview: (() -> Void)?
|
||||
var onRefreshAppSnapshot: (() -> Void)?
|
||||
var onAppCommand: ((WatchAppCommand) -> Void)?
|
||||
@@ -183,7 +183,7 @@ private struct WatchControlSurfaceView: View {
|
||||
subtitle: self.store.body,
|
||||
accessory: self.updatedText)
|
||||
|
||||
if let details = self.promptDetails {
|
||||
if let details = promptDetails {
|
||||
WatchDetailText(text: details)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ private struct WatchControlSurfaceView: View {
|
||||
.disabled(self.store.isReplySending)
|
||||
}
|
||||
|
||||
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
|
||||
if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty {
|
||||
WatchTinyStatus(text: replyStatusText)
|
||||
}
|
||||
}
|
||||
@@ -249,13 +249,19 @@ private struct WatchControlSurfaceView: View {
|
||||
HStack(spacing: 8) {
|
||||
if record.approval.allowedDecisions.contains(.allowOnce) {
|
||||
WatchDecisionButton(title: "Approve", color: .green) {
|
||||
self.onExecApprovalDecision?(record.id, .allowOnce)
|
||||
self.onExecApprovalDecision?(
|
||||
record.id,
|
||||
record.approval.gatewayStableID,
|
||||
.allowOnce)
|
||||
}
|
||||
}
|
||||
|
||||
if record.approval.allowedDecisions.contains(.deny) {
|
||||
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
|
||||
self.onExecApprovalDecision?(record.id, .deny)
|
||||
self.onExecApprovalDecision?(
|
||||
record.id,
|
||||
record.approval.gatewayStableID,
|
||||
.deny)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +313,7 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var primaryDestination: some View {
|
||||
if let record = self.store.activeExecApproval {
|
||||
if let record = store.activeExecApproval {
|
||||
WatchExecApprovalDetailView(
|
||||
store: self.store,
|
||||
record: record,
|
||||
@@ -334,7 +340,7 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var connectionLine: String {
|
||||
if let snapshot = self.store.appSnapshot {
|
||||
if let snapshot = store.appSnapshot {
|
||||
return snapshot.gatewayConnected ? "AI agent online" : "Reconnect on iPhone"
|
||||
}
|
||||
return "Pair iPhone"
|
||||
@@ -346,7 +352,7 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var primaryTitle: String {
|
||||
if let record = self.store.activeExecApproval {
|
||||
if let record = store.activeExecApproval {
|
||||
return record.approval.commandPreview ?? record.approval.commandText
|
||||
}
|
||||
if self.chatCount > 0 {
|
||||
@@ -370,7 +376,7 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var approvalSubtitle: String {
|
||||
guard let record = self.store.activeExecApproval else { return "No approvals waiting" }
|
||||
guard let record = store.activeExecApproval else { return "No approvals waiting" }
|
||||
return record.approval.commandPreview ?? record.approval.commandText
|
||||
}
|
||||
|
||||
@@ -380,7 +386,7 @@ private struct WatchControlSurfaceView: View {
|
||||
|
||||
private func approvalDecisionSubtitle(_ record: WatchExecApprovalRecord) -> String {
|
||||
var parts: [String] = []
|
||||
if let expiresText = self.expiryText(record.approval.expiresAtMs) {
|
||||
if let expiresText = expiryText(record.approval.expiresAtMs) {
|
||||
parts.append("Expires in \(expiresText)")
|
||||
}
|
||||
if let host = record.approval.host, !host.isEmpty {
|
||||
@@ -396,7 +402,7 @@ private struct WatchControlSurfaceView: View {
|
||||
if record.isResolving {
|
||||
return "Sending"
|
||||
}
|
||||
if let risk = self.approvalRiskText(record.approval.risk) {
|
||||
if let risk = approvalRiskText(record.approval.risk) {
|
||||
return risk
|
||||
}
|
||||
return "Review"
|
||||
@@ -416,7 +422,7 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var chatPreviewTitle: String {
|
||||
guard let item = self.chatItems.last else { return "No chat synced" }
|
||||
guard let item = chatItems.last else { return "No chat synced" }
|
||||
return self.roleTitle(item.role)
|
||||
}
|
||||
|
||||
@@ -425,7 +431,7 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var chatStatusText: String {
|
||||
if let status = self.store.appSnapshot?.chatStatusText, !status.isEmpty {
|
||||
if let status = store.appSnapshot?.chatStatusText, !status.isEmpty {
|
||||
return status
|
||||
}
|
||||
if self.chatCount > 0 {
|
||||
@@ -435,14 +441,14 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var chatSendStatusText: String? {
|
||||
guard let status = self.store.appCommandStatusText, status.hasPrefix("Chat:") else {
|
||||
guard let status = store.appCommandStatusText, status.hasPrefix("Chat:") else {
|
||||
return nil
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
private var greetingText: String {
|
||||
if let greetingTextOverride = self.store.greetingTextOverride {
|
||||
if let greetingTextOverride = store.greetingTextOverride {
|
||||
return greetingTextOverride
|
||||
}
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
@@ -452,20 +458,20 @@ private struct WatchControlSurfaceView: View {
|
||||
}
|
||||
|
||||
private var statusLine: String {
|
||||
if let status = self.store.appSnapshotStatusText, !status.isEmpty {
|
||||
if let status = store.appSnapshotStatusText, !status.isEmpty {
|
||||
return status
|
||||
}
|
||||
if let commandStatus = self.store.appCommandStatusText, !commandStatus.isEmpty {
|
||||
if let commandStatus = store.appCommandStatusText, !commandStatus.isEmpty {
|
||||
return commandStatus
|
||||
}
|
||||
if let replyStatus = self.store.replyStatusText, !replyStatus.isEmpty {
|
||||
if let replyStatus = store.replyStatusText, !replyStatus.isEmpty {
|
||||
return replyStatus
|
||||
}
|
||||
return self.store.hasAppSnapshot ? "Synced" : "Waiting for iPhone"
|
||||
}
|
||||
|
||||
private var updatedText: String {
|
||||
guard let updatedAt = self.store.updatedAt else { return "Just now" }
|
||||
guard let updatedAt = store.updatedAt else { return "Just now" }
|
||||
return updatedAt.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
|
||||
@@ -538,7 +544,7 @@ private enum WatchAvatarSource {
|
||||
}
|
||||
|
||||
static func dataImage(from source: String?) -> UIImage? {
|
||||
guard let source = self.normalized(source),
|
||||
guard let source = normalized(source),
|
||||
source.lowercased().hasPrefix("data:image/"),
|
||||
let commaIndex = source.firstIndex(of: ",")
|
||||
else {
|
||||
@@ -552,7 +558,7 @@ private enum WatchAvatarSource {
|
||||
}
|
||||
|
||||
static func remoteURL(from source: String?) -> URL? {
|
||||
guard let source = self.normalized(source),
|
||||
guard let source = normalized(source),
|
||||
let url = URL(string: source),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "https" || scheme == "http"
|
||||
@@ -589,11 +595,11 @@ private struct WatchClawAvatar: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var avatarContent: some View {
|
||||
if let image = self.dataImage {
|
||||
if let image = dataImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if let url = WatchAvatarSource.remoteURL(from: self.imageSource) {
|
||||
} else if let url = WatchAvatarSource.remoteURL(from: imageSource) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case let .success(image):
|
||||
@@ -610,7 +616,7 @@ private struct WatchClawAvatar: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var fallbackContent: some View {
|
||||
if let text = WatchAvatarSource.normalized(self.text) {
|
||||
if let text = WatchAvatarSource.normalized(text) {
|
||||
Text(String(text.prefix(3)))
|
||||
.font(WatchClawType.avatar(size: self.size * 0.42))
|
||||
.foregroundStyle(.white)
|
||||
@@ -1289,7 +1295,7 @@ private enum WatchNativeTextInput {
|
||||
|
||||
private struct WatchExecApprovalListView: View {
|
||||
var store: WatchInboxStore
|
||||
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
WatchDetailScroll(title: "Approvals") {
|
||||
@@ -1353,7 +1359,7 @@ private struct WatchExecApprovalListView: View {
|
||||
private struct WatchExecApprovalDetailView: View {
|
||||
var store: WatchInboxStore
|
||||
let record: WatchExecApprovalRecord
|
||||
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
WatchDetailScroll(title: "Approval") {
|
||||
@@ -1375,13 +1381,19 @@ private struct WatchExecApprovalDetailView: View {
|
||||
HStack(spacing: 8) {
|
||||
if currentRecord.approval.allowedDecisions.contains(.allowOnce) {
|
||||
WatchDecisionButton(title: "Approve", color: .green) {
|
||||
self.onDecision?(currentRecord.id, .allowOnce)
|
||||
self.onDecision?(
|
||||
currentRecord.id,
|
||||
currentRecord.approval.gatewayStableID,
|
||||
.allowOnce)
|
||||
}
|
||||
}
|
||||
|
||||
if currentRecord.approval.allowedDecisions.contains(.deny) {
|
||||
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
|
||||
self.onDecision?(currentRecord.id, .deny)
|
||||
self.onDecision?(
|
||||
currentRecord.id,
|
||||
currentRecord.approval.gatewayStableID,
|
||||
.deny)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +372,8 @@ targets:
|
||||
- path: Tests
|
||||
excludes:
|
||||
- Logic
|
||||
- path: WatchApp/Sources/WatchDeferredPayloadOrdering.swift
|
||||
group: WatchApp/Sources
|
||||
dependencies:
|
||||
- target: OpenClaw
|
||||
- package: Swabble
|
||||
|
||||
@@ -237,7 +237,7 @@ struct GatewayChannelConnectTests {
|
||||
@Test func `stored device token connect scopes reuse cached scopes`() async throws {
|
||||
try await self.withTemporaryStateDir {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedEntry = DeviceAuthStore.storeToken(
|
||||
let storedEntry: DeviceAuthEntry = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "bootstrap-device-token",
|
||||
|
||||
@@ -74,8 +74,17 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public var websocketURL: URL? {
|
||||
let scheme = self.tls ? "wss" : "ws"
|
||||
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
||||
guard (1...65535).contains(self.port) else { return nil }
|
||||
var components = URLComponents()
|
||||
components.scheme = self.tls ? "wss" : "ws"
|
||||
components.host = self.host
|
||||
components.port = self.port
|
||||
return components.url
|
||||
}
|
||||
|
||||
public var isValidEndpoint: Bool {
|
||||
guard (1...65535).contains(self.port), self.websocketURL?.host != nil else { return false }
|
||||
return self.tls || LoopbackHost.isLocalNetworkHost(self.host)
|
||||
}
|
||||
|
||||
public var connectionEndpoints: [GatewayConnectEndpoint] {
|
||||
@@ -196,7 +205,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
if !tls, !LoopbackHost.isLocalNetworkHost(host) {
|
||||
return nil
|
||||
}
|
||||
return GatewayConnectDeepLink(
|
||||
return GatewayConnectDeepLink.validated(
|
||||
host: host,
|
||||
port: payload.port ?? defaultGatewayPort(tls: tls),
|
||||
tls: tls,
|
||||
@@ -223,7 +232,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
if !tls, !LoopbackHost.isLocalNetworkHost(hostname) {
|
||||
return nil
|
||||
}
|
||||
return GatewayConnectDeepLink(
|
||||
return GatewayConnectDeepLink.validated(
|
||||
host: hostname,
|
||||
port: parsed.port ?? defaultGatewayPort(tls: tls),
|
||||
tls: tls,
|
||||
@@ -232,6 +241,24 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
password: password)
|
||||
}
|
||||
|
||||
fileprivate static func validated(
|
||||
host: String,
|
||||
port: Int,
|
||||
tls: Bool,
|
||||
bootstrapToken: String?,
|
||||
token: String?,
|
||||
password: String?) -> GatewayConnectDeepLink?
|
||||
{
|
||||
let link = GatewayConnectDeepLink(
|
||||
host: host,
|
||||
port: port,
|
||||
tls: tls,
|
||||
bootstrapToken: bootstrapToken,
|
||||
token: token,
|
||||
password: password)
|
||||
return link.isValidEndpoint ? link : nil
|
||||
}
|
||||
|
||||
private static func decodeBase64Url(_ input: String) -> Data? {
|
||||
var base64 = input
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
@@ -342,18 +369,27 @@ public enum DeepLinkParser {
|
||||
return nil
|
||||
}
|
||||
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
||||
let port = query["port"].flatMap { Int($0) } ?? defaultGatewayPort(tls: tls)
|
||||
let port: Int
|
||||
if let rawPort = query["port"] {
|
||||
guard let parsedPort = Int(rawPort) else { return nil }
|
||||
port = parsedPort
|
||||
} else {
|
||||
port = defaultGatewayPort(tls: tls)
|
||||
}
|
||||
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
|
||||
return nil
|
||||
}
|
||||
return .gateway(
|
||||
.init(
|
||||
host: hostParam,
|
||||
port: port,
|
||||
tls: tls,
|
||||
bootstrapToken: nil,
|
||||
token: query["token"],
|
||||
password: query["password"]))
|
||||
guard let link = GatewayConnectDeepLink.validated(
|
||||
host: hostParam,
|
||||
port: port,
|
||||
tls: tls,
|
||||
bootstrapToken: nil,
|
||||
token: query["token"],
|
||||
password: query["password"])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return .gateway(link)
|
||||
|
||||
case "dashboard":
|
||||
return .dashboard
|
||||
|
||||
@@ -5,12 +5,14 @@ public struct DeviceAuthEntry: Codable, Sendable {
|
||||
public let role: String
|
||||
public let scopes: [String]
|
||||
public let updatedAtMs: Int
|
||||
public let gatewayID: String?
|
||||
|
||||
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
|
||||
public init(token: String, role: String, scopes: [String], updatedAtMs: Int, gatewayID: String? = nil) {
|
||||
self.token = token
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.updatedAtMs = updatedAtMs
|
||||
self.gatewayID = gatewayID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +26,11 @@ public enum DeviceAuthStore {
|
||||
public static func loadToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
gatewayID: String? = nil,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
|
||||
{
|
||||
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
|
||||
let role = self.normalizeRole(role)
|
||||
return store.tokens[role]
|
||||
return store.tokens[self.tokenKey(role: role, gatewayID: gatewayID)]
|
||||
}
|
||||
|
||||
public static func storeToken(
|
||||
@@ -36,7 +38,25 @@ public enum DeviceAuthStore {
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String] = [],
|
||||
gatewayID: String? = nil,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
|
||||
{
|
||||
self.storeTokenResult(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes,
|
||||
gatewayID: gatewayID,
|
||||
profile: profile).entry
|
||||
}
|
||||
|
||||
static func storeTokenResult(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String] = [],
|
||||
gatewayID: String? = nil,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> (entry: DeviceAuthEntry, persisted: Bool)
|
||||
{
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
var next = self.readStore(profile: profile)
|
||||
@@ -47,26 +67,31 @@ public enum DeviceAuthStore {
|
||||
token: token,
|
||||
role: normalizedRole,
|
||||
scopes: normalizeScopes(scopes),
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
gatewayID: self.normalizeGatewayID(gatewayID))
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
self.writeStore(store, profile: profile)
|
||||
}
|
||||
return entry
|
||||
next?.tokens[self.tokenKey(role: normalizedRole, gatewayID: gatewayID)] = entry
|
||||
let persisted = next.map { self.writeStore($0, profile: profile) } ?? false
|
||||
return (entry, persisted)
|
||||
}
|
||||
|
||||
public static func clearToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
gatewayID: String? = nil,
|
||||
profile: GatewayDeviceIdentityProfile = .primary)
|
||||
{
|
||||
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
guard store.tokens[normalizedRole] != nil else { return }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
if gatewayID == nil {
|
||||
store.tokens = store.tokens.filter { _, entry in
|
||||
self.normalizeRole(entry.role) != normalizedRole
|
||||
}
|
||||
} else {
|
||||
store.tokens.removeValue(forKey: self.tokenKey(role: normalizedRole, gatewayID: gatewayID))
|
||||
}
|
||||
self.writeStore(store, profile: profile)
|
||||
}
|
||||
|
||||
@@ -74,10 +99,66 @@ public enum DeviceAuthStore {
|
||||
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
|
||||
}
|
||||
|
||||
/// Claims one legacy role token for a caller-proven gateway identity.
|
||||
/// Roles can have different gateway owners, so bulk migration is never safe.
|
||||
@discardableResult
|
||||
public static func migrateUnscopedToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
toGatewayID gatewayID: String,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> Bool
|
||||
{
|
||||
guard let gatewayID = self.normalizeGatewayID(gatewayID),
|
||||
var store = self.readStore(profile: profile),
|
||||
store.deviceId == deviceId
|
||||
else { return false }
|
||||
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil)
|
||||
guard let entry = store.tokens[legacyKey], entry.gatewayID == nil else { return false }
|
||||
let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID)
|
||||
if store.tokens[scopedKey] == nil {
|
||||
store.tokens[scopedKey] = DeviceAuthEntry(
|
||||
token: entry.token,
|
||||
role: normalizedRole,
|
||||
scopes: entry.scopes,
|
||||
updatedAtMs: entry.updatedAtMs,
|
||||
gatewayID: gatewayID)
|
||||
}
|
||||
store.tokens.removeValue(forKey: legacyKey)
|
||||
return self.writeStore(store, profile: profile)
|
||||
}
|
||||
|
||||
/// Removes legacy tokens when the app cannot prove which gateway issued them.
|
||||
@discardableResult
|
||||
public static func discardUnscopedTokens(
|
||||
deviceId: String,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> Int
|
||||
{
|
||||
guard var store = self.readStore(profile: profile), store.deviceId == deviceId else { return 0 }
|
||||
let legacyKeys = store.tokens.compactMap { key, entry in entry.gatewayID == nil ? key : nil }
|
||||
guard !legacyKeys.isEmpty else { return 0 }
|
||||
for key in legacyKeys {
|
||||
store.tokens.removeValue(forKey: key)
|
||||
}
|
||||
return self.writeStore(store, profile: profile) ? legacyKeys.count : 0
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func normalizeGatewayID(_ gatewayID: String?) -> String? {
|
||||
let trimmed = gatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func tokenKey(role: String, gatewayID: String?) -> String {
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return normalizedRole }
|
||||
return "\(gatewayID)\u{1F}\(normalizedRole)"
|
||||
}
|
||||
|
||||
private static func normalizeScopes(_ scopes: [String]) -> [String] {
|
||||
let trimmed = scopes
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
@@ -101,7 +182,11 @@ public enum DeviceAuthStore {
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
|
||||
@discardableResult
|
||||
private static func writeStore(
|
||||
_ store: DeviceAuthStoreFile,
|
||||
profile: GatewayDeviceIdentityProfile) -> Bool
|
||||
{
|
||||
let url = self.fileURL(profile: profile)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
@@ -110,8 +195,9 @@ public enum DeviceAuthStore {
|
||||
let data = try JSONEncoder().encode(store)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
return true
|
||||
} catch {
|
||||
// best-effort only
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ public struct GatewayConnectOptions: Sendable {
|
||||
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
|
||||
/// role/scoped sessions such as operator UI clients.
|
||||
public var includeDeviceIdentity: Bool
|
||||
/// Set false for an endpoint handoff whose explicit credentials (including none) must be
|
||||
/// tried without reusing a device token issued by a different gateway.
|
||||
public var allowStoredDeviceAuth: Bool
|
||||
/// Stable gateway owner for device tokens. Nil preserves legacy unscoped storage for clients
|
||||
/// that have not adopted endpoint ownership yet.
|
||||
public var deviceAuthGatewayID: String?
|
||||
|
||||
public init(
|
||||
role: String,
|
||||
@@ -124,7 +130,9 @@ public struct GatewayConnectOptions: Sendable {
|
||||
clientMode: String,
|
||||
clientDisplayName: String?,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
|
||||
includeDeviceIdentity: Bool = true)
|
||||
includeDeviceIdentity: Bool = true,
|
||||
allowStoredDeviceAuth: Bool = true,
|
||||
deviceAuthGatewayID: String? = nil)
|
||||
{
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
@@ -137,6 +145,8 @@ public struct GatewayConnectOptions: Sendable {
|
||||
self.clientDisplayName = clientDisplayName
|
||||
self.deviceIdentityProfile = deviceIdentityProfile
|
||||
self.includeDeviceIdentity = includeDeviceIdentity
|
||||
self.allowStoredDeviceAuth = allowStoredDeviceAuth
|
||||
self.deviceAuthGatewayID = deviceAuthGatewayID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,10 +286,11 @@ public actor GatewayChannelActor {
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var pendingDeviceTokenRetry = false
|
||||
private var deviceTokenRetryBudgetUsed = false
|
||||
private var issuedDeviceAuthRoles = Set<String>()
|
||||
private var reconnectPausedForAuthFailure = false
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
private var connectOptions: GatewayConnectOptions?
|
||||
private let disconnectHandler: (@Sendable (String) async -> Void)?
|
||||
|
||||
public init(
|
||||
@@ -470,10 +481,14 @@ public actor GatewayChannelActor {
|
||||
let requestedScopes = options.scopes
|
||||
let scopesAreExplicit = options.scopesAreExplicit
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity
|
||||
let allowStoredDeviceAuth = options.allowStoredDeviceAuth
|
||||
let deviceAuthGatewayID = options.deviceAuthGatewayID
|
||||
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
|
||||
let selectedAuth = self.selectConnectAuth(
|
||||
role: role,
|
||||
includeDeviceIdentity: includeDeviceIdentity,
|
||||
allowStoredDeviceAuth: allowStoredDeviceAuth,
|
||||
deviceAuthGatewayID: deviceAuthGatewayID,
|
||||
deviceIdentityProfile: deviceIdentityProfile,
|
||||
deviceId: identity?.deviceId,
|
||||
requestedScopes: requestedScopes)
|
||||
@@ -564,11 +579,17 @@ public actor GatewayChannelActor {
|
||||
try await self.task?.send(.data(data))
|
||||
do {
|
||||
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||
try await self.handleConnectResponse(
|
||||
let issuedRoles = try await self.handleConnectResponse(
|
||||
response,
|
||||
identity: identity,
|
||||
role: role,
|
||||
deviceAuthGatewayID: deviceAuthGatewayID,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
self.issuedDeviceAuthRoles.formUnion(issuedRoles)
|
||||
if issuedRoles.contains(role) {
|
||||
// Only a token persisted from this endpoint may unlock stored auth for its role.
|
||||
self.connectOptions?.allowStoredDeviceAuth = true
|
||||
}
|
||||
self.pendingDeviceTokenRetry = false
|
||||
self.deviceTokenRetryBudgetUsed = false
|
||||
} catch {
|
||||
@@ -589,6 +610,7 @@ public actor GatewayChannelActor {
|
||||
DeviceAuthStore.clearToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: role,
|
||||
gatewayID: deviceAuthGatewayID,
|
||||
profile: deviceIdentityProfile)
|
||||
}
|
||||
throw error
|
||||
@@ -598,6 +620,8 @@ public actor GatewayChannelActor {
|
||||
private func selectConnectAuth(
|
||||
role: String,
|
||||
includeDeviceIdentity: Bool,
|
||||
allowStoredDeviceAuth: Bool,
|
||||
deviceAuthGatewayID: String?,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile,
|
||||
deviceId: String?,
|
||||
requestedScopes: [String]) -> SelectedConnectAuth
|
||||
@@ -607,8 +631,12 @@ public actor GatewayChannelActor {
|
||||
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
let storedEntry =
|
||||
(includeDeviceIdentity && deviceId != nil)
|
||||
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
|
||||
(includeDeviceIdentity && allowStoredDeviceAuth && deviceId != nil)
|
||||
? DeviceAuthStore.loadToken(
|
||||
deviceId: deviceId!,
|
||||
role: role,
|
||||
gatewayID: deviceAuthGatewayID,
|
||||
profile: deviceIdentityProfile)
|
||||
: nil
|
||||
let storedToken = storedEntry?.token
|
||||
let storedScopes = storedEntry?.scopes ?? []
|
||||
@@ -793,22 +821,25 @@ public actor GatewayChannelActor {
|
||||
return requestedScopes
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func persistBootstrapHandoffToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String],
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile)
|
||||
deviceAuthGatewayID: String?,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile) -> Bool
|
||||
{
|
||||
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
||||
return
|
||||
return false
|
||||
}
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
return DeviceAuthStore.storeTokenResult(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: filteredScopes,
|
||||
profile: deviceIdentityProfile)
|
||||
gatewayID: deviceAuthGatewayID,
|
||||
profile: deviceIdentityProfile).persisted
|
||||
}
|
||||
|
||||
private func persistIssuedDeviceToken(
|
||||
@@ -817,33 +848,36 @@ public actor GatewayChannelActor {
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String],
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile)
|
||||
deviceAuthGatewayID: String?,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile) -> Bool
|
||||
{
|
||||
if authSource == .bootstrapToken {
|
||||
guard self.shouldPersistBootstrapHandoffTokens() else {
|
||||
return
|
||||
return false
|
||||
}
|
||||
self.persistBootstrapHandoffToken(
|
||||
return self.persistBootstrapHandoffToken(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes,
|
||||
deviceAuthGatewayID: deviceAuthGatewayID,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
return
|
||||
}
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
return DeviceAuthStore.storeTokenResult(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes,
|
||||
profile: deviceIdentityProfile)
|
||||
gatewayID: deviceAuthGatewayID,
|
||||
profile: deviceIdentityProfile).persisted
|
||||
}
|
||||
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity?,
|
||||
role: String,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
|
||||
deviceAuthGatewayID: String?,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws -> Set<String>
|
||||
{
|
||||
if res.ok == false {
|
||||
let error = res.error
|
||||
@@ -900,18 +934,23 @@ public actor GatewayChannelActor {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
let auth = ok.auth
|
||||
var issuedRoles = Set<String>()
|
||||
if let identity {
|
||||
if let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
self.persistIssuedDeviceToken(
|
||||
if self.persistIssuedDeviceToken(
|
||||
authSource: self.lastAuthSource,
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes,
|
||||
deviceAuthGatewayID: deviceAuthGatewayID,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
{
|
||||
issuedRoles.insert(authRole)
|
||||
}
|
||||
}
|
||||
if self.shouldPersistBootstrapHandoffTokens(),
|
||||
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
|
||||
@@ -925,12 +964,16 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
self.persistBootstrapHandoffToken(
|
||||
if self.persistBootstrapHandoffToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes,
|
||||
deviceAuthGatewayID: deviceAuthGatewayID,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
{
|
||||
issuedRoles.insert(authRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,6 +986,11 @@ public actor GatewayChannelActor {
|
||||
if let pushHandler = self.pushHandler {
|
||||
Task { await pushHandler(.snapshot(ok)) }
|
||||
}
|
||||
return issuedRoles
|
||||
}
|
||||
|
||||
public func currentIssuedDeviceAuthRoles() -> Set<String> {
|
||||
self.issuedDeviceAuthRoles
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
|
||||
@@ -42,6 +42,12 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
|
||||
return parsed.string ?? trimmed
|
||||
}
|
||||
|
||||
/// Binds suspended work to one installed gateway channel generation.
|
||||
/// Callers use this lease so an actor hop cannot retarget a payload to a replacement gateway.
|
||||
public struct GatewayNodeSessionRoute: Sendable, Equatable {
|
||||
fileprivate let channelGeneration: UInt64
|
||||
}
|
||||
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
@@ -54,6 +60,7 @@ public actor GatewayNodeSession {
|
||||
private var activePassword: String?
|
||||
private var activeConnectOptionsKey: String?
|
||||
private var activeSessionIdentity: ObjectIdentifier?
|
||||
private var channelGeneration: UInt64 = 0
|
||||
private var connectOptions: GatewayConnectOptions?
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
@@ -164,6 +171,9 @@ public actor GatewayNodeSession {
|
||||
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
|
||||
let allowStoredDeviceAuth = options.allowStoredDeviceAuth ? "1" : "0"
|
||||
let deviceAuthGatewayID = options.deviceAuthGatewayID?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let permissions = options.permissions
|
||||
.map { key, value in
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -182,6 +192,8 @@ public actor GatewayNodeSession {
|
||||
clientDisplayName,
|
||||
deviceIdentityProfile,
|
||||
includeDeviceIdentity,
|
||||
allowStoredDeviceAuth,
|
||||
deviceAuthGatewayID,
|
||||
permissions,
|
||||
].joined(separator: "|")
|
||||
}
|
||||
@@ -212,11 +224,20 @@ public actor GatewayNodeSession {
|
||||
self.onDisconnected = onDisconnected
|
||||
self.onInvoke = onInvoke
|
||||
|
||||
let channelGeneration: UInt64
|
||||
if shouldReconnect {
|
||||
self.channelGeneration &+= 1
|
||||
channelGeneration = self.channelGeneration
|
||||
self.resetConnectionState()
|
||||
if let existing = self.channel {
|
||||
// Detach before suspension so callers cannot lease the old channel with
|
||||
// the replacement generation while shutdown is in flight.
|
||||
self.channel = nil
|
||||
await existing.shutdown()
|
||||
}
|
||||
// A newer connect or disconnect can run while shutdown suspends. Never let the
|
||||
// superseded call install its endpoint or credentials afterward.
|
||||
guard self.channelGeneration == channelGeneration else { throw CancellationError() }
|
||||
let channel = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
@@ -224,11 +245,11 @@ public actor GatewayNodeSession {
|
||||
password: password,
|
||||
session: sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handlePush(push)
|
||||
await self?.handlePush(push, channelGeneration: channelGeneration)
|
||||
},
|
||||
connectOptions: connectOptions,
|
||||
disconnectHandler: { [weak self] reason in
|
||||
await self?.handleChannelDisconnected(reason)
|
||||
await self?.handleChannelDisconnected(reason, channelGeneration: channelGeneration)
|
||||
})
|
||||
self.channel = channel
|
||||
self.activeURL = url
|
||||
@@ -237,6 +258,8 @@ public actor GatewayNodeSession {
|
||||
self.activePassword = password
|
||||
self.activeConnectOptionsKey = nextOptionsKey
|
||||
self.activeSessionIdentity = nextSessionIdentity
|
||||
} else {
|
||||
channelGeneration = self.channelGeneration
|
||||
}
|
||||
|
||||
guard let channel = self.channel else {
|
||||
@@ -247,7 +270,13 @@ public actor GatewayNodeSession {
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
guard self.channelGeneration == channelGeneration,
|
||||
self.channel === channel
|
||||
else { throw CancellationError() }
|
||||
_ = await self.waitForSnapshot(timeoutMs: 500)
|
||||
guard self.channelGeneration == channelGeneration,
|
||||
self.channel === channel
|
||||
else { throw CancellationError() }
|
||||
await self.notifyConnectedIfNeeded()
|
||||
} catch {
|
||||
throw error
|
||||
@@ -255,7 +284,8 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
await self.channel?.shutdown()
|
||||
self.channelGeneration &+= 1
|
||||
let channel = self.channel
|
||||
self.channel = nil
|
||||
self.activeURL = nil
|
||||
self.activeToken = nil
|
||||
@@ -265,6 +295,12 @@ public actor GatewayNodeSession {
|
||||
self.activeSessionIdentity = nil
|
||||
self.hasEverConnected = false
|
||||
self.resetConnectionState()
|
||||
await channel?.shutdown()
|
||||
}
|
||||
|
||||
public func currentIssuedDeviceAuthRoles() async -> Set<String> {
|
||||
guard let channel else { return [] }
|
||||
return await channel.currentIssuedDeviceAuthRoles()
|
||||
}
|
||||
|
||||
public func currentCanvasHostUrl() -> String? {
|
||||
@@ -300,16 +336,31 @@ public actor GatewayNodeSession {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
public func sendEvent(event: String, payloadJSON: String?) async {
|
||||
guard let channel = self.channel else { return }
|
||||
public func currentRoute() -> GatewayNodeSessionRoute? {
|
||||
guard self.channel != nil else { return nil }
|
||||
return GatewayNodeSessionRoute(channelGeneration: self.channelGeneration)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func sendEvent(
|
||||
event: String,
|
||||
payloadJSON: String?,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async -> Bool
|
||||
{
|
||||
if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration {
|
||||
return false
|
||||
}
|
||||
guard let channel = self.channel else { return false }
|
||||
let params: [String: AnyCodable] = [
|
||||
"event": AnyCodable(event),
|
||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||
]
|
||||
do {
|
||||
try await channel.send(method: "node.event", params: params)
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +375,15 @@ public actor GatewayNodeSession {
|
||||
try await channel.send(method: method, params: params)
|
||||
}
|
||||
|
||||
public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
|
||||
public func request(
|
||||
method: String,
|
||||
paramsJSON: String?,
|
||||
timeoutSeconds: Int = 15,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> Data
|
||||
{
|
||||
if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard let channel = self.channel else {
|
||||
throw NSError(domain: "Gateway", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
@@ -349,7 +408,8 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePush(_ push: GatewayPush) async {
|
||||
private func handlePush(_ push: GatewayPush, channelGeneration: UInt64) async {
|
||||
guard self.channelGeneration == channelGeneration else { return }
|
||||
switch push {
|
||||
case let .snapshot(ok):
|
||||
self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls)
|
||||
@@ -361,7 +421,11 @@ public actor GatewayNodeSession {
|
||||
self.markSnapshotReceived()
|
||||
await self.notifyConnectedIfNeeded()
|
||||
case let .event(evt):
|
||||
await self.handleEvent(evt)
|
||||
guard let channel = self.channel else { return }
|
||||
await self.handleEvent(
|
||||
evt,
|
||||
channel: channel,
|
||||
channelGeneration: channelGeneration)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -373,7 +437,8 @@ public actor GatewayNodeSession {
|
||||
self.drainSnapshotWaiters(returning: false)
|
||||
}
|
||||
|
||||
private func handleChannelDisconnected(_ reason: String) async {
|
||||
private func handleChannelDisconnected(_ reason: String, channelGeneration: UInt64) async {
|
||||
guard self.channelGeneration == channelGeneration else { return }
|
||||
// The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh
|
||||
// onConnected callback once a new snapshot arrives after reconnect.
|
||||
self.resetConnectionState()
|
||||
@@ -456,7 +521,11 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ evt: EventFrame) async {
|
||||
private func handleEvent(
|
||||
_ evt: EventFrame,
|
||||
channel: GatewayChannelActor,
|
||||
channelGeneration: UInt64) async
|
||||
{
|
||||
self.broadcastServerEvent(evt)
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
self.logger.info("node invoke request received")
|
||||
@@ -477,9 +546,14 @@ public actor GatewayNodeSession {
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke)
|
||||
// Invoke output belongs to the requesting channel. A target switch while the device
|
||||
// command is running must discard it instead of disclosing it to the replacement.
|
||||
guard self.channelGeneration == channelGeneration,
|
||||
self.channel === channel
|
||||
else { return }
|
||||
self.logger.info(
|
||||
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
await self.sendInvokeResult(request: request, response: response, channel: channel)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -497,8 +571,11 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
|
||||
guard let channel = self.channel else { return }
|
||||
private func sendInvokeResult(
|
||||
request: NodeInvokeRequestPayload,
|
||||
response: BridgeInvokeResponse,
|
||||
channel: GatewayChannelActor) async
|
||||
{
|
||||
self.logger.info(
|
||||
"node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
var params: [String: AnyCodable] = [
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
|
||||
public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
public let gatewayURLString: String
|
||||
public let gatewayStableID: String?
|
||||
public let token: String?
|
||||
public let password: String?
|
||||
public let sessionKey: String
|
||||
@@ -10,6 +11,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
|
||||
public init(
|
||||
gatewayURLString: String,
|
||||
gatewayStableID: String? = nil,
|
||||
token: String?,
|
||||
password: String?,
|
||||
sessionKey: String,
|
||||
@@ -17,6 +19,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
deliveryTo: String? = nil)
|
||||
{
|
||||
self.gatewayURLString = gatewayURLString
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.sessionKey = sessionKey
|
||||
@@ -26,7 +29,10 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public enum ShareGatewayRelaySettings {
|
||||
private static var suiteName: String { OpenClawAppGroup.identifier }
|
||||
private static var suiteName: String {
|
||||
OpenClawAppGroup.identifier
|
||||
}
|
||||
|
||||
private static let relayConfigKey = "share.gatewayRelay.config.v1"
|
||||
private static let lastEventKey = "share.gatewayRelay.event.v1"
|
||||
|
||||
@@ -39,6 +45,22 @@ public enum ShareGatewayRelaySettings {
|
||||
return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data)
|
||||
}
|
||||
|
||||
/// An endpoint is not a gateway identity. If the extension launches before the
|
||||
/// host can prove a stable ID, discard unscoped device auth and use explicit auth only.
|
||||
public static func loadConfigDiscardingUnscopedDeviceAuth() -> ShareGatewayRelayConfig? {
|
||||
guard let config = self.loadConfig() else { return nil }
|
||||
if let gatewayID = config.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!gatewayID.isEmpty
|
||||
{
|
||||
return config
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
||||
DeviceAuthStore.discardUnscopedTokens(
|
||||
deviceId: identity.deviceId,
|
||||
profile: .shareExtension)
|
||||
return config
|
||||
}
|
||||
|
||||
public static func saveConfig(_ config: ShareGatewayRelayConfig) {
|
||||
guard let data = try? JSONEncoder().encode(config) else { return }
|
||||
self.defaults.set(data, forKey: self.relayConfigKey)
|
||||
|
||||
@@ -53,6 +53,7 @@ public struct OpenClawWatchAction: Codable, Sendable, Equatable {
|
||||
|
||||
public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
||||
public var id: String
|
||||
public var gatewayStableID: String?
|
||||
public var commandText: String
|
||||
public var commandPreview: String?
|
||||
public var host: String?
|
||||
@@ -64,6 +65,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
gatewayStableID: String? = nil,
|
||||
commandText: String,
|
||||
commandPreview: String? = nil,
|
||||
host: String? = nil,
|
||||
@@ -74,6 +76,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
|
||||
risk: OpenClawWatchRisk? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.commandText = commandText
|
||||
self.commandPreview = commandPreview
|
||||
self.host = host
|
||||
@@ -109,18 +112,21 @@ public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatab
|
||||
public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvalId: String
|
||||
public var gatewayStableID: String?
|
||||
public var decision: OpenClawWatchExecApprovalDecision
|
||||
public var replyId: String
|
||||
public var sentAtMs: Int?
|
||||
|
||||
public init(
|
||||
approvalId: String,
|
||||
gatewayStableID: String? = nil,
|
||||
decision: OpenClawWatchExecApprovalDecision,
|
||||
replyId: String,
|
||||
sentAtMs: Int? = nil)
|
||||
{
|
||||
self.type = .execApprovalResolve
|
||||
self.approvalId = approvalId
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.decision = decision
|
||||
self.replyId = replyId
|
||||
self.sentAtMs = sentAtMs
|
||||
@@ -130,18 +136,21 @@ public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equata
|
||||
public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvalId: String
|
||||
public var gatewayStableID: String?
|
||||
public var decision: OpenClawWatchExecApprovalDecision?
|
||||
public var resolvedAtMs: Int?
|
||||
public var source: String?
|
||||
|
||||
public init(
|
||||
approvalId: String,
|
||||
gatewayStableID: String? = nil,
|
||||
decision: OpenClawWatchExecApprovalDecision? = nil,
|
||||
resolvedAtMs: Int? = nil,
|
||||
source: String? = nil)
|
||||
{
|
||||
self.type = .execApprovalResolved
|
||||
self.approvalId = approvalId
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.decision = decision
|
||||
self.resolvedAtMs = resolvedAtMs
|
||||
self.source = source
|
||||
@@ -151,16 +160,19 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat
|
||||
public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvalId: String
|
||||
public var gatewayStableID: String?
|
||||
public var reason: OpenClawWatchExecApprovalCloseReason
|
||||
public var expiredAtMs: Int?
|
||||
|
||||
public init(
|
||||
approvalId: String,
|
||||
gatewayStableID: String? = nil,
|
||||
reason: OpenClawWatchExecApprovalCloseReason,
|
||||
expiredAtMs: Int? = nil)
|
||||
{
|
||||
self.type = .execApprovalExpired
|
||||
self.approvalId = approvalId
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.reason = reason
|
||||
self.expiredAtMs = expiredAtMs
|
||||
}
|
||||
@@ -169,16 +181,19 @@ public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equata
|
||||
public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvals: [OpenClawWatchExecApprovalItem]
|
||||
public var gatewayStableID: String?
|
||||
public var sentAtMs: Int?
|
||||
public var snapshotId: String?
|
||||
|
||||
public init(
|
||||
approvals: [OpenClawWatchExecApprovalItem],
|
||||
gatewayStableID: String? = nil,
|
||||
sentAtMs: Int? = nil,
|
||||
snapshotId: String? = nil)
|
||||
{
|
||||
self.type = .execApprovalSnapshot
|
||||
self.approvals = approvals
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.sentAtMs = sentAtMs
|
||||
self.snapshotId = snapshotId
|
||||
}
|
||||
@@ -361,6 +376,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||
public var priority: OpenClawNotificationPriority?
|
||||
public var promptId: String?
|
||||
public var sessionKey: String?
|
||||
public var gatewayStableID: String?
|
||||
public var kind: String?
|
||||
public var details: String?
|
||||
public var expiresAtMs: Int?
|
||||
@@ -373,6 +389,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||
priority: OpenClawNotificationPriority? = nil,
|
||||
promptId: String? = nil,
|
||||
sessionKey: String? = nil,
|
||||
gatewayStableID: String? = nil,
|
||||
kind: String? = nil,
|
||||
details: String? = nil,
|
||||
expiresAtMs: Int? = nil,
|
||||
@@ -384,6 +401,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||
self.priority = priority
|
||||
self.promptId = promptId
|
||||
self.sessionKey = sessionKey
|
||||
self.gatewayStableID = gatewayStableID
|
||||
self.kind = kind
|
||||
self.details = details
|
||||
self.expiresAtMs = expiresAtMs
|
||||
|
||||
@@ -2471,7 +2471,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
label: AnyCodable?,
|
||||
category: AnyCodable?,
|
||||
category: AnyCodable? = nil,
|
||||
archived: Bool? = nil,
|
||||
pinned: Bool? = nil,
|
||||
thinkinglevel: AnyCodable?,
|
||||
@@ -7199,7 +7199,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
alloweddecisions: [String]?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String?,
|
||||
approvalreviewerdeviceids: [String]?,
|
||||
approvalreviewerdeviceids: [String]? = nil,
|
||||
turnsourcechannel: String?,
|
||||
turnsourceto: String?,
|
||||
turnsourceaccountid: String?,
|
||||
|
||||
@@ -5,6 +5,154 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceIdentityStoreTests {
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `device auth store reports failed durable writes`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
|
||||
try Data().write(to: blocker)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let compatibleEntry: DeviceAuthEntry = DeviceAuthStore.storeToken(
|
||||
deviceId: "unwritable-device",
|
||||
role: "node",
|
||||
token: "must-not-be-acknowledged")
|
||||
let stored = DeviceAuthStore.storeTokenResult(
|
||||
deviceId: "unwritable-device",
|
||||
role: "node",
|
||||
token: "must-not-be-acknowledged")
|
||||
|
||||
#expect(compatibleEntry.token == "must-not-be-acknowledged")
|
||||
#expect(!stored.persisted)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil)
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `device auth tokens are isolated by gateway owner`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let deviceID = "test-device"
|
||||
_ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "legacy-token")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil)
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "gateway-a-token",
|
||||
gatewayID: "gateway-a")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "gateway-b-token",
|
||||
gatewayID: "gateway-b")
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "gateway-a")?.token == "gateway-a-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "gateway-b")?.token == "gateway-b-token")
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "gateway-a")?.token == "gateway-a-token")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b") == nil)
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil)
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `legacy device auth migration claims only the proven role`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let deviceID = "legacy-device"
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "legacy-node-token")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
token: "legacy-operator-token")
|
||||
|
||||
#expect(DeviceAuthStore.migrateUnscopedToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
toGatewayID: "trusted-gateway"))
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "trusted-gateway")?.token == "legacy-node-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
gatewayID: "trusted-gateway") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator")?.token == "legacy-operator-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "other-gateway") == nil)
|
||||
#expect(!DeviceAuthStore.migrateUnscopedToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
toGatewayID: "other-gateway"))
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "ambiguous-legacy-token")
|
||||
#expect(DeviceAuthStore.discardUnscopedTokens(deviceId: deviceID) == 2)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "trusted-gateway")?.token == "legacy-node-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `state directory override wins over shared app group storage`() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
@@ -48,7 +196,7 @@ struct DeviceIdentityStoreTests {
|
||||
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `secondary profiles use separate identity and auth files`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
|
||||
+23
-3
@@ -3,7 +3,28 @@ import Testing
|
||||
|
||||
struct GatewayModelsCompatibilityTests {
|
||||
@Test
|
||||
func messageActionParamsKeepsRequesterAccountAdditive() {
|
||||
func `plugin approval request params keeps reviewer devices additive`() {
|
||||
let params = PluginApprovalRequestParams(
|
||||
pluginid: nil,
|
||||
title: "Install plugin",
|
||||
description: "Review requested",
|
||||
severity: nil,
|
||||
toolname: nil,
|
||||
toolcallid: nil,
|
||||
alloweddecisions: nil,
|
||||
sessionkey: nil,
|
||||
turnsourcechannel: nil,
|
||||
turnsourceto: nil,
|
||||
turnsourceaccountid: nil,
|
||||
turnsourcethreadid: nil,
|
||||
timeoutms: nil,
|
||||
twophase: nil)
|
||||
|
||||
#expect(params.approvalreviewerdeviceids == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `message action params keeps requester account additive`() {
|
||||
let params = MessageActionParams(
|
||||
channel: "slack",
|
||||
action: "member-info",
|
||||
@@ -14,8 +35,7 @@ struct GatewayModelsCompatibilityTests {
|
||||
sessionkey: nil,
|
||||
sessionid: nil,
|
||||
toolcontext: nil,
|
||||
idempotencykey: "test"
|
||||
)
|
||||
idempotencykey: "test")
|
||||
|
||||
#expect(params.requesteraccountid == nil)
|
||||
}
|
||||
|
||||
@@ -49,21 +49,63 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
|
||||
}
|
||||
}
|
||||
|
||||
private final class FirstCancelGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var shouldBlock = true
|
||||
private var started = false
|
||||
private var released = false
|
||||
|
||||
func blockIfNeeded() {
|
||||
self.condition.lock()
|
||||
guard self.shouldBlock else {
|
||||
self.condition.unlock()
|
||||
return
|
||||
}
|
||||
self.shouldBlock = false
|
||||
self.started = true
|
||||
self.condition.broadcast()
|
||||
while !self.released {
|
||||
self.condition.wait()
|
||||
}
|
||||
self.condition.unlock()
|
||||
}
|
||||
|
||||
func hasStarted() -> Bool {
|
||||
self.condition.lock()
|
||||
defer { self.condition.unlock() }
|
||||
return self.started
|
||||
}
|
||||
|
||||
func release() {
|
||||
self.condition.lock()
|
||||
self.released = true
|
||||
self.condition.broadcast()
|
||||
self.condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let helloAuth: [String: Any]?
|
||||
private let connectError: [String: Any]?
|
||||
private let cancelGate: FirstCancelGate?
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestId: String?
|
||||
private var connectAuth: [String: Any]?
|
||||
private var connectDevice: [String: Any]?
|
||||
private var sentRequestMethods: [String] = []
|
||||
private var receivePhase = 0
|
||||
private var pendingReceiveHandler:
|
||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
|
||||
init(helloAuth: [String: Any]? = nil, connectError: [String: Any]? = nil) {
|
||||
init(
|
||||
helloAuth: [String: Any]? = nil,
|
||||
connectError: [String: Any]? = nil,
|
||||
cancelGate: FirstCancelGate? = nil)
|
||||
{
|
||||
self.helloAuth = helloAuth
|
||||
self.connectError = connectError
|
||||
self.cancelGate = cancelGate
|
||||
}
|
||||
|
||||
var state: URLSessionTask.State {
|
||||
@@ -78,6 +120,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
_ = (closeCode, reason)
|
||||
self.state = .canceling
|
||||
self.cancelGate?.blockIfNeeded()
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<
|
||||
URLSessionWebSocketTask.Message,
|
||||
Error,
|
||||
@@ -97,9 +140,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
guard let data else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["type"] as? String == "req",
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
let method = obj["method"] as? String
|
||||
{
|
||||
self.lock.withLock { self.sentRequestMethods.append(method) }
|
||||
guard method == "connect", let id = obj["id"] as? String else { return }
|
||||
let params = obj["params"] as? [String: Any]
|
||||
let auth = (params?["auth"] as? [String: Any]) ?? [:]
|
||||
let device = params?["device"] as? [String: Any]
|
||||
@@ -119,6 +163,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
self.lock.withLock { self.connectDevice }
|
||||
}
|
||||
|
||||
func sentRequestCount(method: String) -> Int {
|
||||
self.lock.withLock { self.sentRequestMethods.count(where: { $0 == method }) }
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
@@ -166,6 +214,17 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
|
||||
}
|
||||
|
||||
func emitInvokeRequest(id: String, command: String) {
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<
|
||||
URLSessionWebSocketTask.Message,
|
||||
Error,
|
||||
>) -> Void)? in
|
||||
defer { self.pendingReceiveHandler = nil }
|
||||
return self.pendingReceiveHandler
|
||||
}
|
||||
handler?(.success(.data(Self.invokeRequestData(id: id, command: command))))
|
||||
}
|
||||
|
||||
private static func connectChallengeData(nonce: String) -> Data {
|
||||
let frame: [String: Any] = [
|
||||
"type": "event",
|
||||
@@ -224,18 +283,38 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
]
|
||||
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
|
||||
}
|
||||
|
||||
private static func invokeRequestData(id: String, command: String) -> Data {
|
||||
let frame: [String: Any] = [
|
||||
"type": "event",
|
||||
"event": "node.invoke.request",
|
||||
"payload": [
|
||||
"id": id,
|
||||
"nodeId": "test-node",
|
||||
"command": command,
|
||||
"paramsJSON": "{}",
|
||||
],
|
||||
]
|
||||
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let helloAuth: [String: Any]?
|
||||
private let connectError: [String: Any]?
|
||||
private let cancelGate: FirstCancelGate?
|
||||
private var tasks: [FakeGatewayWebSocketTask] = []
|
||||
private var makeCount = 0
|
||||
|
||||
init(helloAuth: [String: Any]? = nil, connectError: [String: Any]? = nil) {
|
||||
init(
|
||||
helloAuth: [String: Any]? = nil,
|
||||
connectError: [String: Any]? = nil,
|
||||
cancelGate: FirstCancelGate? = nil)
|
||||
{
|
||||
self.helloAuth = helloAuth
|
||||
self.connectError = connectError
|
||||
self.cancelGate = cancelGate
|
||||
}
|
||||
|
||||
func snapshotMakeCount() -> Int {
|
||||
@@ -250,7 +329,10 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
|
||||
_ = url
|
||||
return self.lock.withLock {
|
||||
self.makeCount += 1
|
||||
let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth, connectError: self.connectError)
|
||||
let task = FakeGatewayWebSocketTask(
|
||||
helloAuth: self.helloAuth,
|
||||
connectError: self.connectError,
|
||||
cancelGate: self.cancelGate)
|
||||
self.tasks.append(task)
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
@@ -268,6 +350,18 @@ private actor SeqGapProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private actor DisconnectProbe {
|
||||
private var reasons: [String] = []
|
||||
|
||||
func record(_ reason: String) {
|
||||
self.reasons.append(reason)
|
||||
}
|
||||
|
||||
func values() -> [String] {
|
||||
self.reasons
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
@@ -290,6 +384,243 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `superseded channel callbacks do not reach replacement connection`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let disconnects = DisconnectProbe()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://first.example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { reason in await disconnects.record("first:\(reason)") },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://second.example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { reason in await disconnects.record("second:\(reason)") },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
|
||||
for _ in 0..<20 {
|
||||
await Task.yield()
|
||||
}
|
||||
let replacementDisconnects = await disconnects.values()
|
||||
#expect(replacementDisconnects.isEmpty)
|
||||
|
||||
await gateway.disconnect()
|
||||
for _ in 0..<20 {
|
||||
await Task.yield()
|
||||
}
|
||||
let finalDisconnects = await disconnects.values()
|
||||
#expect(finalDisconnects.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `route bound operations never use a replacement channel`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://first.example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
let firstRoute = try #require(await gateway.currentRoute())
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://second.example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
|
||||
let sent = await gateway.sendEvent(
|
||||
event: "push.apns.register",
|
||||
payloadJSON: "{}",
|
||||
ifCurrentRoute: firstRoute)
|
||||
#expect(!sent)
|
||||
do {
|
||||
_ = try await gateway.request(
|
||||
method: "exec.approval.get",
|
||||
paramsJSON: "{}",
|
||||
ifCurrentRoute: firstRoute)
|
||||
Issue.record("stale route request unexpectedly reached the replacement channel")
|
||||
} catch is CancellationError {
|
||||
// Expected: the route lease belongs to the first channel.
|
||||
}
|
||||
let replacementTask = try #require(session.latestTask())
|
||||
#expect(replacementTask.sentRequestCount(method: "node.event") == 0)
|
||||
#expect(replacementTask.sentRequestCount(method: "exec.approval.get") == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `disconnect during channel shutdown prevents stale channel install`() async throws {
|
||||
let cancelGate = FirstCancelGate()
|
||||
let session = FakeGatewayWebSocketSession(cancelGate: cancelGate)
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://first.example.invalid")),
|
||||
token: "first-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
|
||||
let replacement = Task {
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://stale.example.invalid")),
|
||||
token: "stale-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
}
|
||||
let deadline = ContinuousClock().now.advanced(by: .seconds(2))
|
||||
while !cancelGate.hasStarted(), ContinuousClock().now < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(cancelGate.hasStarted())
|
||||
#expect(await gateway.currentRoute() == nil)
|
||||
|
||||
let release = Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
cancelGate.release()
|
||||
}
|
||||
await gateway.disconnect()
|
||||
await release.value
|
||||
do {
|
||||
try await replacement.value
|
||||
Issue.record("superseded replacement unexpectedly connected")
|
||||
} catch is CancellationError {
|
||||
// Expected: disconnect advanced the generation while old-channel shutdown was suspended.
|
||||
}
|
||||
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `invoke result is discarded after target switch`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let invokeStarted = AsyncStream<Void>.makeStream()
|
||||
let invokeRelease = AsyncStream<Void>.makeStream()
|
||||
var startedIterator = invokeStarted.stream.makeAsyncIterator()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: ["camera.snap"],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://first.example.invalid")),
|
||||
token: "first-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { request in
|
||||
invokeStarted.continuation.yield()
|
||||
for await _ in invokeRelease.stream {
|
||||
return BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payloadJSON: #"{"sensitive":"camera-result"}"#,
|
||||
error: nil)
|
||||
}
|
||||
return BridgeInvokeResponse(id: request.id, ok: false, payloadJSON: nil, error: nil)
|
||||
})
|
||||
let firstTask = try #require(session.latestTask())
|
||||
firstTask.emitInvokeRequest(id: "invoke-old", command: "camera.snap")
|
||||
_ = await startedIterator.next()
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://replacement.example.invalid")),
|
||||
token: "replacement-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
let replacementTask = try #require(session.latestTask())
|
||||
|
||||
invokeRelease.continuation.yield()
|
||||
invokeRelease.continuation.finish()
|
||||
for _ in 0..<100 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(firstTask.sentRequestCount(method: "node.invoke.result") == 0)
|
||||
#expect(replacementTask.sentRequestCount(method: "node.invoke.result") == 0)
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@@ -345,7 +676,129 @@ struct GatewayNodeSessionTests {
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `credentialless setup handoff does not send a stored device token`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "node",
|
||||
token: "previous-gateway-device-token")
|
||||
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true,
|
||||
allowStoredDeviceAuth: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://new-gateway.example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let task = try #require(session.latestTask())
|
||||
let auth = try #require(task.latestConnectAuth())
|
||||
#expect(auth["token"] == nil)
|
||||
#expect(auth["bootstrapToken"] == nil)
|
||||
#expect(auth["deviceToken"] == nil)
|
||||
#expect(task.latestConnectDevice() != nil)
|
||||
#expect(await gateway.currentIssuedDeviceAuthRoles() == [])
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `stored device token cannot cross gateway owner`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "node",
|
||||
token: "gateway-a-device-token",
|
||||
gatewayID: "gateway-a")
|
||||
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true,
|
||||
allowStoredDeviceAuth: true,
|
||||
deviceAuthGatewayID: "gateway-b")
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://gateway-b.example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let auth = try #require(session.latestTask()?.latestConnectAuth())
|
||||
#expect(auth["token"] == nil)
|
||||
#expect(auth["deviceToken"] == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "node",
|
||||
gatewayID: "gateway-a")?.token == "gateway-a-device-token")
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `share extension identity profile uses separate node identity and token store`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@@ -556,7 +1009,7 @@ struct GatewayNodeSessionTests {
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `bootstrap hello stores additional device tokens`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@@ -631,11 +1084,64 @@ struct GatewayNodeSessionTests {
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
])
|
||||
#expect(await gateway.currentIssuedDeviceAuthRoles() == ["node", "operator"])
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `failed device token write is not reported as an issued role`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
|
||||
try Data().write(to: blocker)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "node-device-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "wss://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
#expect(await gateway.currentIssuedDeviceAuthRoles().isEmpty)
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@@ -697,7 +1203,7 @@ struct GatewayNodeSessionTests {
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@@ -760,7 +1266,7 @@ struct GatewayNodeSessionTests {
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `private lan bootstrap persists handoff tokens for reconnect`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Testing
|
||||
|
||||
private actor StateDirectoryTestGate {
|
||||
private var locked = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func acquire() async {
|
||||
if !self.locked {
|
||||
self.locked = true
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { continuation in
|
||||
self.waiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
guard !self.waiters.isEmpty else {
|
||||
self.locked = false
|
||||
return
|
||||
}
|
||||
self.waiters.removeFirst().resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prevents process-wide state-directory overrides from crossing suite boundaries.
|
||||
struct StateDirectoryIsolationTrait: TestTrait, TestScoping {
|
||||
private static let gate = StateDirectoryTestGate()
|
||||
|
||||
func provideScope(
|
||||
for test: Test,
|
||||
testCase: Test.Case?,
|
||||
performing function: @Sendable () async throws -> Void) async throws
|
||||
{
|
||||
await Self.gate.acquire()
|
||||
do {
|
||||
try await function()
|
||||
} catch {
|
||||
await Self.gate.release()
|
||||
throw error
|
||||
}
|
||||
await Self.gate.release()
|
||||
}
|
||||
}
|
||||
|
||||
extension Trait where Self == StateDirectoryIsolationTrait {
|
||||
static var stateDirectoryIsolated: Self {
|
||||
Self()
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
|
||||
["SessionsMessagesUnsubscribeParams", ["agentId"]],
|
||||
["SessionsAbortParams", ["agentId"]],
|
||||
["SessionsListParams", ["archived"]],
|
||||
["SessionsPatchParams", ["agentId", "archived", "pinned"]],
|
||||
["SessionsPatchParams", ["agentId", "category", "archived", "pinned"]],
|
||||
["SessionsResetParams", ["agentId"]],
|
||||
[
|
||||
"SessionsDeleteParams",
|
||||
@@ -78,6 +78,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
|
||||
["CronListParams", ["compact"]],
|
||||
["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]],
|
||||
["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]],
|
||||
["PluginApprovalRequestParams", ["approvalReviewerDeviceIds"]],
|
||||
["DevicePairSetupCodeResult", ["gatewayUrls"]],
|
||||
["AgentSummary", ["thinkingLevels", "thinkingOptions", "thinkingDefault"]],
|
||||
["ModelChoice", ["available"]],
|
||||
|
||||
@@ -115,6 +115,10 @@ vi.mock("../infra/device-pairing.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/device-identity.js", () => ({
|
||||
loadOrCreateProcessDeviceIdentity: () => ({ deviceId: "gateway-device-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/push-apns.js", () => ({
|
||||
loadApnsRegistration: loadApnsRegistrationMock,
|
||||
loadApnsRegistrations: loadApnsRegistrationsMock,
|
||||
@@ -168,7 +172,19 @@ describe("createExecApprovalIosPushDelivery", () => {
|
||||
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("targets iOS devices when the active operator token includes operator.approvals", async () => {
|
||||
it("does not target approval-only iOS devices that cannot validate gateway ownership", async () => {
|
||||
mockPairedIosOperator(["operator.approvals"]);
|
||||
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: {} });
|
||||
|
||||
const accepted = await delivery.handleRequested(approvalRequest("approval-no-read"));
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(loadApnsRegistrationsMock).not.toHaveBeenCalled();
|
||||
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("targets iOS devices when the active operator token can approve and validate ownership", async () => {
|
||||
mockPairedIosOperator(["operator.approvals", "operator.read"]);
|
||||
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: {} });
|
||||
@@ -178,6 +194,9 @@ describe("createExecApprovalIosPushDelivery", () => {
|
||||
expect(accepted).toBe(true);
|
||||
expect(loadApnsRegistrationsMock).toHaveBeenCalledWith(["ios-device-1"]);
|
||||
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gatewayDeviceId: "gateway-device-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads APNs registrations in one bulk read for all visible iOS operators", async () => {
|
||||
@@ -185,7 +204,7 @@ describe("createExecApprovalIosPushDelivery", () => {
|
||||
pairedIosOperator({
|
||||
deviceId: "ios-device-1",
|
||||
publicKey: "pub-1",
|
||||
scopes: ["operator.approvals"],
|
||||
scopes: ["operator.approvals", "operator.read"],
|
||||
token: "operator-token-1",
|
||||
}),
|
||||
pairedIosOperator({
|
||||
@@ -193,7 +212,7 @@ describe("createExecApprovalIosPushDelivery", () => {
|
||||
publicKey: "pub-2",
|
||||
platform: "iPadOS 18",
|
||||
approvedAtMs: 2,
|
||||
scopes: ["operator.approvals"],
|
||||
scopes: ["operator.approvals", "operator.write"],
|
||||
token: "operator-token-2",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Sends APNs request/resolution wakes to paired operator devices.
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { loadOrCreateProcessDeviceIdentity } from "../infra/device-identity.js";
|
||||
import {
|
||||
hasEffectivePairedDeviceRole,
|
||||
listDevicePairing,
|
||||
@@ -25,9 +26,11 @@ import {
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
|
||||
// iOS exec-approval push delivery targets paired operator devices with APNs
|
||||
// registrations. Request pushes require approval scope; cleanup/resolved pushes
|
||||
// reuse the original targets so badges can clear even after scope changes.
|
||||
// registrations. Request pushes require approval scope plus identity-read access
|
||||
// so the client can validate gateway ownership before presenting or resolving.
|
||||
// Cleanup pushes reuse original targets so badges can clear after scope changes.
|
||||
const APPROVALS_SCOPE = "operator.approvals";
|
||||
const READ_SCOPE = "operator.read";
|
||||
const OPERATOR_ROLE = "operator";
|
||||
|
||||
type GatewayLikeLogger = {
|
||||
@@ -82,14 +85,14 @@ function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | nul
|
||||
return operatorToken;
|
||||
}
|
||||
|
||||
function canApproveExecRequests(device: PairedDevice): boolean {
|
||||
function canReceiveExecApprovalRequests(device: PairedDevice): boolean {
|
||||
const operatorToken = resolveActiveOperatorToken(device);
|
||||
if (!operatorToken) {
|
||||
return false;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: [APPROVALS_SCOPE],
|
||||
requestedScopes: [APPROVALS_SCOPE, READ_SCOPE],
|
||||
allowedScopes: operatorToken.scopes,
|
||||
});
|
||||
}
|
||||
@@ -107,7 +110,7 @@ function shouldTargetDevice(params: {
|
||||
if (!params.requireApprovalScope) {
|
||||
return true;
|
||||
}
|
||||
return canApproveExecRequests(params.device);
|
||||
return canReceiveExecApprovalRequests(params.device);
|
||||
}
|
||||
|
||||
async function loadRegisteredTargets(params: {
|
||||
@@ -231,6 +234,7 @@ async function sendRequestedPushes(params: {
|
||||
plan: DeliveryPlan;
|
||||
log: GatewayLikeLogger;
|
||||
}): Promise<{ attempted: number; delivered: number }> {
|
||||
const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
|
||||
return await sendApprovalPushes({
|
||||
approvalId: params.request.id,
|
||||
plan: params.plan,
|
||||
@@ -243,12 +247,14 @@ async function sendRequestedPushes(params: {
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId,
|
||||
gatewayDeviceId,
|
||||
auth: plan.directAuth!,
|
||||
})
|
||||
: await sendApnsExecApprovalAlert({
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId,
|
||||
gatewayDeviceId,
|
||||
relayConfig: plan.relayConfig!,
|
||||
}),
|
||||
});
|
||||
@@ -301,6 +307,7 @@ async function sendResolvedPushes(params: {
|
||||
plan: DeliveryPlan;
|
||||
log: GatewayLikeLogger;
|
||||
}): Promise<void> {
|
||||
const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
|
||||
await sendApprovalPushes({
|
||||
approvalId: params.approvalId,
|
||||
plan: params.plan,
|
||||
@@ -313,12 +320,14 @@ async function sendResolvedPushes(params: {
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId,
|
||||
gatewayDeviceId,
|
||||
auth: plan.directAuth!,
|
||||
})
|
||||
: await sendApnsExecApprovalResolvedWake({
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId,
|
||||
gatewayDeviceId,
|
||||
relayConfig: plan.relayConfig!,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
loadOrCreateProcessDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../../infra/device-identity.js";
|
||||
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
|
||||
@@ -21,7 +21,7 @@ import type { GatewayRequestHandlers } from "./types.js";
|
||||
/** Gateway handlers for identity, heartbeat toggles, and system presence events. */
|
||||
export const systemHandlers: GatewayRequestHandlers = {
|
||||
"gateway.identity.get": ({ respond }) => {
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const identity = loadOrCreateProcessDeviceIdentity();
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
||||
export { agentCommandFromIngress } from "../commands/agent.js";
|
||||
export { getRuntimeConfig } from "../config/io.js";
|
||||
export { canonicalizeSessionEntryAliases } from "../config/sessions.js";
|
||||
export { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
export { loadOrCreateProcessDeviceIdentity } from "../infra/device-identity.js";
|
||||
export { requestHeartbeat } from "../infra/heartbeat-wake.js";
|
||||
export { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
|
||||
export { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
|
||||
@@ -46,7 +46,7 @@ const buildSessionLookup = (
|
||||
|
||||
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const registerApnsRegistrationMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
|
||||
const loadOrCreateProcessDeviceIdentityMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
deviceId: "gateway-device-1",
|
||||
publicKeyPem: "public",
|
||||
@@ -83,7 +83,7 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
formatForLog: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
|
||||
getRuntimeConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
|
||||
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
|
||||
loadOrCreateProcessDeviceIdentity: loadOrCreateProcessDeviceIdentityMock,
|
||||
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
|
||||
canonicalizeSessionEntryAliases: vi.fn(),
|
||||
normalizeChannelId: normalizeChannelIdMock,
|
||||
@@ -265,7 +265,7 @@ describe("node exec events", () => {
|
||||
enqueueSystemEventMock.mockReturnValue(true);
|
||||
requestHeartbeatMock.mockClear();
|
||||
registerApnsRegistrationVi.mockClear();
|
||||
loadOrCreateDeviceIdentityMock.mockClear();
|
||||
loadOrCreateProcessDeviceIdentityMock.mockClear();
|
||||
normalizeChannelIdVi.mockClear();
|
||||
normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null);
|
||||
sanitizeInboundSystemTagsMock.mockClear();
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
enqueueSystemEvent,
|
||||
formatForLog,
|
||||
getRuntimeConfig,
|
||||
loadOrCreateDeviceIdentity,
|
||||
loadOrCreateProcessDeviceIdentity,
|
||||
loadSessionEntry,
|
||||
normalizeChannelId,
|
||||
normalizeMainKey,
|
||||
@@ -819,7 +819,7 @@ export const handleNodeEvent = async (
|
||||
try {
|
||||
if (transport === "relay") {
|
||||
const gatewayDeviceId = normalizeOptionalString(obj.gatewayDeviceId) ?? "";
|
||||
const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId;
|
||||
const currentGatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
|
||||
if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) {
|
||||
ctx.logGateway.warn(
|
||||
`push relay register rejected node=${nodeId}: gateway identity mismatch`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
loadDeviceIdentityIfPresent,
|
||||
loadOrCreateDeviceIdentity,
|
||||
loadOrCreateProcessDeviceIdentity,
|
||||
normalizeDevicePublicKeyBase64Url,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
@@ -132,8 +133,10 @@ describe("device identity crypto helpers", () => {
|
||||
|
||||
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
|
||||
const loaded = loadOrCreateDeviceIdentity(identityPath);
|
||||
const processIdentity = loadOrCreateProcessDeviceIdentity(identityPath);
|
||||
|
||||
expect(loaded.deviceId).not.toBe("stale-device-id");
|
||||
expect(loadOrCreateProcessDeviceIdentity(identityPath)).toBe(processIdentity);
|
||||
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,6 +264,23 @@ export function loadOrCreateDeviceIdentity(
|
||||
return identity;
|
||||
}
|
||||
|
||||
let processDeviceIdentity: { filePath: string; identity: DeviceIdentity } | undefined;
|
||||
|
||||
/**
|
||||
* Keep one identity stable for the lifetime of the active state-dir process.
|
||||
* Recognizable invalid stores yield transient keys, so independent reloads would split gateway ownership.
|
||||
*/
|
||||
export function loadOrCreateProcessDeviceIdentity(
|
||||
filePath: string = resolveDefaultIdentityPath(),
|
||||
): DeviceIdentity {
|
||||
if (processDeviceIdentity?.filePath === filePath) {
|
||||
return processDeviceIdentity.identity;
|
||||
}
|
||||
const identity = loadOrCreateDeviceIdentity(filePath);
|
||||
processDeviceIdentity = { filePath, identity };
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** Load a valid persisted device identity without creating, repairing, or migrating files. */
|
||||
export function loadDeviceIdentityIfPresent(
|
||||
filePath: string = resolveDefaultIdentityPath(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import type { GatewayConfig } from "../config/types.gateway.js";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
loadOrCreateProcessDeviceIdentity,
|
||||
signDevicePayload,
|
||||
type DeviceIdentity,
|
||||
} from "./device-identity.js";
|
||||
@@ -330,7 +330,7 @@ export async function sendApnsRelayPush(params: {
|
||||
requestSender?: ApnsRelayRequestSender;
|
||||
}): Promise<ApnsRelayPushResponse> {
|
||||
const sender = params.requestSender ?? sendApnsRelayRequest;
|
||||
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
|
||||
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateProcessDeviceIdentity();
|
||||
const signedAtMs = Date.now();
|
||||
const bodyJson = JSON.stringify({
|
||||
relayHandle: params.relayHandle,
|
||||
|
||||
@@ -498,6 +498,7 @@ describe("push APNs send semantics", () => {
|
||||
registration,
|
||||
nodeId: "ios-node-approval-alert",
|
||||
approvalId: "approval-123",
|
||||
gatewayDeviceId: "gateway-device-123",
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
@@ -519,6 +520,7 @@ describe("push APNs send semantics", () => {
|
||||
expectRecordFields(openclawPayload, {
|
||||
kind: "exec.approval.requested",
|
||||
approvalId: "approval-123",
|
||||
gatewayDeviceId: "gateway-device-123",
|
||||
});
|
||||
expect(typeof openclawPayload.ts).toBe("number");
|
||||
expectNoProperties(openclawPayload, [
|
||||
@@ -548,6 +550,7 @@ describe("push APNs send semantics", () => {
|
||||
registration,
|
||||
nodeId: "ios-node-approval-cleanup",
|
||||
approvalId: "approval-123",
|
||||
gatewayDeviceId: "gateway-device-123",
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
@@ -563,6 +566,7 @@ describe("push APNs send semantics", () => {
|
||||
expectRecordFields(openclawPayload, {
|
||||
kind: "exec.approval.resolved",
|
||||
approvalId: "approval-123",
|
||||
gatewayDeviceId: "gateway-device-123",
|
||||
});
|
||||
expect(typeof openclawPayload.ts).toBe("number");
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -790,6 +794,7 @@ describe("push APNs send semantics", () => {
|
||||
registration,
|
||||
nodeId: "ios-node-relay-approval-alert",
|
||||
approvalId: "approval-relay-1",
|
||||
gatewayDeviceId: "gateway-device-relay",
|
||||
relayConfig,
|
||||
relayGatewayIdentity: gatewayIdentity,
|
||||
relayRequestSender: send,
|
||||
@@ -809,6 +814,7 @@ describe("push APNs send semantics", () => {
|
||||
expectRecordFields(openclawPayload, {
|
||||
kind: "exec.approval.requested",
|
||||
approvalId: "approval-relay-1",
|
||||
gatewayDeviceId: "gateway-device-relay",
|
||||
});
|
||||
expect(typeof openclawPayload.ts).toBe("number");
|
||||
expectNoProperties(openclawPayload, [
|
||||
|
||||
+14
-4
@@ -930,7 +930,10 @@ function resolveExecApprovalAlertBody(): string {
|
||||
return EXEC_APPROVAL_GENERIC_ALERT_BODY;
|
||||
}
|
||||
|
||||
function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: string }): object {
|
||||
function createExecApprovalAlertPayload(params: {
|
||||
approvalId: string;
|
||||
gatewayDeviceId: string;
|
||||
}): object {
|
||||
return {
|
||||
aps: {
|
||||
alert: {
|
||||
@@ -944,12 +947,16 @@ function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: st
|
||||
openclaw: {
|
||||
kind: "exec.approval.requested",
|
||||
approvalId: params.approvalId,
|
||||
gatewayDeviceId: params.gatewayDeviceId,
|
||||
ts: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId: string }): object {
|
||||
function createExecApprovalResolvedPayload(params: {
|
||||
approvalId: string;
|
||||
gatewayDeviceId: string;
|
||||
}): object {
|
||||
return {
|
||||
aps: {
|
||||
"content-available": 1,
|
||||
@@ -957,6 +964,7 @@ function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId:
|
||||
openclaw: {
|
||||
kind: "exec.approval.resolved",
|
||||
approvalId: params.approvalId,
|
||||
gatewayDeviceId: params.gatewayDeviceId,
|
||||
ts: Date.now(),
|
||||
},
|
||||
};
|
||||
@@ -1012,6 +1020,7 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
|
||||
type ApnsExecApprovalAlertCommonParams = {
|
||||
nodeId: string;
|
||||
approvalId: string;
|
||||
gatewayDeviceId: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
@@ -1035,6 +1044,7 @@ type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
|
||||
type ApnsExecApprovalResolvedCommonParams = {
|
||||
nodeId: string;
|
||||
approvalId: string;
|
||||
gatewayDeviceId: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
@@ -1127,8 +1137,8 @@ export async function sendApnsExecApprovalAlert(
|
||||
params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams,
|
||||
): Promise<ApnsPushAlertResult> {
|
||||
const payload = createExecApprovalAlertPayload({
|
||||
nodeId: params.nodeId,
|
||||
approvalId: params.approvalId,
|
||||
gatewayDeviceId: params.gatewayDeviceId,
|
||||
});
|
||||
|
||||
if (params.registration.transport === "relay") {
|
||||
@@ -1160,8 +1170,8 @@ export async function sendApnsExecApprovalResolvedWake(
|
||||
params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams,
|
||||
): Promise<ApnsPushWakeResult> {
|
||||
const payload = createExecApprovalResolvedPayload({
|
||||
nodeId: params.nodeId,
|
||||
approvalId: params.approvalId,
|
||||
gatewayDeviceId: params.gatewayDeviceId,
|
||||
});
|
||||
|
||||
if (params.registration.transport === "relay") {
|
||||
|
||||
Reference in New Issue
Block a user