fix(apps): show native gateway and notification failures (#125909)

* fix(apps): surface native action failures

Make iOS gateway reconnect attempts return visible closed outcomes, route macOS gateway deep links through primary setup confirmation, and expose pending/sent/error notification test results across native and Dashboard settings.

* chore(apps): refresh native i18n inventory

* test(macos): isolate gateway cancel state

* fix(ui): accept permission-only notification status

* test(gateway): drain accepted agent run before fixture reset

* fix(ui): notify clients after service worker claim

* test(gateway): wait for accepted root work to drain

* test(ui): isolate widget theme observer window

* test(ui): await durable attachment draft before teardown

* test(agents): use race-safe MCP process cleanup

* test(ui): await cloud startup runtime before error probe

* test(ui): align startup and teardown probes with current owners
This commit is contained in:
Peter Steinberger
2026-08-18 20:15:52 -07:00
committed by GitHub
parent 66cacbae5d
commit 57e5ab7a87
32 changed files with 1009 additions and 139 deletions
+173
View File
@@ -19797,6 +19797,17 @@
}
]
},
{
"id": "native.apple.b7a51f5ac6098ec5",
"source": "%@ is not currently discoverable on this network.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.23ad69a52fe7c550",
"source": "%@ min",
@@ -23606,6 +23617,17 @@
}
]
},
{
"id": "native.apple.c767224da1135c79",
"source": "Change the primary Gateway?",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.68f28e54baab5743",
"source": "Change the search or readiness filter.",
@@ -26247,6 +26269,17 @@
}
]
},
{
"id": "native.apple.23f447855b25395a",
"source": "Could Not Change Primary Gateway",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.2e44cb00b99f5c81",
"source": "Could Not Open Gateway Window",
@@ -26482,6 +26515,28 @@
}
]
},
{
"id": "native.apple.6c192cb704c2f643",
"source": "Could not save the active gateway selection.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.839637d118fd48d8",
"source": "Could not save the paired gateway.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.3fafd1c7d790f37e",
"source": "Could not save the watch device credential",
@@ -28124,6 +28179,10 @@
"kind": "ui-call",
"path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift"
},
{
"kind": "ui-named-argument",
"path": "apps/ios/Sources/RootTabs.swift"
},
{
"kind": "ui-modifier",
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift"
@@ -29325,6 +29384,17 @@
}
]
},
{
"id": "native.apple.cd811b1e4af50d99",
"source": "Failed to build the gateway URL.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.192cb36afc99c87d",
"source": "Failed: \\(reason)",
@@ -30138,6 +30208,17 @@
}
]
},
{
"id": "native.apple.a89c5ab50baafc61",
"source": "Gateway Setup Not Supported",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.384fcc93f505c5c1",
"source": "Gateway URL",
@@ -30457,6 +30538,17 @@
}
]
},
{
"id": "native.apple.a6ec7e8d8d11e84a",
"source": "Gateway reconnect failed",
"surface": "apple",
"sites": [
{
"kind": "ui-named-argument",
"path": "apps/ios/Sources/RootTabs.swift"
}
]
},
{
"id": "native.apple.6f2661cb894de16f",
"source": "Gateway recovery failed.",
@@ -34325,6 +34417,10 @@
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/RootSidebar.swift"
},
{
"kind": "ui-named-argument",
"path": "apps/ios/Sources/RootTabs.swift"
}
]
},
@@ -35290,6 +35386,17 @@
}
]
},
{
"id": "native.apple.67798128e396e0ef",
"source": "No paired gateway is available to reconnect.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.c137581496211b45",
"source": "No phase status",
@@ -43339,6 +43446,17 @@
}
]
},
{
"id": "native.apple.bb03dba3e7eb1ee8",
"source": "Sending test notification…",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DebugSettings.swift"
}
]
},
{
"id": "native.apple.19c12c52941ddf52",
"source": "Sent with foreground app connections to this gateway. Changes apply on the next reconnect; Share extension delivery is not yet supported.",
@@ -46007,6 +46125,17 @@
}
]
},
{
"id": "native.apple.0edae9804e36af9f",
"source": "Test notification queued.",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DebugSettings.swift"
}
]
},
{
"id": "native.apple.1a8953b851a377c3",
"source": "Test remote",
@@ -47098,6 +47227,17 @@
}
]
},
{
"id": "native.apple.768fb431d51d4190",
"source": "This gateway is unavailable.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.39e226bc9f837cb8",
"source": "This gateway is using unsupported auth",
@@ -47153,6 +47293,28 @@
}
]
},
{
"id": "native.apple.47e70eedcab1a81c",
"source": "This paired gateway has an invalid saved endpoint.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.bb5f5e891aeadb25",
"source": "This paired gateway is no longer available.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.c6f29415067807fd",
"source": "This permanently deletes the session and its transcript.",
@@ -50732,6 +50894,17 @@
}
]
},
{
"id": "native.apple.8e781789ba7a7b6a",
"source": "an unencrypted private-network connection",
"surface": "apple",
"sites": [
{
"kind": "conditional-branch",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.a7cc4e836193eab6",
"source": "announce",
@@ -58,6 +58,7 @@ struct SettingsProTab: View {
@State var manualGatewayPortText = ""
@State var manualGatewayContextPath: String?
@State var setupStatusText: String?
@State var gatewayActionStatusText: String?
@State var setupAttemptID: UUID?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@@ -128,25 +128,32 @@ extension SettingsProTab {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
guard !self.isReconnectingGateway else { return }
self.isReconnectingGateway = true
self.gatewayActionStatusText = nil
defer { self.isReconnectingGateway = false }
await self.gatewayController.connectActiveGateway()
if case let .failed(message) = await self.gatewayController.connectActiveGateway() {
self.gatewayActionStatusText = message
}
}
func switchGateway(to entry: GatewaySettingsStore.GatewayRegistryEntry) async {
guard self.connectingGateway == nil else { return }
self.connectingGateway = .gateway(entry.id)
self.setupStatusText = String(
self.gatewayActionStatusText = String(
format: String(localized: "Switching to %@…"),
entry.name)
defer {
self.connectingGateway = nil
self.refreshGatewayRegistry()
}
if let failure = await self.gatewayController.switchToGateway(stableID: entry.stableID) {
self.setupStatusText = failure
return
switch await self.gatewayController.switchToGateway(stableID: entry.stableID) {
case .accepted:
self.gatewayActionStatusText = nil
self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false)
case let .failed(message):
self.gatewayActionStatusText = message
case .superseded:
self.gatewayActionStatusText = nil
}
self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false)
}
func forgetGateway(_ entry: GatewaySettingsStore.GatewayRegistryEntry) async {
@@ -347,6 +347,8 @@ extension SettingsProTab {
.font(OpenClawType.body)
}
.disabled(self.isRefreshingGateway)
} footer: {
self.gatewayActionStatusView
}
self.gatewaySetupCard
@@ -831,6 +833,17 @@ extension SettingsProTab {
{
Task { await self.runDiagnostics() }
}
self.gatewayActionStatusView
}
}
@ViewBuilder
var gatewayActionStatusView: some View {
if let gatewayActionStatusText {
Text(verbatim: gatewayActionStatusText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
@@ -31,6 +31,12 @@ private func defaultGatewayTCPReachabilityProbe(
@MainActor
@Observable
final class GatewayConnectionController {
enum ConnectionAttemptResult: Equatable {
case accepted
case failed(String)
case superseded
}
enum DiscoveredGatewayConnectionAvailability: Equatable {
case available
case secureTransportRequired
@@ -254,9 +260,13 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
}
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
/// Direct setup callers keep their existing diagnostic contract while registered
/// reconnect and switch actions consume the closed attempt result below.
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
await self.connectDiscoveredGateway(gateway)
if case let .failed(message) = await self.connectDiscoveredGateway(gateway) {
return message
}
return nil
}
func discoveredGatewayConnectionAvailability(
@@ -276,21 +286,23 @@ final class GatewayConnectionController {
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
forceReconnect: Bool = false) async -> ConnectionAttemptResult
{
let availability = self.discoveredGatewayConnectionAvailability(gateway)
guard availability.canConnect else { return availability.guidanceText }
guard availability.canConnect else {
return .failed(availability.guidanceText ?? String(localized: "This gateway is unavailable."))
}
let connectAttempt = self.beginConnectAttempt()
self.pendingConnectionStableID = gateway.stableID
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
await self.waitForPendingForgetCleanup(stableID: gateway.stableID)
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return .superseded }
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway", allowAutoReconnect: false)
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
return "Missing instanceId (node.instanceId). Try restarting the app."
return .failed("Missing instanceId (node.instanceId). Try restarting the app.")
}
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
let target = if let serviceEndpointResolver {
@@ -298,9 +310,9 @@ final class GatewayConnectionController {
} else {
await self.resolveServiceEndpoint(gateway.endpoint)
}
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return .superseded }
guard let target else {
return "Failed to resolve the discovered gateway endpoint."
return .failed("Failed to resolve the discovered gateway endpoint.")
}
let stableID = gateway.stableID
@@ -313,15 +325,17 @@ final class GatewayConnectionController {
if tlsRequired, stored == nil {
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
else { return "Failed to build TLS URL for trust verification." }
else { return .failed("Failed to build TLS URL for trust verification.") }
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
guard let probeResult = await self.probeTLSFingerprint(
host: target.host,
port: target.port,
url: url,
queueLabel: "gateway.tls.discovered")
else { return nil }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
else { return .superseded }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else {
return .superseded
}
switch probeResult {
case let .fingerprint(fp):
self.pendingTrustConnect = GatewayPendingTrustConnect(
@@ -340,14 +354,14 @@ final class GatewayConnectionController {
fingerprintSha256: fp,
isManual: false)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return nil
return .accepted
case let .failure(failure):
let message = self.tlsProbeFailureMessage(
failure,
host: target.host,
port: target.port)
self.appModel?.gatewayStatusText = message
return message
return .failed(message)
}
}
@@ -359,7 +373,7 @@ final class GatewayConnectionController {
host: target.host,
port: target.port,
useTLS: tlsParams?.required == true)
else { return "Failed to build discovered gateway URL." }
else { return .failed("Failed to build discovered gateway URL.") }
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
kind: .discovered,
@@ -369,10 +383,10 @@ final class GatewayConnectionController {
useTLS: true,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else {
return "Could not save the paired gateway."
return .failed("Could not save the paired gateway.")
}
self.didAutoConnect = true
self.startAutoConnect(
let didStart = self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
@@ -383,30 +397,31 @@ final class GatewayConnectionController {
forceReconnect: forceReconnect,
suppressionGeneration: connectAttempt.suppressionLease.generation,
expectedGeneration: connectAttempt.gatewayGeneration)
return nil
return didStart ? .accepted : .superseded
}
@discardableResult
func connectManual(
host: String,
port: Int,
useTLS: Bool,
contextPath: String? = nil,
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
forceReconnect: Bool = false) async -> ConnectionAttemptResult
{
let connectAttempt = self.beginConnectAttempt()
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
self.requestLocalNetworkAccess(reason: "connect_manual", allowAutoReconnect: false)
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = Self.resolvedManualPort(host: host, port: port)
else { return }
else { return .failed(String(localized: "This paired gateway has an invalid saved endpoint.")) }
let stableID = self.manualStableID(
host: host,
port: resolvedPort,
contextPath: contextPath)
self.pendingConnectionStableID = stableID
await self.waitForPendingForgetCleanup(stableID: stableID)
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return .superseded }
let instanceId = GatewaySettingsStore.currentInstanceID()
let storedCredentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
@@ -431,15 +446,17 @@ final class GatewayConnectionController {
port: resolvedPort,
useTLS: true,
contextPath: contextPath)
else { return }
else { return .failed(String(localized: "Failed to build the gateway URL.")) }
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
guard let probeResult = await self.probeTLSFingerprint(
host: host,
port: resolvedPort,
url: url,
queueLabel: "gateway.tls.manual")
else { return }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
else { return .superseded }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else {
return .superseded
}
switch probeResult {
case let .fingerprint(fp):
self.pendingTrustConnect = GatewayPendingTrustConnect(
@@ -458,13 +475,14 @@ final class GatewayConnectionController {
fingerprintSha256: fp,
isManual: true)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return
return .accepted
case let .failure(failure):
self.appModel?.gatewayStatusText = self.tlsProbeFailureMessage(
let message = self.tlsProbeFailureMessage(
failure,
host: host,
port: resolvedPort)
return
self.appModel?.gatewayStatusText = message
return .failed(message)
}
}
@@ -476,7 +494,7 @@ final class GatewayConnectionController {
port: resolvedPort,
useTLS: tlsParams?.required == true,
contextPath: contextPath)
else { return }
else { return .failed(String(localized: "Failed to build the gateway URL.")) }
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
kind: .manual,
@@ -486,9 +504,11 @@ final class GatewayConnectionController {
useTLS: resolvedUseTLS && tlsParams != nil,
contextPath: contextPath,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else { return }
guard self.persistActiveGateway(registryEntry) else {
return .failed(String(localized: "Could not save the paired gateway."))
}
self.didAutoConnect = true
self.startAutoConnect(
let didStart = self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
@@ -499,72 +519,66 @@ final class GatewayConnectionController {
forceReconnect: forceReconnect,
suppressionGeneration: connectAttempt.suppressionLease.generation,
expectedGeneration: connectAttempt.gatewayGeneration)
return didStart ? .accepted : .superseded
}
func connectActiveGateway() async {
@discardableResult
func connectActiveGateway() async -> ConnectionAttemptResult {
self.requestLocalNetworkAccess(reason: "connect_active_gateway", allowAutoReconnect: false)
guard let active = GatewaySettingsStore.activeGatewayEntry() else { return }
switch active.kind {
case .manual:
guard let host = active.host, let port = active.port else { return }
await self.connectManual(
host: host,
port: port,
useTLS: active.useTLS,
contextPath: active.contextPath,
forceReconnect: true)
case .discovered:
if let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, active.stableID)
}) {
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
return
}
guard let fallback = self.mostRecentlyConnectedManualGateway() else { return }
guard let host = fallback.host, let port = fallback.port else { return }
await self.connectManual(
host: host,
port: port,
useTLS: fallback.useTLS,
contextPath: fallback.contextPath,
forceReconnect: true)
guard let active = GatewaySettingsStore.activeGatewayEntry() else {
return .failed(String(localized: "No paired gateway is available to reconnect."))
}
return await self.connectRegisteredGateway(active, allowManualFallback: true, activate: false)
}
/// Returns `nil` after initiating a switch, or a user-facing discovery failure.
func switchToGateway(stableID: String) async -> String? {
@discardableResult
func switchToGateway(stableID: String) async -> ConnectionAttemptResult {
guard let stableID = GatewayStableIdentifier.exact(stableID) else {
return "This paired gateway is no longer available."
return .failed(String(localized: "This paired gateway is no longer available."))
}
guard let entry = GatewaySettingsStore.loadGatewayRegistry().entries.first(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
}) else {
return "This paired gateway is no longer available."
return .failed(String(localized: "This paired gateway is no longer available."))
}
return await self.connectRegisteredGateway(entry, allowManualFallback: false, activate: true)
}
private func connectRegisteredGateway(
_ entry: GatewaySettingsStore.GatewayRegistryEntry,
allowManualFallback: Bool,
activate: Bool) async -> ConnectionAttemptResult
{
switch entry.kind {
case .manual:
guard let host = entry.host, let port = entry.port else {
return "This paired gateway has an invalid saved endpoint."
return .failed(String(localized: "This paired gateway has an invalid saved endpoint."))
}
// Switching intentionally persists the user's selection at initiation, matching connect flows.
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
return "Could not save the active gateway selection."
if activate, !GatewaySettingsStore.setActiveGateway(stableID: entry.stableID) {
return .failed(String(localized: "Could not save the active gateway selection."))
}
await self.connectManual(
return await self.connectManual(
host: host,
port: port,
useTLS: entry.useTLS,
contextPath: entry.contextPath,
forceReconnect: true)
return nil
case .discovered:
guard let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
GatewayStableIdentifier.matches($0.stableID, entry.stableID)
}) else {
return "\(entry.name) is not currently discoverable on this network."
if allowManualFallback, let fallback = self.mostRecentlyConnectedManualGateway() {
return await self.connectRegisteredGateway(
fallback,
allowManualFallback: false,
activate: true)
}
return .failed(String(
format: String(localized: "%@ is not currently discoverable on this network."),
entry.name))
}
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
return "Could not save the active gateway selection."
if activate, !GatewaySettingsStore.setActiveGateway(stableID: entry.stableID) {
return .failed(String(localized: "Could not save the active gateway selection."))
}
return await self.connectDiscoveredGateway(gateway, forceReconnect: true)
}
@@ -1530,7 +1530,9 @@ extension OnboardingWizardView {
switch GatewaySettingsStore.activeGatewayEntry()?.kind {
case .discovered:
await self.gatewayController.connectActiveGateway()
if case let .failed(message) = await self.gatewayController.connectActiveGateway() {
self.setConnectionFailure(message)
}
case .manual, .none:
// connectActiveGateway() replays the persisted endpoint and credentials,
// so token/host/port edits made on this screen would be ignored and
+24 -3
View File
@@ -49,6 +49,7 @@ struct RootTabs: View {
@State private var presentedSheet: PresentedSheet?
@State private var showGatewayProblemDetails: Bool = false
@State private var gatewayToastDragOffset: CGFloat = 0
@State private var gatewayRetryFailure: String?
// Swipe-up hides the toast only until the next problem report.
@State private var isGatewayToastSwipeDismissed: Bool = false
@State private var showOnboarding: Bool = false
@@ -527,17 +528,32 @@ struct RootTabs: View {
// Stable container so the toast's move/opacity transition animates
// when the gateway problem appears or clears outside withAnimation.
ZStack(alignment: .top) {
if let gatewayProblem = self.activeGatewayProblemToast {
if let gatewayRetryFailure {
OpenClawNoticeBanner(
icon: "wifi.exclamationmark",
title: "Gateway reconnect failed",
message: .verbatim(gatewayRetryFailure),
ownerLabel: "Needs attention",
tint: OpenClawBrand.warn,
secondaryActionTitle: "Dismiss",
onSecondaryAction: { self.gatewayRetryFailure = nil })
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
} else if let gatewayProblem = self.activeGatewayProblemToast {
self.gatewayProblemToast(gatewayProblem)
}
}
.animation(self.gatewayToastAnimation, value: self.gatewayRetryFailure)
.animation(self.gatewayToastAnimation, value: self.activeGatewayProblemToast)
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, self.activeGatewayProblemToast == nil ? 58 : 132)
.safeAreaPadding(
.top,
self.activeGatewayProblemToast == nil && self.gatewayRetryFailure == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -1096,7 +1112,12 @@ extension RootTabs {
} else if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
return
} else if problem.retryable {
Task { await self.gatewayController.connectActiveGateway() }
self.gatewayRetryFailure = nil
Task {
if case let .failed(message) = await self.gatewayController.connectActiveGateway() {
self.gatewayRetryFailure = message
}
}
} else {
self.selectSidebarDestination(.gateway)
}
@@ -2471,8 +2471,9 @@ private func waitUntil(
controller.clearPendingTrustPrompt()
probe.results.continuation.yield(.fingerprint("stale-fingerprint"))
probe.results.continuation.finish()
await connectTask.value
let result = await connectTask.value
#expect(result == .superseded)
#expect(controller.pendingTrustPrompt == nil)
}
@@ -2505,10 +2506,10 @@ private func waitUntil(
startDiscovery: false,
forceReconnectReset: { _ in })
let failure = await controller.switchToGateway(stableID: stableID)
let result = await controller.switchToGateway(stableID: stableID)
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(failure == nil)
#expect(result == .accepted)
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
#expect(appModel.activeGatewayConnectConfig?.url == URL(string: "ws://127.0.0.1:1"))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == stableID)
@@ -2531,13 +2532,36 @@ private func waitUntil(
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let failure = await controller.switchToGateway(stableID: discoveredID)
let result = await controller.switchToGateway(stableID: discoveredID)
#expect(failure == "Kitchen Gateway is not currently discoverable on this network.")
#expect(result == .failed("Kitchen Gateway is not currently discoverable on this network."))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == activeID)
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor
func `reconnect to active undiscoverable gateway returns failure without queuing connection`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let discoveredID = "bonjour|missing-active"
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: discoveredID,
kind: .discovered,
name: "Kitchen Gateway",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: nil), activate: true)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let result = await controller.connectActiveGateway()
#expect(result == .failed("Kitchen Gateway is not currently discoverable on this network."))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == discoveredID)
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor func `chat cache remains isolated when active gateway switches`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -129,9 +129,15 @@ enum DashboardGatewayCatalog {
enum DashboardPrimaryGatewayError: LocalizedError, Equatable {
case notPromotable
case passwordUnsupported
var errorDescription: String? {
"This Gateway cannot be set as primary."
switch self {
case .notPromotable:
"This Gateway cannot be set as primary."
case .passwordUnsupported:
"Password authentication is not supported by the Mac app's primary Gateway connection. Use a token instead."
}
}
}
@@ -162,6 +168,23 @@ struct DashboardPrimaryGatewayAdapter {
GatewayTLSStore.loadFingerprint(stableID: $0)
}
}
try self.apply(url: endpoint.config.url, token: token, tlsFingerprint: tlsFingerprint)
}
func apply(link: GatewayConnectDeepLink) throws {
if link.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty != nil {
throw DashboardPrimaryGatewayError.passwordUnsupported
}
guard let url = link.websocketURL else {
throw DashboardPrimaryGatewayError.notPromotable
}
try self.apply(
url: url,
token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
tlsFingerprint: nil)
}
private func apply(url: URL, token: String?, tlsFingerprint: String?) throws {
let previous = (
transport: self.state.remoteTransport,
url: self.state.remoteUrl,
@@ -169,10 +192,10 @@ struct DashboardPrimaryGatewayAdapter {
mode: self.state.connectionMode,
tlsFingerprint: self.currentTLSFingerprint())
self.state.remoteTransport = .direct
self.state.remoteUrl = endpoint.config.url.absoluteString
self.state.remoteUrl = url.absoluteString
// Promotion intentionally moves the saved token into gateway.remote.token,
// matching the existing Settings connection flow.
self.state.remoteToken = token
self.state.remoteToken = token ?? ""
self.state.connectionMode = .remote
guard self.persist(self.state, tlsFingerprint) else {
self.state.remoteTransport = previous.transport
@@ -184,3 +207,32 @@ struct DashboardPrimaryGatewayAdapter {
}
}
}
@MainActor
struct DashboardGatewaySetupCoordinator {
let adapter: DashboardPrimaryGatewayAdapter
let confirm: (_ title: String, _ message: String) -> Bool
let presentError: (_ title: String, _ message: String) -> Void
let openConnectionSettings: () -> Void
func handle(_ link: GatewayConnectDeepLink) {
if link.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty != nil {
self.presentError(
"Gateway Setup Not Supported",
DashboardPrimaryGatewayError.passwordUnsupported.localizedDescription)
return
}
let endpoint = "\(link.host):\(link.port)"
let transport = link.tls ? "TLS" : "an unencrypted private-network connection"
guard self.confirm(
"Change the primary Gateway?",
"Connect the Mac app directly to \(endpoint) using \(transport)?")
else { return }
do {
try self.adapter.apply(link: link)
self.openConnectionSettings()
} catch {
self.presentError("Could Not Change Primary Gateway", error.localizedDescription)
}
}
}
@@ -1024,6 +1024,27 @@ extension DashboardManager {
}
}
func handleGatewaySetup(_ link: GatewayConnectDeepLink) {
NSApp.activate(ignoringOtherApps: true)
let coordinator = DashboardGatewaySetupCoordinator(
adapter: DashboardPrimaryGatewayAdapter(state: AppStateStore.shared),
confirm: { title, message in
let alert = DashboardWindowController.makeGatewaySetupAlert(title: title, message: message)
return alert.runModal() == .alertFirstButtonReturn
},
presentError: { title, message in
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.runModal()
},
openConnectionSettings: {
AppNavigationActions.openSettings(tab: .connection)
})
coordinator.handle(link)
}
func openOrFocusDashboard(for target: DashboardGatewayTarget) {
Task { await self.performOpenOrFocusDashboard(for: target) }
}
@@ -148,4 +148,14 @@ extension DashboardWindowController {
alert.addButton(withTitle: "Cancel")
return alert
}
static func makeGatewaySetupAlert(title: String, message: String) -> NSAlert {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "Change Gateway")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert
}
}
@@ -8,6 +8,11 @@ enum DashboardNotificationsRequest: String {
case sendTest = "send-test"
}
struct DashboardNotificationsSnapshot: Encodable, Equatable {
let permission: String
let test: TestNotificationOutcome?
}
@MainActor
final class DashboardNotificationsMessageHandler: NSObject, WKScriptMessageHandler {
weak var owner: DashboardWindowController?
@@ -57,32 +62,50 @@ extension DashboardWindowController {
switch request {
case .status:
Task { await self.publishNotificationsStatus() }
Task { await self.refreshNotificationsPermission() }
case .requestPermission:
Task {
_ = await PermissionManager.ensure([.notifications], interactive: true)
await self.publishNotificationsStatus()
await self.refreshNotificationsPermission()
}
case .sendTest:
Task {
_ = await NotificationManager().send(
title: "OpenClaw",
body: "Test notification",
sound: nil)
guard self.notificationTestOutcome != .pending else { return }
self.notificationTestOutcome = .pending
await self.publishNotificationsStatus()
self.notificationTestOutcome = await TestNotificationAction.send()
await self.publishNotificationsStatus()
}
}
}
static func notificationsSnapshot(
permission: String,
testOutcome: TestNotificationOutcome?) -> DashboardNotificationsSnapshot
{
DashboardNotificationsSnapshot(permission: permission, test: testOutcome)
}
private func refreshNotificationsPermission() async {
guard PermissionManager.notificationCenterAvailable else { return }
let settings = await UNUserNotificationCenter.current().notificationSettings()
self.notificationPermission = Self.notificationsPermissionLabel(for: settings.authorizationStatus)
await self.publishNotificationsStatus()
}
private func publishNotificationsStatus() async {
// Honest absence beats a fabricated status when the process is unbundled.
guard PermissionManager.notificationCenterAvailable else { return }
let settings = await UNUserNotificationCenter.current().notificationSettings()
let permission = Self.notificationsPermissionLabel(for: settings.authorizationStatus)
let snapshot = Self.notificationsSnapshot(
permission: self.notificationPermission,
testOutcome: self.notificationTestOutcome)
guard let data = try? JSONEncoder().encode(snapshot),
let json = String(data: data, encoding: .utf8)
else { return }
// Keep a global snapshot so late subscribers can read status without a bridge round-trip.
_ = try? await self.webView.evaluateJavaScript(
"""
window.__OPENCLAW_NATIVE_NOTIFICATIONS__ = {permission:"\(permission)"};
window.__OPENCLAW_NATIVE_NOTIFICATIONS__ = \(json);
window.dispatchEvent(new CustomEvent('openclaw:native-notifications-status', \
{detail:window.__OPENCLAW_NATIVE_NOTIFICATIONS__}));
""")
@@ -88,6 +88,8 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private(set) var currentURL: URL
var auth: DashboardWindowAuth
var gatewaySnapshot: DashboardGatewaySnapshot?
var notificationPermission = "notDetermined"
var notificationTestOutcome: TestNotificationOutcome?
let tlsParams: GatewayTLSParams?
private let dashboardFrameAutosaveName: String
private let updater: UpdaterProviding?
@@ -60,8 +60,8 @@ enum DebugActions {
}
}
static func sendTestNotification() async {
_ = await NotificationManager().send(title: "OpenClaw", body: "Test notification", sound: nil)
static func sendTestNotification() async -> TestNotificationOutcome {
await TestNotificationAction.send()
}
static func sendDebugVoice() async -> Result<String, DebugActionError> {
@@ -17,6 +17,7 @@ struct DebugSettings: View {
@State private var debugSendInFlight = false
@State private var debugSendStatus: String?
@State private var debugSendError: String?
@State private var testNotificationOutcome: TestNotificationOutcome?
@State private var portCheckInFlight = false
@State private var portReports: [DebugActions.PortReport] = []
@State private var portKillStatus: String?
@@ -460,9 +461,26 @@ struct DebugSettings: View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Button("Send Test Notification") {
Task { await DebugActions.sendTestNotification() }
Task { await self.sendTestNotification() }
}
.buttonStyle(.bordered)
.disabled(self.testNotificationOutcome == .pending)
if let testNotificationOutcome {
switch testNotificationOutcome {
case .pending:
ProgressView("Sending test notification…")
.controlSize(.small)
case .sent:
Text("Test notification queued.")
.font(.caption)
.foregroundStyle(.secondary)
case let .error(message):
Text(message)
.font(.caption)
.foregroundStyle(.red)
}
}
Button("Open Agent Events") {
DebugActions.openAgentEventsWindow()
@@ -715,6 +733,13 @@ struct DebugSettings: View {
}
}
@MainActor
private func sendTestNotification() async {
guard self.testNotificationOutcome != .pending else { return }
self.testNotificationOutcome = .pending
self.testNotificationOutcome = await DebugActions.sendTestNotification()
}
private func revealApp() {
let url = Bundle.main.bundleURL
NSWorkspace.shared.activateFileViewerSelecting([url])
+16 -6
View File
@@ -48,15 +48,22 @@ final class DeepLinkHandler {
static let shared = DeepLinkHandler()
private var lastPromptAt: Date = .distantPast
private let gatewaySetup: @MainActor (GatewayConnectDeepLink) -> Void
/// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
/// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
/// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
init(gatewaySetup: @escaping @MainActor (GatewayConnectDeepLink) -> Void = { link in
DashboardManager.shared.handleGatewaySetup(link)
}) {
self.gatewaySetup = gatewaySetup
}
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
deepLinkLogger.debug("ignored deep link \(Self.invalidRouteMetadata(url), privacy: .public)")
return
}
switch route {
@@ -69,14 +76,17 @@ final class DeepLinkHandler {
return
}
await self.handleAgent(link: link, originalURL: url)
case .gateway:
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
return
}
case let .gateway(link):
self.gatewaySetup(link)
}
}
static func invalidRouteMetadata(_ url: URL) -> String {
let scheme = url.scheme?.lowercased() ?? "missing"
let route = url.host?.lowercased() ?? "missing"
return "scheme=\(scheme) route=\(route)"
}
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
@@ -24,6 +24,7 @@ struct MenuContent: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var browserControlEnabled = true
@State private var testNotificationPending = false
@AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey, store: AppDefaults.standard)
private var appLogLevelRaw: String = Logger.Level.info.rawValue
@@ -325,10 +326,11 @@ struct MenuContent: View {
Label("Send Debug Voice Text", systemImage: "waveform.circle")
}
Button {
Task { await DebugActions.sendTestNotification() }
Task { await self.sendTestNotification() }
} label: {
Label("Send Test Notification", systemImage: "bell")
}
.disabled(self.testNotificationPending)
Divider()
if self.state.connectionMode == .local {
Button {
@@ -594,6 +596,27 @@ struct MenuContent: View {
alert.runModal()
}
@MainActor
private func sendTestNotification() async {
guard !self.testNotificationPending else { return }
self.testNotificationPending = true
let outcome = await DebugActions.sendTestNotification()
self.testNotificationPending = false
let alert = NSAlert()
alert.messageText = "Test Notification"
switch outcome {
case .pending:
return
case .sent:
alert.informativeText = "The notification request was queued."
alert.alertStyle = .informational
case let .error(message):
alert.informativeText = message
alert.alertStyle = .warning
}
alert.runModal()
}
@MainActor
private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else {
@@ -68,3 +68,46 @@ struct NotificationManager {
}
}
}
enum TestNotificationOutcome: Encodable, Equatable {
case pending
case sent
case error(String)
private enum State: String, Encodable {
case pending
case sent
case error
}
private enum CodingKeys: String, CodingKey {
case state
case message
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .pending:
try container.encode(State.pending, forKey: .state)
case .sent:
try container.encode(State.sent, forKey: .state)
case let .error(message):
try container.encode(State.error, forKey: .state)
try container.encode(message, forKey: .message)
}
}
}
@MainActor
enum TestNotificationAction {
static func send() async -> TestNotificationOutcome {
let sent = await NotificationManager().send(
title: "OpenClaw",
body: "Test notification",
sound: nil)
return sent
? .sent
: .error("Notification could not be sent. Check System Settings → Notifications and try again.")
}
}
@@ -586,6 +586,171 @@ struct DashboardPrimaryGatewayAdapterTests {
#expect(state.connectionMode == .local)
#expect(persistedFingerprints == [profileFingerprint, previousFingerprint])
}
@Test func `deep link applies direct endpoint and clears omitted token and pin`() throws {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "ws://127.0.0.1:18789"
state.remoteToken = "stale-token"
state.connectionMode = .local
var persistedFingerprints: [String?] = []
let adapter = DashboardPrimaryGatewayAdapter(
state: state,
currentTLSFingerprint: { String(repeating: "b", count: 64) },
persist: { _, fingerprint in
persistedFingerprints.append(fingerprint)
return true
})
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 8443,
tls: true,
bootstrapToken: nil,
token: nil,
password: nil)
try adapter.apply(link: link)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "wss://gateway.example:8443")
#expect(state.remoteToken.isEmpty)
#expect(state.connectionMode == .remote)
#expect(persistedFingerprints == [nil])
}
@Test func `deep link password is rejected without mutation`() throws {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "wss://previous.example:443"
state.remoteToken = "previous-token"
state.connectionMode = .local
let adapter = DashboardPrimaryGatewayAdapter(state: state)
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 443,
tls: true,
bootstrapToken: nil,
token: "fixture-token",
password: "fixture-password")
#expect(throws: DashboardPrimaryGatewayError.passwordUnsupported) {
try adapter.apply(link: link)
}
#expect(state.remoteUrl == "wss://previous.example:443")
#expect(state.remoteToken == "previous-token")
}
}
@MainActor
struct DashboardGatewaySetupCoordinatorTests {
@Test func `cancel prompts once and preserves primary state without credential disclosure`() {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "wss://previous.example:443"
state.remoteToken = "previous-token"
state.connectionMode = .local
let token = "fixture-token"
let link = GatewayConnectDeepLink(
host: "192.168.1.20",
port: 18789,
tls: false,
bootstrapToken: nil,
token: token,
password: nil)
var prompts: [(String, String)] = []
var openedSettings = 0
var persistCount = 0
let coordinator = DashboardGatewaySetupCoordinator(
adapter: DashboardPrimaryGatewayAdapter(
state: state,
currentTLSFingerprint: { String(repeating: "a", count: 64) },
persist: { _, _ in
persistCount += 1
return true
}),
confirm: { title, message in
prompts.append((title, message))
return false
},
presentError: { _, _ in Issue.record("unexpected error") },
openConnectionSettings: { openedSettings += 1 })
coordinator.handle(link)
#expect(prompts.count == 1)
#expect(!prompts[0].0.contains(token))
#expect(!prompts[0].1.contains(token))
#expect(prompts[0].1.contains("unencrypted private-network connection"))
#expect(!prompts[0].1.localizedCaseInsensitiveContains("loopback"))
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "wss://previous.example:443")
#expect(state.remoteToken == "previous-token")
#expect(state.connectionMode == .local)
#expect(persistCount == 0)
#expect(openedSettings == 0)
}
@Test func `accept applies primary and opens connection settings`() {
let state = AppState(preview: true)
var persistedFingerprints: [String?] = []
var openedSettings = 0
let adapter = DashboardPrimaryGatewayAdapter(
state: state,
persist: { _, fingerprint in
persistedFingerprints.append(fingerprint)
return true
})
let coordinator = DashboardGatewaySetupCoordinator(
adapter: adapter,
confirm: { _, _ in true },
presentError: { _, _ in Issue.record("unexpected error") },
openConnectionSettings: { openedSettings += 1 })
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 443,
tls: true,
bootstrapToken: nil,
token: "fixture-token",
password: nil)
coordinator.handle(link)
#expect(state.remoteUrl == "wss://gateway.example:443")
#expect(state.remoteToken == "fixture-token")
#expect(persistedFingerprints == [nil])
#expect(openedSettings == 1)
}
@Test func `password route visibly rejects before prompting or mutation`() {
let state = AppState(preview: true)
state.remoteUrl = "wss://previous.example:443"
var promptCount = 0
var errors: [(String, String)] = []
let coordinator = DashboardGatewaySetupCoordinator(
adapter: DashboardPrimaryGatewayAdapter(state: state),
confirm: { _, _ in
promptCount += 1
return true
},
presentError: { errors.append(($0, $1)) },
openConnectionSettings: { Issue.record("unexpected settings open") })
let password = "fixture-password"
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 443,
tls: true,
bootstrapToken: nil,
token: nil,
password: password)
coordinator.handle(link)
#expect(promptCount == 0)
#expect(errors.count == 1)
#expect(!errors[0].0.contains(password))
#expect(!errors[0].1.contains(password))
#expect(state.remoteUrl == "wss://previous.example:443")
}
}
private enum DashboardGatewayTestTLS {
@@ -1,3 +1,4 @@
import Foundation
import Testing
import UserNotifications
@testable import OpenClaw
@@ -29,4 +30,41 @@ struct DashboardNotificationsBridgeTests {
#expect(DashboardWindowController.notificationsPermissionLabel(
for: .notDetermined) == "notDetermined")
}
@Test func `permission and test send outcome remain independent bridge facts`() {
let failed = DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .error("Open System Settings and try again."))
let refreshed = DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .error("Open System Settings and try again."))
#expect(failed.permission == "granted")
#expect(failed.test == .error("Open System Settings and try again."))
#expect(refreshed == failed)
}
@Test func `bridge exposes pending and queued test send states`() {
#expect(DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .pending).test == .pending)
#expect(DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .sent).test == .sent)
}
@Test func `bridge encodes closed wire states and error-only messages`() throws {
let pending = try self.testSnapshotJSON(.pending)
let error = try self.testSnapshotJSON(.error("Open System Settings and try again."))
#expect(pending["state"] as? String == "pending")
#expect(pending["message"] == nil)
#expect(error["state"] as? String == "error")
#expect(error["message"] as? String == "Open System Settings and try again.")
}
private func testSnapshotJSON(_ snapshot: TestNotificationOutcome) throws -> [String: Any] {
let data = try JSONEncoder().encode(snapshot)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
}
@@ -1,3 +1,4 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@@ -75,3 +76,35 @@ struct DeepLinkAgentPolicyTests {
#expect(res.channel == .webchat)
}
}
@MainActor
struct DeepLinkGatewayRoutingTests {
@Test func `valid gateway route is forwarded once even while app is paused`() async throws {
let state = AppStateStore.shared
let wasPaused = state.isPaused
defer { state.isPaused = wasPaused }
state.isPaused = true
var routed: [GatewayConnectDeepLink] = []
let handler = DeepLinkHandler(gatewaySetup: { routed.append($0) })
let url = try #require(URL(
string: "openclaw://gateway?host=gateway.example&port=443&tls=1&token=route-token"))
await handler.handle(url: url)
#expect(routed.count == 1)
#expect(routed.first?.host == "gateway.example")
}
@Test func `invalid route metadata omits query credentials`() throws {
let token = "fixture-route-token"
let password = "fixture-route-password"
let url = try #require(URL(
string: "openclaw://gateway?host=attacker.example&tls=0&token=\(token)&password=\(password)"))
let metadata = DeepLinkHandler.invalidRouteMetadata(url)
#expect(!metadata.contains(token))
#expect(!metadata.contains(password))
#expect(!metadata.contains("?"))
}
}
@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { isPidAlive } from "../shared/pid-alive.js";
import { killPidIfAlive } from "../test-utils/process-tree.js";
import { OpenClawStdioClientTransport } from "./mcp-stdio-transport.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -46,9 +47,7 @@ describe.skipIf(process.platform === "win32")("OpenClaw stdio process-group owne
expect(isPidAlive(descendantPid)).toBe(false);
} finally {
await transport.forceClose();
if (descendantPid && isPidAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
killPidIfAlive(descendantPid || undefined);
}
},
);
@@ -83,9 +82,7 @@ describe.skipIf(process.platform === "win32")("OpenClaw stdio process-group owne
await vi.waitFor(() => expect(isPidAlive(descendantPid)).toBe(false));
} finally {
await transport.forceClose();
if (descendantPid && isPidAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
killPidIfAlive(descendantPid || undefined);
}
},
);
@@ -718,6 +718,7 @@ describe("gateway server chat", () => {
expect(agentAllowedRes.payload?.status).toBe("accepted");
expect(agentAllowedRes.payload?.runId).toBe("idem-2");
await waitForFast(() => expect(agentCommandMock).toHaveBeenCalled());
await waitForFast(() => expect(getActiveGatewayRootWorkCount()).toBe(0));
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
@@ -910,6 +911,10 @@ describe("gateway server chat", () => {
const persistSpy = vi
.spyOn(sessionLifecycleState, "persistGatewaySessionLifecycleEvent")
.mockImplementation(async (params) => {
if (params.event.runId !== "idem-dispatch-error-1") {
await persistLifecycleEvent(params);
return;
}
persistenceEntered.resolve();
await releasePersistence.promise;
await persistLifecycleEvent(params);
+7 -4
View File
@@ -24,10 +24,7 @@ self.addEventListener("install", (event) => {
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const [cacheKeys, windowClients] = await Promise.all([
caches.keys(),
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
]);
const cacheKeys = await caches.keys();
const controlKeys = cacheKeys.filter((key) => key.startsWith(CACHE_PREFIX));
const priorCacheLimit = Math.max(0, CONTROL_CACHE_LIMIT - 1);
// Keep a small prior-build window so open tabs can still load old hashed chunks after updates.
@@ -42,6 +39,12 @@ self.addEventListener("activate", (event) => {
controlKeys.filter((key) => !retained.has(key)).map((key) => caches.delete(key)),
),
]);
// Enumerate after claim so a concurrent reload receives the activation event
// instead of leaving the new document unaware of the replacement worker.
const windowClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of windowClients) {
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- Service Worker Client.postMessage does not take targetOrigin.
+43 -13
View File
@@ -47,11 +47,11 @@ describe("native notifications", () => {
capability = createNativeNotificationsCapability();
expect(capability?.snapshot).toEqual({ permission: "unknown" });
expect(capability?.snapshot).toEqual({ permission: "unknown", test: null });
expect(postMessage).toHaveBeenCalledWith({ type: "status" });
});
it("seeds status from the native snapshot", () => {
it("accepts the permission-only native snapshot", () => {
installBridge();
(window as NativeNotificationsTestWindow)["__OPENCLAW_NATIVE_NOTIFICATIONS__"] = {
permission: "granted",
@@ -59,7 +59,7 @@ describe("native notifications", () => {
capability = createNativeNotificationsCapability();
expect(capability?.snapshot).toEqual({ permission: "granted" });
expect(capability?.snapshot).toEqual({ permission: "granted", test: null });
});
it("publishes valid status events", () => {
@@ -70,12 +70,12 @@ describe("native notifications", () => {
window.dispatchEvent(
new CustomEvent(NATIVE_NOTIFICATIONS_STATUS_EVENT, {
detail: { permission: "denied" },
detail: { permission: "denied", test: null },
}),
);
expect(capability?.snapshot).toEqual({ permission: "denied" });
expect(listener).toHaveBeenCalledWith({ permission: "denied" });
expect(capability?.snapshot).toEqual({ permission: "denied", test: null });
expect(listener).toHaveBeenCalledWith({ permission: "denied", test: null });
});
it("ignores invalid status event details", () => {
@@ -90,7 +90,7 @@ describe("native notifications", () => {
}),
);
expect(capability?.snapshot).toEqual({ permission: "unknown" });
expect(capability?.snapshot).toEqual({ permission: "unknown", test: null });
expect(listener).not.toHaveBeenCalled();
});
@@ -104,18 +104,48 @@ describe("native notifications", () => {
expect(postMessage).toHaveBeenCalledWith({ type: "status" });
});
it("posts permission and test actions", () => {
it("posts permission requests", () => {
const postMessage = installBridge();
capability = createNativeNotificationsCapability();
postMessage.mockClear();
capability?.requestPermission();
expect(postMessage).toHaveBeenCalledWith({ type: "request-permission" });
});
it("publishes pending immediately and suppresses duplicate test sends", () => {
const postMessage = installBridge();
capability = createNativeNotificationsCapability();
postMessage.mockClear();
capability?.sendTest();
capability?.sendTest();
expect(postMessage.mock.calls).toEqual([
[{ type: "request-permission" }],
[{ type: "send-test" }],
]);
expect(capability?.snapshot).toEqual({ permission: "unknown", test: { state: "pending" } });
expect(postMessage.mock.calls).toEqual([[{ type: "send-test" }]]);
});
it("keeps permission and failed send as independent facts across focus refresh", () => {
const postMessage = installBridge();
capability = createNativeNotificationsCapability();
window.dispatchEvent(
new CustomEvent(NATIVE_NOTIFICATIONS_STATUS_EVENT, {
detail: {
permission: "granted",
test: { state: "error", message: "Open System Settings and try again." },
},
}),
);
postMessage.mockClear();
window.dispatchEvent(new Event("focus"));
expect(capability?.snapshot).toEqual({
permission: "granted",
test: { state: "error", message: "Open System Settings and try again." },
});
expect(postMessage).toHaveBeenCalledWith({ type: "status" });
});
it("removes listeners on dispose", () => {
@@ -130,7 +160,7 @@ describe("native notifications", () => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(
new CustomEvent(NATIVE_NOTIFICATIONS_STATUS_EVENT, {
detail: { permission: "granted" },
detail: { permission: "granted", test: null },
}),
);
+31 -3
View File
@@ -1,7 +1,13 @@
export type NativeNotificationsPermission = "granted" | "denied" | "notDetermined";
export type NativeNotificationTestOutcome =
| { state: "pending" }
| { state: "sent" }
| { state: "error"; message: string };
type NativeNotificationsSnapshot = {
permission: NativeNotificationsPermission | "unknown";
test: NativeNotificationTestOutcome | null;
};
type NativeNotificationsMessage =
@@ -41,9 +47,26 @@ function snapshotFrom(value: unknown): NativeNotificationsSnapshot | null {
if (typeof value !== "object" || value === null || !("permission" in value)) {
return null;
}
return isNativeNotificationsPermission(value.permission)
? { permission: value.permission }
: null;
if (!isNativeNotificationsPermission(value.permission)) {
return null;
}
if (!("test" in value)) {
return { permission: value.permission, test: null };
}
const test = value.test;
if (test === null) {
return { permission: value.permission, test: null };
}
if (typeof test !== "object" || test === null || !("state" in test)) {
return null;
}
if (test.state === "pending" || test.state === "sent") {
return { permission: value.permission, test: { state: test.state } };
}
if (test.state === "error" && "message" in test && typeof test.message === "string") {
return { permission: value.permission, test: { state: "error", message: test.message } };
}
return null;
}
function getNativeNotificationsPoster():
@@ -66,6 +89,7 @@ export function createNativeNotificationsCapability(): NativeNotificationsCapabi
const nativeWindow = window as NativeNotificationsWindow;
let snapshot = snapshotFrom(nativeWindow["__OPENCLAW_NATIVE_NOTIFICATIONS__"]) ?? {
permission: "unknown" as const,
test: null,
};
const listeners = new Set<(snapshot: NativeNotificationsSnapshot) => void>();
@@ -100,6 +124,10 @@ export function createNativeNotificationsCapability(): NativeNotificationsCapabi
postMessage({ type: "request-permission" });
},
sendTest() {
if (snapshot.test?.state === "pending") {
return;
}
publish({ ...snapshot, test: { state: "pending" } });
postMessage({ type: "send-test" });
},
dispose() {
+1 -1
View File
@@ -71,7 +71,7 @@ describe("Control UI service worker cache versioning", () => {
await activationPromise;
expect(clients.matchAll).toHaveBeenCalledWith({ type: "window", includeUncontrolled: true });
expect(clients.claim).toHaveBeenCalled();
expect(clients.claim).toHaveBeenCalledBefore(clients.matchAll);
expect(cacheDelete).toHaveBeenCalledWith("openclaw-control-oldest");
expect(windowClient.postMessage).toHaveBeenCalledWith({
type: "sw-updated",
+4
View File
@@ -1779,6 +1779,10 @@ export const en: TranslationMap = {
status: "Status",
unsubscribe: "Unsubscribe",
sendTest: "Send test",
sendingTest: "Sending test…",
testOutcome: "Test notification",
testQueued: "Test notification queued",
testFailed: "Test notification failed",
subscribing: "Subscribing...",
enable: "Enable notifications",
openSystemSettings: "Open System Settings",
@@ -110,6 +110,7 @@ describe("widget theme bridge", () => {
}
vi.stubGlobal("MutationObserver", FakeMutationObserver);
vi.stubGlobal("window", {});
stubComputedStyles({ "--accent": "#c41e30" });
const chatFrame = document.createElement("iframe");
chatFrame.className = "chat-tool-card__preview-frame";
@@ -0,0 +1,60 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderNotificationsSection } from "./notifications-section.ts";
describe("native notification test outcome", () => {
it("renders pending immediately and disables duplicate sends", () => {
const onSend = vi.fn();
const container = document.createElement("div");
render(
renderNotificationsSection({
connected: true,
nativeNotifications: { permission: "granted", test: { state: "pending" } },
onNativeNotificationsSendTest: onSend,
}),
container,
);
const button = container.querySelector<HTMLButtonElement>("button");
expect(button?.disabled).toBe(true);
expect(button?.textContent).toContain("Sending test");
button?.click();
expect(onSend).not.toHaveBeenCalled();
});
it("renders an actionable error without replacing granted permission", () => {
const container = document.createElement("div");
render(
renderNotificationsSection({
connected: true,
nativeNotifications: {
permission: "granted",
test: { state: "error", message: "Open System Settings and try again." },
},
}),
container,
);
expect(container.textContent).toContain("Granted");
expect(container.textContent).toContain("Open System Settings and try again.");
expect(container.querySelector(".settings-status--danger")).not.toBeNull();
});
it("renders queued success independently from permission", () => {
const container = document.createElement("div");
render(
renderNotificationsSection({
connected: true,
nativeNotifications: { permission: "granted", test: { state: "sent" } },
}),
container,
);
expect(container.textContent).toContain("Granted");
expect(container.textContent).toContain("Test notification queued");
});
});
+40 -4
View File
@@ -1,5 +1,8 @@
import { html, nothing } from "lit";
import type { NativeNotificationsPermission } from "../../app/native-notifications.ts";
import type {
NativeNotificationsPermission,
NativeNotificationTestOutcome,
} from "../../app/native-notifications.ts";
import { icons } from "../../components/icons.ts";
import {
renderDocsLink,
@@ -30,7 +33,10 @@ export type WebPushUiState = {
// assignable to this subset.
type NotificationsSectionProps = {
connected: boolean;
nativeNotifications?: { permission: NativeNotificationsPermission | "unknown" };
nativeNotifications?: {
permission: NativeNotificationsPermission | "unknown";
test: NativeNotificationTestOutcome | null;
};
onNativeNotificationsRequestPermission?: () => void;
onNativeNotificationsSendTest?: () => void;
webPush?: WebPushUiState;
@@ -59,6 +65,7 @@ export function renderNotificationsSection(props: NotificationsSectionProps) {
const native = props.nativeNotifications;
if (native) {
const status = nativeNotificationsStatus(native.permission);
const testPending = native.test?.state === "pending";
const actionButton =
native.permission === "notDetermined"
? html`
@@ -77,8 +84,15 @@ export function renderNotificationsSection(props: NotificationsSectionProps) {
`
: native.permission === "granted"
? html`
<button class="btn primary" @click=${() => props.onNativeNotificationsSendTest?.()}>
${icons.send} ${t("configView.notifications.sendTest")}
<button
class="btn primary"
?disabled=${testPending}
@click=${() => props.onNativeNotificationsSendTest?.()}
>
${testPending ? icons.loader : icons.send}
${testPending
? t("configView.notifications.sendingTest")
: t("configView.notifications.sendTest")}
</button>
`
: nothing;
@@ -115,6 +129,28 @@ export function renderNotificationsSection(props: NotificationsSectionProps) {
}),
})
: nothing}
${native.test
? renderSettingsRow({
title: t("configView.notifications.testOutcome"),
description: native.test.state === "error" ? native.test.message : undefined,
control: renderSettingsStatus(
native.test.state === "pending"
? {
kind: "accent",
label: t("configView.notifications.sendingTest"),
}
: native.test.state === "sent"
? {
kind: "ok",
label: t("configView.notifications.testQueued"),
}
: {
kind: "danger",
label: t("configView.notifications.testFailed"),
},
),
})
: nothing}
</div>
</section>
</div>
+8 -2
View File
@@ -2,7 +2,10 @@ import type { TemplateResult } from "lit";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { ConfigUiHints, ModelCatalogEntry } from "../../api/types.ts";
import type { NativeNotificationsPermission } from "../../app/native-notifications.ts";
import type {
NativeNotificationsPermission,
NativeNotificationTestOutcome,
} from "../../app/native-notifications.ts";
import type { ServerUiPrefProvenance } from "../../app/server-prefs.ts";
import type { ChatFollowUpMode, ChatSendShortcut, CatalogOpenTarget } from "../../app/settings.ts";
import type { ThemeTransitionContext } from "../../app/theme-transition.ts";
@@ -187,7 +190,10 @@ export type ConfigProps = {
includeVirtualSections?: boolean;
/** Layout mode: "tabs" (default flat scroll) or "accordion" (grouped collapsible). */
settingsLayout?: "tabs" | "accordion";
nativeNotifications?: { permission: NativeNotificationsPermission | "unknown" };
nativeNotifications?: {
permission: NativeNotificationsPermission | "unknown";
test: NativeNotificationTestOutcome | null;
};
onNativeNotificationsRequestPermission?: () => void;
onNativeNotificationsSendTest?: () => void;
webPush?: WebPushUiState;