mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
66cacbae5d
commit
57e5ab7a87
Generated
+173
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user