mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(watchos): connect directly to Gateway as a node (#102893)
* feat(watchos): add direct gateway node * docs: refresh watch node docs map * chore: leave release notes to release workflow * chore(ios): refresh native localization inventory * fix(watchos): keep direct node policy bounded
This commit is contained in:
parent
b80062be3a
commit
54f45a950b
67 changed files with 3938 additions and 415 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -63,6 +63,8 @@ struct SettingsProTab: View {
|
||||||
@State var showResetOnboardingAlert = false
|
@State var showResetOnboardingAlert = false
|
||||||
@State var suppressCredentialPersist = false
|
@State var suppressCredentialPersist = false
|
||||||
@State var locationStatusText: String?
|
@State var locationStatusText: String?
|
||||||
|
@State var watchDirectSetupStatusText: String?
|
||||||
|
@State var isSendingWatchDirectSetup = false
|
||||||
@State var locationPermissionSummary = LocationPermissionSummary(
|
@State var locationPermissionSummary = LocationPermissionSummary(
|
||||||
desiredMode: .off,
|
desiredMode: .off,
|
||||||
locationServicesEnabled: true,
|
locationServicesEnabled: true,
|
||||||
|
|
|
||||||
|
|
@ -806,6 +806,7 @@ extension SettingsProTab {
|
||||||
func title(for route: SettingsRoute) -> String {
|
func title(for route: SettingsRoute) -> String {
|
||||||
switch route {
|
switch route {
|
||||||
case .gateway: "Gateway"
|
case .gateway: "Gateway"
|
||||||
|
case .appleWatch: "Apple Watch"
|
||||||
case .approvals: "Approvals"
|
case .approvals: "Approvals"
|
||||||
case .permissions: "Permissions"
|
case .permissions: "Permissions"
|
||||||
case .channels: "Channels"
|
case .channels: "Channels"
|
||||||
|
|
@ -818,6 +819,21 @@ extension SettingsProTab {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendDirectWatchSetup() async {
|
||||||
|
guard !self.isSendingWatchDirectSetup else { return }
|
||||||
|
self.isSendingWatchDirectSetup = true
|
||||||
|
self.watchDirectSetupStatusText = "Preparing one-time setup…"
|
||||||
|
defer { self.isSendingWatchDirectSetup = false }
|
||||||
|
do {
|
||||||
|
let result = try await self.appModel.sendDirectWatchSetup()
|
||||||
|
self.watchDirectSetupStatusText = result.deliveredImmediately
|
||||||
|
? "Setup sent. Open OpenClaw on the watch to connect."
|
||||||
|
: "Setup queued for the watch. Open OpenClaw before the code expires."
|
||||||
|
} catch {
|
||||||
|
self.watchDirectSetupStatusText = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var manualPortBinding: Binding<String> {
|
var manualPortBinding: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { self.manualGatewayPortText },
|
get: { self.manualGatewayPortText },
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,11 @@ extension SettingsProTab {
|
||||||
iconColor: .indigo,
|
iconColor: .indigo,
|
||||||
title: "Privacy",
|
title: "Privacy",
|
||||||
route: .privacy)
|
route: .privacy)
|
||||||
|
self.settingsListRow(
|
||||||
|
icon: "applewatch",
|
||||||
|
iconColor: .green,
|
||||||
|
title: "Apple Watch",
|
||||||
|
route: .appleWatch)
|
||||||
self.settingsListRow(
|
self.settingsListRow(
|
||||||
icon: "info.circle.fill",
|
icon: "info.circle.fill",
|
||||||
iconColor: .gray,
|
iconColor: .gray,
|
||||||
|
|
@ -224,6 +229,8 @@ extension SettingsProTab {
|
||||||
switch route {
|
switch route {
|
||||||
case .gateway:
|
case .gateway:
|
||||||
self.gatewayDestination
|
self.gatewayDestination
|
||||||
|
case .appleWatch:
|
||||||
|
self.appleWatchDestination
|
||||||
case .approvals:
|
case .approvals:
|
||||||
self.approvalsDestination
|
self.approvalsDestination
|
||||||
case .permissions:
|
case .permissions:
|
||||||
|
|
@ -247,6 +254,10 @@ extension SettingsProTab {
|
||||||
.font(OpenClawType.body)
|
.font(OpenClawType.body)
|
||||||
.navigationTitle(title(for: route))
|
.navigationTitle(title(for: route))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task(id: route) {
|
||||||
|
guard route == .appleWatch else { return }
|
||||||
|
await self.appModel.refreshWatchMessagingStatus()
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if let headerLeadingAction {
|
if let headerLeadingAction {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
|
@ -355,6 +366,50 @@ extension SettingsProTab {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appleWatchDestination: some View {
|
||||||
|
Group {
|
||||||
|
let watchStatus = self.appModel.watchMessagingStatus
|
||||||
|
self.detailStatusCard(
|
||||||
|
icon: "applewatch",
|
||||||
|
title: "Apple Watch",
|
||||||
|
detail: watchStatus.appInstalled
|
||||||
|
? "Relay remains available; direct mode adds an independent Gateway node."
|
||||||
|
: "Install the OpenClaw watch app before enabling direct mode.",
|
||||||
|
value: watchStatus.reachable ? "Reachable" : (watchStatus.appInstalled ? "Installed" : "Unavailable"),
|
||||||
|
color: watchStatus.appInstalled ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task { await self.sendDirectWatchSetup() }
|
||||||
|
} label: {
|
||||||
|
Label("Enable Direct Gateway Connection", systemImage: "point.3.connected.trianglepath.dotted")
|
||||||
|
.font(OpenClawType.body)
|
||||||
|
}
|
||||||
|
.disabled(
|
||||||
|
self.isSendingWatchDirectSetup
|
||||||
|
|| !self.appModel.isOperatorGatewayConnected
|
||||||
|
|| !self.appModel.hasOperatorAdminScope
|
||||||
|
|| !watchStatus.appInstalled)
|
||||||
|
|
||||||
|
if let statusText = self.watchDirectSetupStatusText {
|
||||||
|
Text(statusText)
|
||||||
|
.font(OpenClawType.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(
|
||||||
|
"The watch receives a one-time pairing code and stores its own device token. A reachable secure Gateway URL is required away from the iPhone.")
|
||||||
|
.font(OpenClawType.footnote)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Direct node features") {
|
||||||
|
SettingsDetailRow("Device", value: "Info and status")
|
||||||
|
SettingsDetailRow("Notifications", value: "While app is active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var approvalNotificationsWarningCard: some View {
|
var approvalNotificationsWarningCard: some View {
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import UserNotifications
|
||||||
|
|
||||||
enum SettingsRoute: Hashable {
|
enum SettingsRoute: Hashable {
|
||||||
case gateway
|
case gateway
|
||||||
|
case appleWatch
|
||||||
case approvals
|
case approvals
|
||||||
case permissions
|
case permissions
|
||||||
case channels
|
case channels
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ private struct ExecApprovalPromptCard: View {
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private func expiresText(_ expiresAtMs: Int?) -> String? {
|
private func expiresText(_ expiresAtMs: Int64?) -> String? {
|
||||||
guard let expiresAtMs else { return nil }
|
guard let expiresAtMs else { return nil }
|
||||||
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
|
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
|
||||||
if remainingSeconds <= 0 {
|
if remainingSeconds <= 0 {
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ final class NodeAppModel {
|
||||||
let host: String?
|
let host: String?
|
||||||
let nodeId: String?
|
let nodeId: String?
|
||||||
let agentId: String?
|
let agentId: String?
|
||||||
let expiresAtMs: Int?
|
let expiresAtMs: Int64?
|
||||||
|
|
||||||
var allowsAllowAlways: Bool {
|
var allowsAllowAlways: Bool {
|
||||||
self.allowedDecisions.contains("allow-always")
|
self.allowedDecisions.contains("allow-always")
|
||||||
|
|
@ -493,6 +493,12 @@ final class NodeAppModel {
|
||||||
var cameraHUDKind: CameraHUDKind?
|
var cameraHUDKind: CameraHUDKind?
|
||||||
var cameraFlashNonce: Int = 0
|
var cameraFlashNonce: Int = 0
|
||||||
var screenRecordActive: Bool = false
|
var screenRecordActive: Bool = false
|
||||||
|
private(set) var watchMessagingStatus = WatchMessagingStatus(
|
||||||
|
supported: false,
|
||||||
|
paired: false,
|
||||||
|
appInstalled: false,
|
||||||
|
reachable: false,
|
||||||
|
activationState: "notActivated")
|
||||||
|
|
||||||
init(
|
init(
|
||||||
screen: ScreenController = ScreenController(),
|
screen: ScreenController = ScreenController(),
|
||||||
|
|
@ -2295,6 +2301,47 @@ extension NodeAppModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendDirectWatchSetup() async throws -> WatchNotificationSendResult {
|
||||||
|
struct SetupCodeResponse: Decodable {
|
||||||
|
var setupCode: String
|
||||||
|
}
|
||||||
|
|
||||||
|
guard self.isOperatorGatewayConnected else {
|
||||||
|
throw NSError(domain: "WatchDirectSetup", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Connect the iPhone to a Gateway first.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
guard self.hasOperatorAdminScope else {
|
||||||
|
throw NSError(domain: "WatchDirectSetup", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "The iPhone connection needs operator.admin access.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
let status = await watchMessagingService.status()
|
||||||
|
guard status.supported, status.paired, status.appInstalled else {
|
||||||
|
throw NSError(domain: "WatchDirectSetup", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Pair an Apple Watch and install the OpenClaw watch app first.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try await operatorGateway.request(
|
||||||
|
method: "device.pair.setupCode",
|
||||||
|
paramsJSON: #"{"includeQr":false,"bootstrapProfile":"node"}"#,
|
||||||
|
timeoutSeconds: 20)
|
||||||
|
let setup = try JSONDecoder().decode(SetupCodeResponse.self, from: response)
|
||||||
|
guard let setupLink = GatewayConnectDeepLink.fromSetupCode(setup.setupCode),
|
||||||
|
setupLink.connectionEndpoints.contains(where: \.tls)
|
||||||
|
else {
|
||||||
|
throw NSError(domain: "WatchDirectSetup", code: 4, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Direct Apple Watch mode requires a trusted HTTPS Gateway endpoint.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return try await self.watchMessagingService.sendDirectNodeSetup(setupCode: setup.setupCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshWatchMessagingStatus() async {
|
||||||
|
self.watchMessagingStatus = await self.watchMessagingService.status()
|
||||||
|
}
|
||||||
|
|
||||||
private func locationMode() -> OpenClawLocationMode {
|
private func locationMode() -> OpenClawLocationMode {
|
||||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||||
|
|
@ -4359,8 +4406,8 @@ extension NodeAppModel {
|
||||||
private func persistWatchExecApprovalBridgeState() {
|
private func persistWatchExecApprovalBridgeState() {
|
||||||
self.pruneExpiredWatchExecApprovalPrompts()
|
self.pruneExpiredWatchExecApprovalPrompts()
|
||||||
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
|
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
|
||||||
let lhsExpires = lhs.expiresAtMs ?? Int.max
|
let lhsExpires = lhs.expiresAtMs ?? Int64.max
|
||||||
let rhsExpires = rhs.expiresAtMs ?? Int.max
|
let rhsExpires = rhs.expiresAtMs ?? Int64.max
|
||||||
if lhsExpires != rhsExpires {
|
if lhsExpires != rhsExpires {
|
||||||
return lhsExpires < rhsExpires
|
return lhsExpires < rhsExpires
|
||||||
}
|
}
|
||||||
|
|
@ -4384,8 +4431,8 @@ extension NodeAppModel {
|
||||||
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
|
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) {
|
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int64? = nil) {
|
||||||
let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000)
|
let currentNowMs = nowMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
|
self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
|
||||||
guard let expiresAtMs = prompt.expiresAtMs else { return true }
|
guard let expiresAtMs = prompt.expiresAtMs else { return true }
|
||||||
return expiresAtMs > currentNowMs
|
return expiresAtMs > currentNowMs
|
||||||
|
|
@ -4393,6 +4440,7 @@ extension NodeAppModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
|
private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
|
||||||
|
self.watchMessagingStatus = status
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch exec approval: status changed "
|
"watch exec approval: status changed "
|
||||||
+ "reachable=\(status.reachable) activation=\(status.activationState) "
|
+ "reachable=\(status.reachable) activation=\(status.activationState) "
|
||||||
|
|
@ -4493,7 +4541,7 @@ extension NodeAppModel {
|
||||||
let deliveryGeneration = self.gatewayConnectGeneration
|
let deliveryGeneration = self.gatewayConnectGeneration
|
||||||
let message = OpenClawWatchExecApprovalPromptMessage(
|
let message = OpenClawWatchExecApprovalPromptMessage(
|
||||||
approval: Self.makeWatchExecApprovalItem(from: prompt),
|
approval: Self.makeWatchExecApprovalItem(from: prompt),
|
||||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
deliveryId: UUID().uuidString,
|
deliveryId: UUID().uuidString,
|
||||||
resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
|
resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
|
||||||
do {
|
do {
|
||||||
|
|
@ -4533,7 +4581,7 @@ extension NodeAppModel {
|
||||||
approvalId: normalizedApprovalID,
|
approvalId: normalizedApprovalID,
|
||||||
gatewayStableID: gatewayStableID,
|
gatewayStableID: gatewayStableID,
|
||||||
decision: decision,
|
decision: decision,
|
||||||
resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
source: source)
|
source: source)
|
||||||
do {
|
do {
|
||||||
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
||||||
|
|
@ -4562,7 +4610,7 @@ extension NodeAppModel {
|
||||||
approvalId: normalizedApprovalID,
|
approvalId: normalizedApprovalID,
|
||||||
gatewayStableID: gatewayStableID,
|
gatewayStableID: gatewayStableID,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
expiredAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
expiredAtMs: Int64(Date().timeIntervalSince1970 * 1000))
|
||||||
do {
|
do {
|
||||||
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -4590,8 +4638,8 @@ extension NodeAppModel {
|
||||||
let approvals = self.watchExecApprovalPromptsByID.values
|
let approvals = self.watchExecApprovalPromptsByID.values
|
||||||
.filter(self.isExecApprovalPromptCurrent)
|
.filter(self.isExecApprovalPromptCurrent)
|
||||||
.sorted { lhs, rhs in
|
.sorted { lhs, rhs in
|
||||||
let lhsExpires = lhs.expiresAtMs ?? Int.max
|
let lhsExpires = lhs.expiresAtMs ?? Int64.max
|
||||||
let rhsExpires = rhs.expiresAtMs ?? Int.max
|
let rhsExpires = rhs.expiresAtMs ?? Int64.max
|
||||||
if lhsExpires != rhsExpires {
|
if lhsExpires != rhsExpires {
|
||||||
return lhsExpires < rhsExpires
|
return lhsExpires < rhsExpires
|
||||||
}
|
}
|
||||||
|
|
@ -4601,7 +4649,7 @@ extension NodeAppModel {
|
||||||
let message = OpenClawWatchExecApprovalSnapshotMessage(
|
let message = OpenClawWatchExecApprovalSnapshotMessage(
|
||||||
approvals: approvals,
|
approvals: approvals,
|
||||||
gatewayStableID: currentExecApprovalGatewayStableID(),
|
gatewayStableID: currentExecApprovalGatewayStableID(),
|
||||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
snapshotId: UUID().uuidString)
|
snapshotId: UUID().uuidString)
|
||||||
do {
|
do {
|
||||||
guard shouldContinue() else { return }
|
guard shouldContinue() else { return }
|
||||||
|
|
@ -4655,7 +4703,7 @@ extension NodeAppModel {
|
||||||
from raw: [OpenClawKit.AnyCodable],
|
from raw: [OpenClawKit.AnyCodable],
|
||||||
runId: String,
|
runId: String,
|
||||||
submittedText: String,
|
submittedText: String,
|
||||||
submittedAtMs: Int) -> String?
|
submittedAtMs: Int64) -> String?
|
||||||
{
|
{
|
||||||
let entries = raw.compactMap(self.decodeWatchChatMessage)
|
let entries = raw.compactMap(self.decodeWatchChatMessage)
|
||||||
if let directReply = entries.last(where: {
|
if let directReply = entries.last(where: {
|
||||||
|
|
@ -4775,7 +4823,7 @@ extension NodeAppModel {
|
||||||
return "\(trimmed.prefix(237))..."
|
return "\(trimmed.prefix(237))..."
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int? {
|
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int64? {
|
||||||
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
|
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
|
||||||
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
|
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
|
||||||
let maxReasonableEpochMs: Double = 32_503_680_000_000
|
let maxReasonableEpochMs: Double = 32_503_680_000_000
|
||||||
|
|
@ -4785,7 +4833,7 @@ extension NodeAppModel {
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return Int(milliseconds)
|
return Int64(milliseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeWatchAppSnapshot(
|
private func makeWatchAppSnapshot(
|
||||||
|
|
@ -4813,7 +4861,7 @@ extension NodeAppModel {
|
||||||
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
|
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
|
||||||
chatItems: chatPreview?.items,
|
chatItems: chatPreview?.items,
|
||||||
chatStatusText: chatPreview?.statusText,
|
chatStatusText: chatPreview?.statusText,
|
||||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
snapshotId: UUID().uuidString)
|
snapshotId: UUID().uuidString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4998,7 +5046,7 @@ extension NodeAppModel {
|
||||||
let thinking = messageKind == .quickReply ? "low" : "auto"
|
let thinking = messageKind == .quickReply ? "low" : "auto"
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let submittedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
if self.isAppleReviewDemoModeEnabled {
|
if self.isAppleReviewDemoModeEnabled {
|
||||||
let response = try await appleReviewDemoChatTransport.sendMessage(
|
let response = try await appleReviewDemoChatTransport.sendMessage(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
|
|
@ -5114,7 +5162,7 @@ extension NodeAppModel {
|
||||||
sessionKey: String,
|
sessionKey: String,
|
||||||
runId: String,
|
runId: String,
|
||||||
submittedText: String,
|
submittedText: String,
|
||||||
submittedAtMs: Int,
|
submittedAtMs: Int64,
|
||||||
deadline: Date,
|
deadline: Date,
|
||||||
expectedRoute: GatewayNodeSessionRoute) async -> String?
|
expectedRoute: GatewayNodeSessionRoute) async -> String?
|
||||||
{
|
{
|
||||||
|
|
@ -5143,7 +5191,7 @@ extension NodeAppModel {
|
||||||
OpenClawWatchChatCompletionMessage(
|
OpenClawWatchChatCompletionMessage(
|
||||||
commandId: commandId,
|
commandId: commandId,
|
||||||
replyText: replyText,
|
replyText: replyText,
|
||||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000)))
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000)))
|
||||||
} catch {
|
} catch {
|
||||||
GatewayDiagnostics.log(
|
GatewayDiagnostics.log(
|
||||||
"watch chat completion failed commandId=\(commandId) error=\(error.localizedDescription)")
|
"watch chat completion failed commandId=\(commandId) error=\(error.localizedDescription)")
|
||||||
|
|
@ -5963,7 +6011,7 @@ extension NodeAppModel {
|
||||||
var host: String?
|
var host: String?
|
||||||
var nodeId: String?
|
var nodeId: String?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var expiresAtMs: Int?
|
var expiresAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentExecApprovalNotificationPrompt(
|
func presentExecApprovalNotificationPrompt(
|
||||||
|
|
@ -7143,7 +7191,7 @@ extension NodeAppModel {
|
||||||
from raw: [OpenClawKit.AnyCodable],
|
from raw: [OpenClawKit.AnyCodable],
|
||||||
runId: String,
|
runId: String,
|
||||||
submittedText: String,
|
submittedText: String,
|
||||||
submittedAtMs: Int) -> String?
|
submittedAtMs: Int64) -> String?
|
||||||
{
|
{
|
||||||
self.watchChatReplyText(
|
self.watchChatReplyText(
|
||||||
from: raw,
|
from: raw,
|
||||||
|
|
@ -7318,7 +7366,7 @@ extension NodeAppModel {
|
||||||
host: String?,
|
host: String?,
|
||||||
nodeId: String?,
|
nodeId: String?,
|
||||||
agentId: String?,
|
agentId: String?,
|
||||||
expiresAtMs: Int?) -> ExecApprovalPrompt?
|
expiresAtMs: Int64?) -> ExecApprovalPrompt?
|
||||||
{
|
{
|
||||||
self.makeExecApprovalPrompt(
|
self.makeExecApprovalPrompt(
|
||||||
from: ExecApprovalGetResponse(
|
from: ExecApprovalGetResponse(
|
||||||
|
|
|
||||||
|
|
@ -660,7 +660,7 @@ extension NodeAppModel {
|
||||||
sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil,
|
sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil,
|
||||||
gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil,
|
gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil,
|
||||||
note: "source=ios.notification",
|
note: "source=ios.notification",
|
||||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
transport: "ios.notification")
|
transport: "ios.notification")
|
||||||
await _bridgeConsumeMirroredWatchReply(event)
|
await _bridgeConsumeMirroredWatchReply(event)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ struct WatchQuickReplyEvent: Codable, Equatable {
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var note: String?
|
var note: String?
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,19 +98,19 @@ struct WatchExecApprovalResolveEvent: Codable, Equatable {
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var decision: OpenClawWatchExecApprovalDecision
|
var decision: OpenClawWatchExecApprovalDecision
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
|
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
|
||||||
var requestId: String
|
var requestId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchAppSnapshotRequestEvent: Equatable {
|
struct WatchAppSnapshotRequestEvent: Equatable {
|
||||||
var requestId: String
|
var requestId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var transport: String
|
var transport: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ struct WatchAppCommandEvent: Codable, Equatable {
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var text: String?
|
var text: String?
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var transport: String
|
var transport: String
|
||||||
var messageKind: WatchMessageKind? = nil
|
var messageKind: WatchMessageKind? = nil
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +140,7 @@ protocol WatchMessagingServicing: AnyObject, Sendable {
|
||||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||||
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?)
|
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?)
|
||||||
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
|
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
|
||||||
|
func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult
|
||||||
func sendNotification(
|
func sendNotification(
|
||||||
id: String,
|
id: String,
|
||||||
params: OpenClawWatchNotifyParams,
|
params: OpenClawWatchNotifyParams,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ enum WatchMessagingPayloadCodec {
|
||||||
|
|
||||||
static let completedChatReplyTextLimit = 4000
|
static let completedChatReplyTextLimit = 4000
|
||||||
|
|
||||||
static func nowMs() -> Int {
|
static func nowMs() -> Int64 {
|
||||||
Int(Date().timeIntervalSince1970 * 1000)
|
Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nonEmpty(_ value: String?) -> String? {
|
static func nonEmpty(_ value: String?) -> String? {
|
||||||
|
|
@ -67,6 +67,14 @@ enum WatchMessagingPayloadCodec {
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func encodeDirectNodeSetupPayload(setupCode: String) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"type": OpenClawWatchPayloadType.directNodeSetup.rawValue,
|
||||||
|
"setupCode": setupCode,
|
||||||
|
"sentAtMs": self.nowMs(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
|
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
|
||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
|
|
@ -280,7 +288,7 @@ enum WatchMessagingPayloadCodec {
|
||||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||||
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
||||||
let note = self.nonEmpty(payload["note"] as? String)
|
let note = self.nonEmpty(payload["note"] as? String)
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
|
|
||||||
return WatchQuickReplyEvent(
|
return WatchQuickReplyEvent(
|
||||||
replyId: replyId,
|
replyId: replyId,
|
||||||
|
|
@ -309,7 +317,7 @@ enum WatchMessagingPayloadCodec {
|
||||||
}
|
}
|
||||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||||
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
return WatchExecApprovalResolveEvent(
|
return WatchExecApprovalResolveEvent(
|
||||||
replyId: replyId,
|
replyId: replyId,
|
||||||
approvalId: approvalId,
|
approvalId: approvalId,
|
||||||
|
|
@ -327,7 +335,7 @@ enum WatchMessagingPayloadCodec {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
return WatchExecApprovalSnapshotRequestEvent(
|
return WatchExecApprovalSnapshotRequestEvent(
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
sentAtMs: sentAtMs,
|
sentAtMs: sentAtMs,
|
||||||
|
|
@ -342,7 +350,7 @@ enum WatchMessagingPayloadCodec {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
return WatchAppSnapshotRequestEvent(
|
return WatchAppSnapshotRequestEvent(
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
sentAtMs: sentAtMs,
|
sentAtMs: sentAtMs,
|
||||||
|
|
@ -365,7 +373,7 @@ enum WatchMessagingPayloadCodec {
|
||||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||||
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
||||||
let text = self.nonEmpty(payload["text"] as? String)
|
let text = self.nonEmpty(payload["text"] as? String)
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
return WatchAppCommandEvent(
|
return WatchAppCommandEvent(
|
||||||
commandId: commandId,
|
commandId: commandId,
|
||||||
command: command,
|
command: command,
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,11 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
|
||||||
return try await self.transport.sendPayload(payload)
|
return try await self.transport.sendPayload(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult {
|
||||||
|
try await self.transport.sendPayload(
|
||||||
|
WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload(setupCode: setupCode))
|
||||||
|
}
|
||||||
|
|
||||||
func sendExecApprovalPrompt(
|
func sendExecApprovalPrompt(
|
||||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||||
transport: "sendMessage")
|
transport: "sendMessage")
|
||||||
var sendError: Error?
|
var sendError: Error?
|
||||||
var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)?
|
var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)?
|
||||||
|
var lastDirectNodeSetupCode: String?
|
||||||
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
|
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
|
||||||
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
|
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
|
||||||
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
|
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
|
||||||
|
|
@ -155,6 +156,11 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||||
self.statusHandler = handler
|
self.statusHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func emitStatus(_ status: WatchMessagingStatus) {
|
||||||
|
self.currentStatus = status
|
||||||
|
self.statusHandler?(status)
|
||||||
|
}
|
||||||
|
|
||||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||||
self.replyHandler = handler
|
self.replyHandler = handler
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +195,14 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||||
return self.nextSendResult
|
return self.nextSendResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult {
|
||||||
|
self.lastDirectNodeSetupCode = setupCode
|
||||||
|
if let sendError {
|
||||||
|
throw sendError
|
||||||
|
}
|
||||||
|
return self.nextSendResult
|
||||||
|
}
|
||||||
|
|
||||||
func sendExecApprovalPrompt(
|
func sendExecApprovalPrompt(
|
||||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||||
{
|
{
|
||||||
|
|
@ -942,7 +956,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
@Test @MainActor func `watch exec approval snapshot request publishes cached approvals in background`() async throws {
|
@Test @MainActor func `watch exec approval snapshot request publishes cached approvals in background`() async throws {
|
||||||
let watchService = MockWatchMessagingService()
|
let watchService = MockWatchMessagingService()
|
||||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000
|
let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000
|
||||||
try appModel._test_presentExecApprovalPrompt(
|
try appModel._test_presentExecApprovalPrompt(
|
||||||
#require(
|
#require(
|
||||||
NodeAppModel._test_makeExecApprovalPrompt(
|
NodeAppModel._test_makeExecApprovalPrompt(
|
||||||
|
|
@ -970,7 +984,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
@Test @MainActor func `watch exec approval snapshot request skips foreground recovery`() async throws {
|
@Test @MainActor func `watch exec approval snapshot request skips foreground recovery`() async throws {
|
||||||
let watchService = MockWatchMessagingService()
|
let watchService = MockWatchMessagingService()
|
||||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000
|
let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000
|
||||||
try appModel._test_presentExecApprovalPrompt(
|
try appModel._test_presentExecApprovalPrompt(
|
||||||
#require(
|
#require(
|
||||||
NodeAppModel._test_makeExecApprovalPrompt(
|
NodeAppModel._test_makeExecApprovalPrompt(
|
||||||
|
|
@ -1764,7 +1778,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
gatewayStableID: nil,
|
gatewayStableID: nil,
|
||||||
text: "Message \(index)",
|
text: "Message \(index)",
|
||||||
sentAtMs: index,
|
sentAtMs: Int64(index),
|
||||||
transport: "sendMessage")
|
transport: "sendMessage")
|
||||||
if case .forward = coordinator.ingest(
|
if case .forward = coordinator.ingest(
|
||||||
event,
|
event,
|
||||||
|
|
@ -1830,7 +1844,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
gatewayStableID: nil,
|
gatewayStableID: nil,
|
||||||
text: "Queued \(index)",
|
text: "Queued \(index)",
|
||||||
sentAtMs: index,
|
sentAtMs: Int64(index),
|
||||||
transport: "transferUserInfo")
|
transport: "transferUserInfo")
|
||||||
if case .queue = coordinator.ingest(
|
if case .queue = coordinator.ingest(
|
||||||
event,
|
event,
|
||||||
|
|
@ -1920,7 +1934,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
host: "gateway",
|
host: "gateway",
|
||||||
nodeId: nil,
|
nodeId: nil,
|
||||||
agentId: nil,
|
agentId: nil,
|
||||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
|
expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000)))
|
||||||
|
|
||||||
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
|
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
|
||||||
}
|
}
|
||||||
|
|
@ -1996,7 +2010,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
host: "gateway",
|
host: "gateway",
|
||||||
nodeId: nil,
|
nodeId: nil,
|
||||||
agentId: nil,
|
agentId: nil,
|
||||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
|
expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000)))
|
||||||
|
|
||||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||||
type: "event",
|
type: "event",
|
||||||
|
|
@ -2484,6 +2498,38 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
#expect(payload.activationState == "inactive")
|
#expect(payload.activationState == "inactive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func `watch status refresh publishes service snapshot`() async {
|
||||||
|
let watchService = MockWatchMessagingService()
|
||||||
|
let status = WatchMessagingStatus(
|
||||||
|
supported: true,
|
||||||
|
paired: true,
|
||||||
|
appInstalled: true,
|
||||||
|
reachable: false,
|
||||||
|
activationState: "activated")
|
||||||
|
watchService.currentStatus = status
|
||||||
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
|
|
||||||
|
await appModel.refreshWatchMessagingStatus()
|
||||||
|
|
||||||
|
#expect(appModel.watchMessagingStatus == status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func `watch status callback publishes reachability changes`() async {
|
||||||
|
let watchService = MockWatchMessagingService()
|
||||||
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
|
let status = WatchMessagingStatus(
|
||||||
|
supported: true,
|
||||||
|
paired: true,
|
||||||
|
appInstalled: true,
|
||||||
|
reachable: true,
|
||||||
|
activationState: "activated")
|
||||||
|
|
||||||
|
watchService.emitStatus(status)
|
||||||
|
await waitForMainActorWork { appModel.watchMessagingStatus == status }
|
||||||
|
|
||||||
|
#expect(appModel.watchMessagingStatus == status)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor func `handle invoke watch notify routes to watch service`() async throws {
|
@Test @MainActor func `handle invoke watch notify routes to watch service`() async throws {
|
||||||
let watchService = MockWatchMessagingService()
|
let watchService = MockWatchMessagingService()
|
||||||
watchService.nextSendResult = WatchNotificationSendResult(
|
watchService.nextSendResult = WatchNotificationSendResult(
|
||||||
|
|
@ -2573,6 +2619,54 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
#expect(expired["gatewayStableID"] as? String == "gateway-a")
|
#expect(expired["gatewayStableID"] as? String == "gateway-a")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func `watch direct node setup codec carries opaque setup code`() {
|
||||||
|
let payload = WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload(
|
||||||
|
setupCode: "opaque-bootstrap-code")
|
||||||
|
|
||||||
|
#expect(payload["type"] as? String == OpenClawWatchPayloadType.directNodeSetup.rawValue)
|
||||||
|
#expect(payload["setupCode"] as? String == "opaque-bootstrap-code")
|
||||||
|
#expect(payload["sentAtMs"] is Int64)
|
||||||
|
#expect(payload["token"] == nil)
|
||||||
|
#expect(payload["password"] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func `watch payload codec preserves 64 bit epoch milliseconds`() throws {
|
||||||
|
let sentAtMs: Int64 = 1_725_000_000_123
|
||||||
|
let encodedTimestamp = NSNumber(value: sentAtMs)
|
||||||
|
|
||||||
|
let reply = try #require(WatchMessagingPayloadCodec.parseQuickReplyPayload([
|
||||||
|
"type": OpenClawWatchPayloadType.reply.rawValue,
|
||||||
|
"actionId": "approve",
|
||||||
|
"sentAtMs": encodedTimestamp,
|
||||||
|
], transport: "sendMessage"))
|
||||||
|
let resolution = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([
|
||||||
|
"type": OpenClawWatchPayloadType.execApprovalResolve.rawValue,
|
||||||
|
"approvalId": "approval-a",
|
||||||
|
"decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue,
|
||||||
|
"sentAtMs": encodedTimestamp,
|
||||||
|
], transport: "sendMessage"))
|
||||||
|
let approvalSnapshotRequest = try #require(
|
||||||
|
WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([
|
||||||
|
"type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue,
|
||||||
|
"sentAtMs": encodedTimestamp,
|
||||||
|
], transport: "sendMessage"))
|
||||||
|
let appSnapshotRequest = try #require(WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload([
|
||||||
|
"type": OpenClawWatchPayloadType.appSnapshotRequest.rawValue,
|
||||||
|
"sentAtMs": encodedTimestamp,
|
||||||
|
], transport: "sendMessage"))
|
||||||
|
let appCommand = try #require(WatchMessagingPayloadCodec.parseAppCommandPayload([
|
||||||
|
"type": OpenClawWatchPayloadType.appCommand.rawValue,
|
||||||
|
"command": OpenClawWatchAppCommand.refresh.rawValue,
|
||||||
|
"sentAtMs": encodedTimestamp,
|
||||||
|
], transport: "sendMessage"))
|
||||||
|
|
||||||
|
#expect(reply.sentAtMs == sentAtMs)
|
||||||
|
#expect(resolution.sentAtMs == sentAtMs)
|
||||||
|
#expect(approvalSnapshotRequest.sentAtMs == sentAtMs)
|
||||||
|
#expect(appSnapshotRequest.sentAtMs == sentAtMs)
|
||||||
|
#expect(appCommand.sentAtMs == sentAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor func `watch application context retains app and approval snapshots`() throws {
|
@Test @MainActor func `watch application context retains app and approval snapshots`() throws {
|
||||||
let appPayload = WatchMessagingPayloadCodec.encodeAppSnapshotPayload(
|
let appPayload = WatchMessagingPayloadCodec.encodeAppSnapshotPayload(
|
||||||
OpenClawWatchAppSnapshotMessage(
|
OpenClawWatchAppSnapshotMessage(
|
||||||
|
|
@ -2838,7 +2932,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
gatewayStableID: "gateway-a",
|
gatewayStableID: "gateway-a",
|
||||||
text: "Pending \(index)",
|
text: "Pending \(index)",
|
||||||
sentAtMs: index + 2,
|
sentAtMs: Int64(index + 2),
|
||||||
transport: "transferUserInfo")
|
transport: "transferUserInfo")
|
||||||
_ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a")
|
_ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,15 @@ struct WatchDeferredPayloadOrderingTests {
|
||||||
@Test func `replays missing timestamps first in receipt order`() {
|
@Test func `replays missing timestamps first in receipt order`() {
|
||||||
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1])
|
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func `epoch values preserve ordering above 32 bit range`() {
|
||||||
|
let earlier: Int64 = 1_700_000_000_000
|
||||||
|
let later = earlier + 1
|
||||||
|
|
||||||
|
#expect(WatchDeferredPayloadOrdering.isExpired(expiresAtMs: earlier, nowMs: later))
|
||||||
|
#expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(
|
||||||
|
payloadSentAtMs: later,
|
||||||
|
snapshotSentAtMs: earlier))
|
||||||
|
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [later, earlier]) == [1, 0])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@
|
||||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks.</string>
|
||||||
|
<key>WKPrefersNetworkUponForeground</key>
|
||||||
|
<true/>
|
||||||
<key>UIAppFonts</key>
|
<key>UIAppFonts</key>
|
||||||
<array>
|
<array>
|
||||||
<string>RedHatDisplay[wght].ttf</string>
|
<string>RedHatDisplay[wght].ttf</string>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,25 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
private final class WatchNotificationPresentationDelegate: NSObject, UNUserNotificationCenterDelegate,
|
||||||
|
@unchecked Sendable
|
||||||
|
{
|
||||||
|
func userNotificationCenter(
|
||||||
|
_: UNUserNotificationCenter,
|
||||||
|
willPresent _: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
|
||||||
|
{
|
||||||
|
completionHandler([.banner, .list, .sound])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OpenClawWatchApp: App {
|
struct OpenClawWatchApp: App {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var inboxStore = WatchInboxStore(
|
@State private var inboxStore = WatchInboxStore(
|
||||||
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
|
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
|
||||||
|
@State private var directNode = WatchDirectNode()
|
||||||
|
@State private var notificationDelegate = WatchNotificationPresentationDelegate()
|
||||||
@State private var receiver: WatchConnectivityReceiver?
|
@State private var receiver: WatchConnectivityReceiver?
|
||||||
@State private var execApprovalRefreshTask: Task<Void, Never>?
|
@State private var execApprovalRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
|
@ -18,6 +33,7 @@ struct OpenClawWatchApp: App {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
WatchInboxView(
|
WatchInboxView(
|
||||||
store: self.inboxStore,
|
store: self.inboxStore,
|
||||||
|
directNode: self.directNode,
|
||||||
onAction: { action in
|
onAction: { action in
|
||||||
guard let receiver = self.receiver else { return }
|
guard let receiver = self.receiver else { return }
|
||||||
let draft = self.inboxStore.makeReplyDraft(action: action)
|
let draft = self.inboxStore.makeReplyDraft(action: action)
|
||||||
|
|
@ -54,22 +70,37 @@ struct OpenClawWatchApp: App {
|
||||||
self.sendChatMessage(text)
|
self.sendChatMessage(text)
|
||||||
})
|
})
|
||||||
.task {
|
.task {
|
||||||
|
UNUserNotificationCenter.current().delegate = self.notificationDelegate
|
||||||
if OpenClawWatchApp.isScreenshotMode {
|
if OpenClawWatchApp.isScreenshotMode {
|
||||||
self.inboxStore.configureScreenshotFixture()
|
self.inboxStore.configureScreenshotFixture()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if self.receiver == nil {
|
if self.receiver == nil {
|
||||||
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
|
let receiver = WatchConnectivityReceiver(
|
||||||
|
store: self.inboxStore,
|
||||||
|
directNodeSetupHandler: { [weak directNode] setupCode, sentAtMs in
|
||||||
|
directNode?.configure(setupCode: setupCode, sentAtMs: sentAtMs)
|
||||||
|
})
|
||||||
receiver.activate()
|
receiver.activate()
|
||||||
self.receiver = receiver
|
self.receiver = receiver
|
||||||
}
|
}
|
||||||
|
if self.scenePhase == .active {
|
||||||
|
self.directNode.connectForForeground()
|
||||||
|
}
|
||||||
self.refreshAppSnapshot()
|
self.refreshAppSnapshot()
|
||||||
self.refreshExecApprovalReview()
|
self.refreshExecApprovalReview()
|
||||||
}
|
}
|
||||||
.onChange(of: self.scenePhase) { _, newPhase in
|
.onChange(of: self.scenePhase) { _, newPhase in
|
||||||
guard newPhase == .active else { return }
|
switch newPhase {
|
||||||
self.refreshAppSnapshot()
|
case .active:
|
||||||
self.refreshExecApprovalReview()
|
self.directNode.connectForForeground()
|
||||||
|
self.refreshAppSnapshot()
|
||||||
|
self.refreshExecApprovalReview()
|
||||||
|
case .inactive, .background:
|
||||||
|
self.directNode.disconnectForBackground()
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +175,7 @@ struct OpenClawWatchApp: App {
|
||||||
@MainActor
|
@MainActor
|
||||||
extension WatchInboxStore {
|
extension WatchInboxStore {
|
||||||
fileprivate func configureScreenshotFixture() {
|
fileprivate func configureScreenshotFixture() {
|
||||||
let sentAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let sentAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
greetingTextOverride = "Good morning"
|
greetingTextOverride = "Good morning"
|
||||||
self.consume(
|
self.consume(
|
||||||
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
|
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ struct WatchReplyDraft {
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var note: String?
|
var note: String?
|
||||||
var sentAtMs: Int
|
var sentAtMs: Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchReplySendResult: Equatable {
|
struct WatchReplySendResult: Equatable {
|
||||||
|
|
@ -25,9 +25,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
private let store: WatchInboxStore
|
private let store: WatchInboxStore
|
||||||
private let session: WCSession?
|
private let session: WCSession?
|
||||||
private let activationGate = WatchSessionActivationGate()
|
private let activationGate = WatchSessionActivationGate()
|
||||||
|
private let directNodeSetupHandler: @MainActor @Sendable (String, Int64) -> Void
|
||||||
|
|
||||||
init(store: WatchInboxStore) {
|
init(
|
||||||
|
store: WatchInboxStore,
|
||||||
|
directNodeSetupHandler: @escaping @MainActor @Sendable (String, Int64) -> Void)
|
||||||
|
{
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.directNodeSetupHandler = directNodeSetupHandler
|
||||||
if WCSession.isSupported() {
|
if WCSession.isSupported() {
|
||||||
self.session = WCSession.default
|
self.session = WCSession.default
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -203,8 +208,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
errorMessage: error.localizedDescription)
|
errorMessage: error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func nowMs() -> Int {
|
private static func nowMs() -> Int64 {
|
||||||
Int(Date().timeIntervalSince1970 * 1000)
|
Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeObject(_ value: Any) -> [String: Any]? {
|
private static func normalizeObject(_ value: Any) -> [String: Any]? {
|
||||||
|
|
@ -261,7 +266,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
|
|
||||||
let id = (payload["id"] as? String)?
|
let id = (payload["id"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
let promptId = (payload["promptId"] as? String)?
|
let promptId = (payload["promptId"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let sessionKey = (payload["sessionKey"] as? String)?
|
let sessionKey = (payload["sessionKey"] as? String)?
|
||||||
|
|
@ -272,7 +277,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let details = (payload["details"] as? String)?
|
let details = (payload["details"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
|
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
|
||||||
let risk = (payload["risk"] as? String)?
|
let risk = (payload["risk"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let actions = Self.parseActions(payload["actions"])
|
let actions = Self.parseActions(payload["actions"])
|
||||||
|
|
@ -314,7 +319,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
|
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
|
||||||
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let risk = WatchRiskLevel(rawValue: riskRaw)
|
let risk = WatchRiskLevel(rawValue: riskRaw)
|
||||||
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
|
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
|
||||||
|
|
@ -342,7 +347,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let resetResolvingState = payload["resetResolvingState"] as? Bool
|
let resetResolvingState = payload["resetResolvingState"] as? Bool
|
||||||
return WatchExecApprovalPromptMessage(
|
return WatchExecApprovalPromptMessage(
|
||||||
|
|
@ -365,8 +370,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
let decision = Self.parseExecApprovalDecision(payload["decision"])
|
let decision = Self.parseExecApprovalDecision(payload["decision"])
|
||||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
|
let resolvedAtMs = (payload["resolvedAtMs"] as? NSNumber)?.int64Value
|
||||||
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
|
|
||||||
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return WatchExecApprovalResolvedMessage(
|
return WatchExecApprovalResolvedMessage(
|
||||||
approvalId: approvalId,
|
approvalId: approvalId,
|
||||||
|
|
@ -391,7 +395,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
|
let expiredAtMs = (payload["expiredAtMs"] as? NSNumber)?.int64Value
|
||||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return WatchExecApprovalExpiredMessage(
|
return WatchExecApprovalExpiredMessage(
|
||||||
|
|
@ -414,7 +418,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
}
|
}
|
||||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return WatchExecApprovalSnapshotMessage(
|
return WatchExecApprovalSnapshotMessage(
|
||||||
approvals: approvals,
|
approvals: approvals,
|
||||||
|
|
@ -446,7 +450,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
|
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
|
||||||
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
|
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
|
||||||
?? 0
|
?? 0
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
|
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
|
||||||
let chatStatusText = (payload["chatStatusText"] as? String)?
|
let chatStatusText = (payload["chatStatusText"] as? String)?
|
||||||
|
|
@ -481,8 +485,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
let replyText = (payload["replyText"] as? String)?
|
let replyText = (payload["replyText"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !commandId.isEmpty, !replyText.isEmpty else { return nil }
|
guard !commandId.isEmpty, !replyText.isEmpty else { return nil }
|
||||||
let sentAtMs = (payload["sentAtMs"] as? Int)
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
|
||||||
return WatchChatCompletionMessage(
|
return WatchChatCompletionMessage(
|
||||||
commandId: commandId,
|
commandId: commandId,
|
||||||
replyText: replyText,
|
replyText: replyText,
|
||||||
|
|
@ -499,7 +502,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard let text, !text.isEmpty else { return nil }
|
guard let text, !text.isEmpty else { return nil }
|
||||||
let timestampMs = (dict["timestampMs"] as? Int) ?? (dict["timestampMs"] as? NSNumber)?.intValue
|
let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value
|
||||||
return WatchChatItem(
|
return WatchChatItem(
|
||||||
id: id,
|
id: id,
|
||||||
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
|
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
|
||||||
|
|
@ -632,6 +635,16 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
|
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
|
||||||
|
if let type = payload["type"] as? String,
|
||||||
|
type == WatchPayloadType.directNodeSetup.rawValue,
|
||||||
|
let setupCode = payload["setupCode"] as? String,
|
||||||
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
||||||
|
{
|
||||||
|
Task { @MainActor in
|
||||||
|
self.directNodeSetupHandler(setupCode, sentAtMs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any])
|
let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any])
|
||||||
.flatMap(Self.parseAppSnapshotPayload)
|
.flatMap(Self.parseAppSnapshotPayload)
|
||||||
let execApprovalSnapshot =
|
let execApprovalSnapshot =
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
enum WatchDeferredPayloadOrdering {
|
enum WatchDeferredPayloadOrdering {
|
||||||
static func isExpired(expiresAtMs: Int?, nowMs: Int) -> Bool {
|
static func isExpired(expiresAtMs: Int64?, nowMs: Int64) -> Bool {
|
||||||
expiresAtMs.map { $0 <= nowMs } == true
|
expiresAtMs.map { $0 <= nowMs } == true
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isNewerThanSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
|
static func isNewerThanSnapshot(payloadSentAtMs: Int64?, snapshotSentAtMs: Int64?) -> Bool {
|
||||||
guard let payloadSentAtMs, let snapshotSentAtMs else { return true }
|
guard let payloadSentAtMs, let snapshotSentAtMs else { return true }
|
||||||
return payloadSentAtMs > snapshotSentAtMs
|
return payloadSentAtMs > snapshotSentAtMs
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isAtOrBeforeSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
|
static func isAtOrBeforeSnapshot(payloadSentAtMs: Int64?, snapshotSentAtMs: Int64?) -> Bool {
|
||||||
guard let snapshotSentAtMs else { return false }
|
guard let snapshotSentAtMs else { return false }
|
||||||
return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true
|
return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
static func indicesOldestFirst(for timestamps: [Int?]) -> [Int] {
|
static func indicesOldestFirst(for timestamps: [Int64?]) -> [Int] {
|
||||||
timestamps.indices.sorted { lhs, rhs in
|
timestamps.indices.sorted { lhs, rhs in
|
||||||
let lhsTimestamp = timestamps[lhs] ?? .min
|
let lhsTimestamp = timestamps[lhs] ?? .min
|
||||||
let rhsTimestamp = timestamps[rhs] ?? .min
|
let rhsTimestamp = timestamps[rhs] ?? .min
|
||||||
|
|
|
||||||
820
apps/ios/WatchApp/Sources/WatchDirectNode.swift
Normal file
820
apps/ios/WatchApp/Sources/WatchDirectNode.swift
Normal file
|
|
@ -0,0 +1,820 @@
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
|
import OpenClawProtocol
|
||||||
|
import UserNotifications
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class WatchDirectNode {
|
||||||
|
private struct PersistedConfiguration: Codable {
|
||||||
|
var link: GatewayConnectDeepLink
|
||||||
|
var gatewayID: String
|
||||||
|
var setupSentAtMs: Int64?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveSession: Equatable {
|
||||||
|
let baseURL: URL
|
||||||
|
let token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConnectResponse: Decodable {
|
||||||
|
let sessionToken: String
|
||||||
|
let deviceToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ConnectCredential {
|
||||||
|
case bootstrap(String)
|
||||||
|
case device(String)
|
||||||
|
|
||||||
|
var token: String {
|
||||||
|
switch self {
|
||||||
|
case let .bootstrap(token), let .device(token): token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChallengeResponse: Decodable {
|
||||||
|
let nonce: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PollResponse: Decodable {
|
||||||
|
let event: NodeEvent?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeEvent: Decodable {
|
||||||
|
let event: String
|
||||||
|
let payload: InvokeRequest?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InvokeRequest: Decodable {
|
||||||
|
let id: String
|
||||||
|
let nodeId: String
|
||||||
|
let command: String
|
||||||
|
let paramsJSON: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HTTPError: LocalizedError {
|
||||||
|
let status: Int
|
||||||
|
let detail: String
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
self.detail.isEmpty ? "Gateway HTTP error (\(self.status))" : self.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let keychainService = "ai.openclaw.watch.direct-node"
|
||||||
|
private static let keychainAccount = "gateway"
|
||||||
|
private static let enabledDefaultsKey = "watch.directNode.enabled"
|
||||||
|
private static let lastSetupSentAtDefaultsKey = "watch.directNode.lastSetupSentAtMs"
|
||||||
|
private static let maximumSetupAgeMs: Int64 = 12 * 60 * 1000
|
||||||
|
private static let maximumSetupClockSkewMs: Int64 = 2 * 60 * 1000
|
||||||
|
private static let commands = [
|
||||||
|
OpenClawDeviceCommand.info.rawValue,
|
||||||
|
OpenClawDeviceCommand.status.rawValue,
|
||||||
|
OpenClawSystemCommand.notify.rawValue,
|
||||||
|
]
|
||||||
|
|
||||||
|
private let networkMetrics: WatchURLSessionMetrics
|
||||||
|
private let urlSession: URLSession
|
||||||
|
private var configuration: PersistedConfiguration?
|
||||||
|
private var connectTask: Task<Void, Never>?
|
||||||
|
private var activeSession: ActiveSession?
|
||||||
|
private var isForeground = false
|
||||||
|
private var connectionGeneration = 0
|
||||||
|
|
||||||
|
private(set) var isEnabled: Bool
|
||||||
|
private(set) var isConnected = false
|
||||||
|
private(set) var statusText = "Use iPhone Settings to enable direct connection."
|
||||||
|
private(set) var endpointText: String?
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
self.configuration != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||||
|
sessionConfiguration.waitsForConnectivity = true
|
||||||
|
sessionConfiguration.timeoutIntervalForRequest = 30
|
||||||
|
sessionConfiguration.timeoutIntervalForResource = 35
|
||||||
|
let networkMetrics = WatchURLSessionMetrics()
|
||||||
|
self.networkMetrics = networkMetrics
|
||||||
|
self.urlSession = URLSession(
|
||||||
|
configuration: sessionConfiguration,
|
||||||
|
delegate: networkMetrics,
|
||||||
|
delegateQueue: nil)
|
||||||
|
self.isEnabled = UserDefaults.standard.bool(forKey: Self.enabledDefaultsKey)
|
||||||
|
self.configuration = Self.loadConfiguration()
|
||||||
|
if let setupSentAtMs = configuration?.setupSentAtMs,
|
||||||
|
setupSentAtMs > Self.lastAcceptedSetupSentAtMs()
|
||||||
|
{
|
||||||
|
Self.saveLastAcceptedSetupSentAtMs(setupSentAtMs)
|
||||||
|
}
|
||||||
|
self.endpointText = self.configuration.map(Self.endpointText)
|
||||||
|
if self.configuration != nil {
|
||||||
|
self.statusText = self.isEnabled ? "Ready to connect" : "Direct connection is off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(setupCode: String, sentAtMs: Int64) {
|
||||||
|
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let oldestAcceptedMs = nowMs - Self.maximumSetupAgeMs
|
||||||
|
let newestAcceptedMs = nowMs + Self.maximumSetupClockSkewMs
|
||||||
|
guard (oldestAcceptedMs...newestAcceptedMs).contains(sentAtMs) else {
|
||||||
|
self.statusText = "Ignored an expired direct connection setup. Send setup again from iPhone."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let newestInstalledSetupMs = configuration?.setupSentAtMs ?? 0
|
||||||
|
guard sentAtMs > max(Self.lastAcceptedSetupSentAtMs(), newestInstalledSetupMs) else { return }
|
||||||
|
guard let link = GatewayConnectDeepLink.fromSetupCode(setupCode),
|
||||||
|
link.isValidEndpoint,
|
||||||
|
link.bootstrapToken != nil,
|
||||||
|
link.token == nil,
|
||||||
|
link.password == nil,
|
||||||
|
let secureEndpoint = link.connectionEndpoints.first(where: \.tls)
|
||||||
|
else {
|
||||||
|
self.statusText = "Direct mode requires a trusted HTTPS Gateway endpoint."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let secureEndpoints = link.connectionEndpoints.filter(\.tls)
|
||||||
|
let secureLink = GatewayConnectDeepLink(
|
||||||
|
host: secureEndpoint.host,
|
||||||
|
port: secureEndpoint.port,
|
||||||
|
tls: true,
|
||||||
|
bootstrapToken: link.bootstrapToken,
|
||||||
|
token: nil,
|
||||||
|
password: nil,
|
||||||
|
fallbackEndpoints: Array(secureEndpoints.dropFirst()))
|
||||||
|
let previousConfiguration = self.configuration
|
||||||
|
let configuration = PersistedConfiguration(
|
||||||
|
link: secureLink,
|
||||||
|
gatewayID: Self.gatewayID(for: secureLink),
|
||||||
|
setupSentAtMs: sentAtMs)
|
||||||
|
guard Self.saveConfiguration(configuration) else {
|
||||||
|
self.statusText = "Could not save direct connection securely."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let previousConfiguration,
|
||||||
|
previousConfiguration.gatewayID != configuration.gatewayID,
|
||||||
|
let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
|
||||||
|
{
|
||||||
|
DeviceAuthStore.clearToken(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: "node",
|
||||||
|
gatewayID: previousConfiguration.gatewayID,
|
||||||
|
profile: .primary)
|
||||||
|
}
|
||||||
|
Self.saveLastAcceptedSetupSentAtMs(sentAtMs)
|
||||||
|
self.disconnectActiveSession()
|
||||||
|
self.configuration = configuration
|
||||||
|
self.endpointText = Self.endpointText(configuration)
|
||||||
|
self.statusText = "Setup received. Connecting…"
|
||||||
|
self.setEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
self.isEnabled = enabled
|
||||||
|
UserDefaults.standard.set(enabled, forKey: Self.enabledDefaultsKey)
|
||||||
|
if enabled {
|
||||||
|
self.connect()
|
||||||
|
} else {
|
||||||
|
self.disconnectActiveSession()
|
||||||
|
self.connectionGeneration &+= 1
|
||||||
|
self.connectTask?.cancel()
|
||||||
|
self.connectTask = nil
|
||||||
|
self.isConnected = false
|
||||||
|
self.statusText = self.isConfigured
|
||||||
|
? "Direct connection is off"
|
||||||
|
: "Use iPhone Settings to enable direct connection."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() {
|
||||||
|
guard self.isForeground, self.isEnabled, let configuration else { return }
|
||||||
|
self.disconnectActiveSession()
|
||||||
|
self.connectTask?.cancel()
|
||||||
|
self.connectionGeneration &+= 1
|
||||||
|
let generation = self.connectionGeneration
|
||||||
|
self.connectTask = Task { [weak self] in
|
||||||
|
await self?.run(configuration, generation: generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectForForeground() {
|
||||||
|
self.isForeground = true
|
||||||
|
self.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectForBackground() {
|
||||||
|
self.isForeground = false
|
||||||
|
self.disconnectActiveSession()
|
||||||
|
self.connectionGeneration &+= 1
|
||||||
|
self.connectTask?.cancel()
|
||||||
|
self.connectTask = nil
|
||||||
|
self.isConnected = false
|
||||||
|
if self.isEnabled, self.isConfigured {
|
||||||
|
self.statusText = "Reconnects when OpenClaw is active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forget() {
|
||||||
|
self.disconnectActiveSession()
|
||||||
|
self.connectionGeneration &+= 1
|
||||||
|
self.connectTask?.cancel()
|
||||||
|
self.connectTask = nil
|
||||||
|
if let configuration {
|
||||||
|
if let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) {
|
||||||
|
DeviceAuthStore.clearToken(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: "node",
|
||||||
|
gatewayID: configuration.gatewayID,
|
||||||
|
profile: .primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = GenericPasswordKeychainStore.delete(
|
||||||
|
service: Self.keychainService,
|
||||||
|
account: Self.keychainAccount)
|
||||||
|
configuration = nil
|
||||||
|
self.endpointText = nil
|
||||||
|
self.isConnected = false
|
||||||
|
self.setEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ configuration: PersistedConfiguration, generation: Int) async {
|
||||||
|
while self.isCurrentConnection(generation, configuration: configuration) {
|
||||||
|
var lastError: Error?
|
||||||
|
for endpoint in configuration.link.connectionEndpoints {
|
||||||
|
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
|
||||||
|
let link = configuration.link.selectingEndpoint(endpoint)
|
||||||
|
guard let baseURL = Self.httpBaseURL(for: link) else { continue }
|
||||||
|
do {
|
||||||
|
try await self.connectAndPoll(
|
||||||
|
configuration: configuration,
|
||||||
|
link: link,
|
||||||
|
baseURL: baseURL,
|
||||||
|
generation: generation)
|
||||||
|
return
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch let error as URLError where error.code == .cancelled {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
|
||||||
|
lastError = error
|
||||||
|
self.isConnected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
|
||||||
|
self.statusText = lastError.map {
|
||||||
|
"Direct connection failed: \($0.localizedDescription)"
|
||||||
|
} ?? "No usable Gateway endpoint"
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(3))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectAndPoll(
|
||||||
|
configuration: PersistedConfiguration,
|
||||||
|
link: GatewayConnectDeepLink,
|
||||||
|
baseURL: URL,
|
||||||
|
generation: Int) async throws
|
||||||
|
{
|
||||||
|
try self.requireCurrentConnection(generation, configuration: configuration)
|
||||||
|
self.statusText = "Connecting directly…"
|
||||||
|
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) else {
|
||||||
|
throw HTTPError(status: 0, detail: "Could not save the watch device identity")
|
||||||
|
}
|
||||||
|
let storedToken = DeviceAuthStore.loadToken(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: "node",
|
||||||
|
gatewayID: configuration.gatewayID,
|
||||||
|
profile: .primary)?.token
|
||||||
|
guard storedToken != nil || link.bootstrapToken != nil else {
|
||||||
|
throw HTTPError(status: 401, detail: "No watch device credential")
|
||||||
|
}
|
||||||
|
let response: ConnectResponse
|
||||||
|
if let bootstrapToken = link.bootstrapToken {
|
||||||
|
do {
|
||||||
|
response = try await self.establishSession(
|
||||||
|
identity: identity,
|
||||||
|
baseURL: baseURL,
|
||||||
|
credential: .bootstrap(bootstrapToken))
|
||||||
|
} catch let error as HTTPError where error.status == 401 {
|
||||||
|
guard let storedToken else { throw error }
|
||||||
|
response = try await self.establishSession(
|
||||||
|
identity: identity,
|
||||||
|
baseURL: baseURL,
|
||||||
|
credential: .device(storedToken))
|
||||||
|
}
|
||||||
|
} else if let storedToken {
|
||||||
|
response = try await self.establishSession(
|
||||||
|
identity: identity,
|
||||||
|
baseURL: baseURL,
|
||||||
|
credential: .device(storedToken))
|
||||||
|
} else {
|
||||||
|
throw HTTPError(status: 401, detail: "No watch device credential")
|
||||||
|
}
|
||||||
|
// A successful bootstrap response has already consumed the one-time code.
|
||||||
|
// Finish that durable handoff across background/toggle cancellation, but
|
||||||
|
// never let an obsolete attempt overwrite a forgotten or newer setup.
|
||||||
|
do {
|
||||||
|
guard self.isInstalledConfiguration(configuration) else { throw CancellationError() }
|
||||||
|
guard DeviceAuthStore.storeTokenPersisted(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: "node",
|
||||||
|
token: response.deviceToken,
|
||||||
|
scopes: [],
|
||||||
|
gatewayID: configuration.gatewayID,
|
||||||
|
profile: .primary)
|
||||||
|
else {
|
||||||
|
throw HTTPError(status: 0, detail: "Could not save the watch device credential")
|
||||||
|
}
|
||||||
|
if link.bootstrapToken != nil {
|
||||||
|
try self.finishCredentialHandoff(configuration: configuration)
|
||||||
|
}
|
||||||
|
try self.requireCurrentConnection(generation, configuration: configuration)
|
||||||
|
} catch {
|
||||||
|
self.sendDisconnect(ActiveSession(baseURL: baseURL, token: response.sessionToken))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
let session = ActiveSession(baseURL: baseURL, token: response.sessionToken)
|
||||||
|
self.activeSession = session
|
||||||
|
defer { releaseActiveSession(session) }
|
||||||
|
self.isConnected = true
|
||||||
|
self.statusText = "Connected directly"
|
||||||
|
while self.isCurrentConnection(generation, configuration: configuration) {
|
||||||
|
let pollData = try await request(
|
||||||
|
baseURL: baseURL,
|
||||||
|
path: "poll",
|
||||||
|
method: "POST",
|
||||||
|
token: response.sessionToken)
|
||||||
|
try self.requireCurrentConnection(generation, configuration: configuration)
|
||||||
|
let poll = try JSONDecoder().decode(PollResponse.self, from: pollData)
|
||||||
|
guard let event = poll.event else { continue }
|
||||||
|
guard event.event == "node.invoke.request", let invoke = event.payload else { continue }
|
||||||
|
let invokeRequest = BridgeInvokeRequest(
|
||||||
|
id: invoke.id,
|
||||||
|
command: invoke.command,
|
||||||
|
paramsJSON: invoke.paramsJSON,
|
||||||
|
nodeId: invoke.nodeId)
|
||||||
|
let result = await handleInvoke(invokeRequest)
|
||||||
|
try requireCurrentConnection(generation, configuration: configuration)
|
||||||
|
_ = try await self.request(
|
||||||
|
baseURL: baseURL,
|
||||||
|
path: "result",
|
||||||
|
method: "POST",
|
||||||
|
token: response.sessionToken,
|
||||||
|
body: result)
|
||||||
|
try self.requireCurrentConnection(generation, configuration: configuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCurrentConnection(
|
||||||
|
_ generation: Int,
|
||||||
|
configuration: PersistedConfiguration) -> Bool
|
||||||
|
{
|
||||||
|
!Task.isCancelled
|
||||||
|
&& generation == self.connectionGeneration
|
||||||
|
&& self.isForeground
|
||||||
|
&& self.isEnabled
|
||||||
|
&& self.isInstalledConfiguration(configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isInstalledConfiguration(_ configuration: PersistedConfiguration) -> Bool {
|
||||||
|
configuration.gatewayID == self.configuration?.gatewayID
|
||||||
|
&& configuration.setupSentAtMs == self.configuration?.setupSentAtMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireCurrentConnection(
|
||||||
|
_ generation: Int,
|
||||||
|
configuration: PersistedConfiguration) throws
|
||||||
|
{
|
||||||
|
guard self.isCurrentConnection(generation, configuration: configuration) else {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func establishSession(
|
||||||
|
identity: DeviceIdentity,
|
||||||
|
baseURL: URL,
|
||||||
|
credential: ConnectCredential) async throws -> ConnectResponse
|
||||||
|
{
|
||||||
|
let challengeData = try await request(
|
||||||
|
baseURL: baseURL,
|
||||||
|
path: "challenge",
|
||||||
|
method: "GET",
|
||||||
|
token: nil)
|
||||||
|
let challenge = try JSONDecoder().decode(ChallengeResponse.self, from: challengeData)
|
||||||
|
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||||
|
let params = try connectParams(
|
||||||
|
identity: identity,
|
||||||
|
nonce: challenge.nonce,
|
||||||
|
credential: credential,
|
||||||
|
notificationsAuthorized: notificationSettings.authorizationStatus == .authorized
|
||||||
|
|| notificationSettings.authorizationStatus == .provisional)
|
||||||
|
let connectData = try await request(
|
||||||
|
baseURL: baseURL,
|
||||||
|
path: "connect",
|
||||||
|
method: "POST",
|
||||||
|
token: nil,
|
||||||
|
body: params)
|
||||||
|
return try JSONDecoder().decode(ConnectResponse.self, from: connectData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectParams(
|
||||||
|
identity: DeviceIdentity,
|
||||||
|
nonce: String,
|
||||||
|
credential: ConnectCredential,
|
||||||
|
notificationsAuthorized: Bool) throws -> ConnectParams
|
||||||
|
{
|
||||||
|
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: "openclaw-watchos",
|
||||||
|
clientMode: "node",
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
signedAtMs: signedAtMs,
|
||||||
|
token: credential.token,
|
||||||
|
nonce: nonce,
|
||||||
|
platform: InstanceIdentity.platformString,
|
||||||
|
deviceFamily: InstanceIdentity.deviceFamily)
|
||||||
|
guard let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
|
||||||
|
payload: payload,
|
||||||
|
identity: identity,
|
||||||
|
signedAtMs: signedAtMs,
|
||||||
|
nonce: nonce)
|
||||||
|
else {
|
||||||
|
throw HTTPError(status: 0, detail: "Could not sign watch identity")
|
||||||
|
}
|
||||||
|
var client: [String: AnyCodable] = [
|
||||||
|
"id": AnyCodable("openclaw-watchos"),
|
||||||
|
"displayName": AnyCodable(InstanceIdentity.displayName),
|
||||||
|
"version": AnyCodable(
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||||
|
"platform": AnyCodable(InstanceIdentity.platformString),
|
||||||
|
"deviceFamily": AnyCodable(InstanceIdentity.deviceFamily),
|
||||||
|
"mode": AnyCodable("node"),
|
||||||
|
"instanceId": AnyCodable(InstanceIdentity.instanceId),
|
||||||
|
]
|
||||||
|
if let modelIdentifier = InstanceIdentity.modelIdentifier {
|
||||||
|
client["modelIdentifier"] = AnyCodable(modelIdentifier)
|
||||||
|
}
|
||||||
|
let auth: [String: AnyCodable] = switch credential {
|
||||||
|
case let .device(token):
|
||||||
|
["deviceToken": AnyCodable(token)]
|
||||||
|
case let .bootstrap(token):
|
||||||
|
["bootstrapToken": AnyCodable(token)]
|
||||||
|
}
|
||||||
|
return ConnectParams(
|
||||||
|
minprotocol: GATEWAY_MIN_PROTOCOL_VERSION,
|
||||||
|
maxprotocol: GATEWAY_PROTOCOL_VERSION,
|
||||||
|
client: client,
|
||||||
|
caps: [],
|
||||||
|
commands: Self.commands,
|
||||||
|
permissions: ["notifications": AnyCodable(notificationsAuthorized)],
|
||||||
|
pathenv: nil,
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
device: device,
|
||||||
|
auth: auth,
|
||||||
|
locale: Locale.preferredLanguages.first ?? Locale.current.identifier,
|
||||||
|
useragent: ProcessInfo.processInfo.operatingSystemVersionString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
baseURL: URL,
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
token: String?) async throws -> Data
|
||||||
|
{
|
||||||
|
try await self.performRequest(
|
||||||
|
baseURL: baseURL,
|
||||||
|
path: path,
|
||||||
|
method: method,
|
||||||
|
token: token,
|
||||||
|
body: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
baseURL: URL,
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
token: String?,
|
||||||
|
body: some Encodable) async throws -> Data
|
||||||
|
{
|
||||||
|
let encodedBody = try JSONEncoder().encode(body)
|
||||||
|
return try await self.performRequest(
|
||||||
|
baseURL: baseURL,
|
||||||
|
path: path,
|
||||||
|
method: method,
|
||||||
|
token: token,
|
||||||
|
body: encodedBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performRequest(
|
||||||
|
baseURL: URL,
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
token: String?,
|
||||||
|
body: Data?) async throws -> Data
|
||||||
|
{
|
||||||
|
let url = baseURL
|
||||||
|
.appendingPathComponent("api")
|
||||||
|
.appendingPathComponent("nodes")
|
||||||
|
.appendingPathComponent("watch")
|
||||||
|
.appendingPathComponent(path)
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method
|
||||||
|
request.timeoutInterval = path == "poll" ? 25 : 8
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let token {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
if let body {
|
||||||
|
request.httpBody = body
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
let (data, response) = try await urlSession.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw HTTPError(status: 0, detail: "Invalid Gateway response")
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw HTTPError(status: http.statusCode, detail: detail)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishCredentialHandoff(configuration: PersistedConfiguration) throws {
|
||||||
|
let link = configuration.link
|
||||||
|
let sanitized = PersistedConfiguration(
|
||||||
|
link: GatewayConnectDeepLink(
|
||||||
|
host: link.host,
|
||||||
|
port: link.port,
|
||||||
|
tls: link.tls,
|
||||||
|
bootstrapToken: nil,
|
||||||
|
token: nil,
|
||||||
|
password: nil,
|
||||||
|
fallbackEndpoints: link.fallbackEndpoints),
|
||||||
|
gatewayID: configuration.gatewayID,
|
||||||
|
setupSentAtMs: configuration.setupSentAtMs)
|
||||||
|
guard Self.saveConfiguration(sanitized) else {
|
||||||
|
throw HTTPError(status: 0, detail: "Paired, but could not finish secure setup")
|
||||||
|
}
|
||||||
|
self.configuration = sanitized
|
||||||
|
self.endpointText = Self.endpointText(sanitized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleInvoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
|
do {
|
||||||
|
switch request.command {
|
||||||
|
case OpenClawDeviceCommand.info.rawValue:
|
||||||
|
return try self.encodedResponse(id: request.id, payload: self.deviceInfo())
|
||||||
|
case OpenClawDeviceCommand.status.rawValue:
|
||||||
|
return try self.encodedResponse(id: request.id, payload: self.deviceStatus())
|
||||||
|
case OpenClawSystemCommand.notify.rawValue:
|
||||||
|
return try await self.handleNotification(request)
|
||||||
|
default:
|
||||||
|
return Self.errorResponse(
|
||||||
|
id: request.id,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: unsupported watchOS command")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return Self.errorResponse(
|
||||||
|
id: request.id,
|
||||||
|
code: .unavailable,
|
||||||
|
message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleNotification(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
let params = try Self.decode(OpenClawSystemNotifyParams.self, from: request.paramsJSON)
|
||||||
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !title.isEmpty || !body.isEmpty else {
|
||||||
|
return Self.errorResponse(
|
||||||
|
id: request.id,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: empty notification")
|
||||||
|
}
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
|
||||||
|
return Self.errorResponse(
|
||||||
|
id: request.id,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "NOT_AUTHORIZED: notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
switch params.priority ?? .active {
|
||||||
|
case .passive:
|
||||||
|
content.interruptionLevel = .passive
|
||||||
|
case .timeSensitive:
|
||||||
|
content.interruptionLevel = .timeSensitive
|
||||||
|
case .active:
|
||||||
|
content.interruptionLevel = .active
|
||||||
|
}
|
||||||
|
let sound = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
content.sound = sound.map { ["none", "silent", "off"].contains($0) } == true ? nil : .default
|
||||||
|
try await center.add(UNNotificationRequest(
|
||||||
|
identifier: UUID().uuidString,
|
||||||
|
content: content,
|
||||||
|
trigger: nil))
|
||||||
|
return BridgeInvokeResponse(id: request.id, ok: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceInfo() -> OpenClawDeviceInfoPayload {
|
||||||
|
let device = WKInterfaceDevice.current()
|
||||||
|
let info = Bundle.main.infoDictionary ?? [:]
|
||||||
|
let appVersion = (info["CFBundleShortVersionString"] as? String) ?? "0"
|
||||||
|
let appBuild = (info["CFBundleVersion"] as? String) ?? "0"
|
||||||
|
return OpenClawDeviceInfoPayload(
|
||||||
|
deviceName: device.name,
|
||||||
|
modelIdentifier: InstanceIdentity.modelIdentifier ?? "Apple Watch",
|
||||||
|
systemName: "watchOS",
|
||||||
|
systemVersion: device.systemVersion,
|
||||||
|
appVersion: appVersion,
|
||||||
|
appBuild: appBuild,
|
||||||
|
locale: Locale.preferredLanguages.first ?? Locale.current.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceStatus() -> OpenClawDeviceStatusPayload {
|
||||||
|
let device = WKInterfaceDevice.current()
|
||||||
|
device.isBatteryMonitoringEnabled = true
|
||||||
|
let batteryState: OpenClawBatteryState = switch device.batteryState {
|
||||||
|
case .charging: .charging
|
||||||
|
case .full: .full
|
||||||
|
case .unplugged: .unplugged
|
||||||
|
case .unknown: .unknown
|
||||||
|
@unknown default: .unknown
|
||||||
|
}
|
||||||
|
let battery = OpenClawBatteryStatusPayload(
|
||||||
|
level: device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil,
|
||||||
|
state: batteryState,
|
||||||
|
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
|
||||||
|
let thermalState: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
|
||||||
|
case .nominal: .nominal
|
||||||
|
case .fair: .fair
|
||||||
|
case .serious: .serious
|
||||||
|
case .critical: .critical
|
||||||
|
@unknown default: .nominal
|
||||||
|
}
|
||||||
|
let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
|
||||||
|
let total = (attributes[.systemSize] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let free = (attributes[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let networkMetrics = self.networkMetrics.snapshot()
|
||||||
|
return OpenClawDeviceStatusPayload(
|
||||||
|
battery: battery,
|
||||||
|
thermal: OpenClawThermalStatusPayload(state: thermalState),
|
||||||
|
storage: OpenClawStorageStatusPayload(
|
||||||
|
totalBytes: total,
|
||||||
|
freeBytes: free,
|
||||||
|
usedBytes: max(0, total - free)),
|
||||||
|
network: OpenClawNetworkStatusPayload(
|
||||||
|
status: self.isConnected ? .satisfied : .requiresConnection,
|
||||||
|
isExpensive: networkMetrics?.isExpensive ?? false,
|
||||||
|
isConstrained: networkMetrics?.isConstrained ?? false,
|
||||||
|
interfaces: networkMetrics?.isCellular == true ? [.cellular] : [.other]),
|
||||||
|
uptimeSeconds: ProcessInfo.processInfo.systemUptime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodedResponse(id: String, payload: some Encodable) throws -> BridgeInvokeResponse {
|
||||||
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
guard let json = String(data: data, encoding: .utf8) else {
|
||||||
|
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||||
|
}
|
||||||
|
return BridgeInvokeResponse(id: id, ok: true, payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decode<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||||
|
try JSONDecoder().decode(type, from: Data((json ?? "{}").utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
id: String,
|
||||||
|
code: OpenClawNodeErrorCode,
|
||||||
|
message: String) -> BridgeInvokeResponse
|
||||||
|
{
|
||||||
|
BridgeInvokeResponse(
|
||||||
|
id: id,
|
||||||
|
ok: false,
|
||||||
|
error: OpenClawNodeError(code: code, message: message))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func httpBaseURL(for link: GatewayConnectDeepLink) -> URL? {
|
||||||
|
guard link.tls else { return nil }
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "https"
|
||||||
|
components.host = link.host
|
||||||
|
components.port = link.port
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func gatewayID(for link: GatewayConnectDeepLink) -> String {
|
||||||
|
"watch-direct:\(link.tls ? "https" : "http")://\(link.host.lowercased()):\(link.port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func endpointText(_ configuration: PersistedConfiguration) -> String {
|
||||||
|
"\(configuration.link.tls ? "https" : "http")://\(configuration.link.host):\(configuration.link.port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadConfiguration() -> PersistedConfiguration? {
|
||||||
|
guard let raw = GenericPasswordKeychainStore.loadString(
|
||||||
|
service: keychainService,
|
||||||
|
account: keychainAccount),
|
||||||
|
let data = raw.data(using: .utf8)
|
||||||
|
else { return nil }
|
||||||
|
return try? JSONDecoder().decode(PersistedConfiguration.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveConfiguration(_ configuration: PersistedConfiguration) -> Bool {
|
||||||
|
guard let data = try? JSONEncoder().encode(configuration),
|
||||||
|
let raw = String(data: data, encoding: .utf8)
|
||||||
|
else { return false }
|
||||||
|
return GenericPasswordKeychainStore.saveString(
|
||||||
|
raw,
|
||||||
|
service: self.keychainService,
|
||||||
|
account: self.keychainAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disconnectActiveSession() {
|
||||||
|
self.isConnected = false
|
||||||
|
guard let session = activeSession else { return }
|
||||||
|
self.activeSession = nil
|
||||||
|
self.sendDisconnect(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseActiveSession(_ session: ActiveSession) {
|
||||||
|
guard self.activeSession == session else { return }
|
||||||
|
self.activeSession = nil
|
||||||
|
self.sendDisconnect(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendDisconnect(_ session: ActiveSession) {
|
||||||
|
Task { [weak self] in
|
||||||
|
_ = try? await self?.request(
|
||||||
|
baseURL: session.baseURL,
|
||||||
|
path: "disconnect",
|
||||||
|
method: "POST",
|
||||||
|
token: session.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lastAcceptedSetupSentAtMs() -> Int64 {
|
||||||
|
(UserDefaults.standard.object(forKey: self.lastSetupSentAtDefaultsKey) as? NSNumber)?.int64Value ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveLastAcceptedSetupSentAtMs(_ sentAtMs: Int64) {
|
||||||
|
UserDefaults.standard.set(NSNumber(value: sentAtMs), forKey: self.lastSetupSentAtDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchNetworkMetricsSnapshot {
|
||||||
|
let isCellular: Bool
|
||||||
|
let isExpensive: Bool
|
||||||
|
let isConstrained: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class WatchURLSessionMetrics: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var latest: WatchNetworkMetricsSnapshot?
|
||||||
|
|
||||||
|
func snapshot() -> WatchNetworkMetricsSnapshot? {
|
||||||
|
self.lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return self.latest
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_: URLSession,
|
||||||
|
task _: URLSessionTask,
|
||||||
|
didFinishCollecting metrics: URLSessionTaskMetrics)
|
||||||
|
{
|
||||||
|
guard let transaction = metrics.transactionMetrics.last else { return }
|
||||||
|
let snapshot = WatchNetworkMetricsSnapshot(
|
||||||
|
isCellular: transaction.isCellular,
|
||||||
|
isExpensive: transaction.isExpensive,
|
||||||
|
isConstrained: transaction.isConstrained)
|
||||||
|
self.lock.lock()
|
||||||
|
self.latest = snapshot
|
||||||
|
self.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_: URLSession,
|
||||||
|
task _: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection _: HTTPURLResponse,
|
||||||
|
newRequest _: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void)
|
||||||
|
{
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import WatchKit
|
||||||
|
|
||||||
enum WatchPayloadType: String, Codable, Equatable {
|
enum WatchPayloadType: String, Codable, Equatable {
|
||||||
case notify = "watch.notify"
|
case notify = "watch.notify"
|
||||||
|
case directNodeSetup = "watch.node.setup"
|
||||||
case reply = "watch.reply"
|
case reply = "watch.reply"
|
||||||
case appSnapshot = "watch.app.snapshot"
|
case appSnapshot = "watch.app.snapshot"
|
||||||
case appSnapshotRequest = "watch.app.snapshotRequest"
|
case appSnapshotRequest = "watch.app.snapshotRequest"
|
||||||
|
|
@ -45,14 +46,14 @@ struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
|
||||||
var host: String?
|
var host: String?
|
||||||
var nodeId: String?
|
var nodeId: String?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var expiresAtMs: Int?
|
var expiresAtMs: Int64?
|
||||||
var allowedDecisions: [WatchExecApprovalDecision]
|
var allowedDecisions: [WatchExecApprovalDecision]
|
||||||
var risk: WatchRiskLevel?
|
var risk: WatchRiskLevel?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalPromptMessage: Codable, Equatable {
|
struct WatchExecApprovalPromptMessage: Codable, Equatable {
|
||||||
var approval: WatchExecApprovalItem
|
var approval: WatchExecApprovalItem
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var deliveryId: String?
|
var deliveryId: String?
|
||||||
var resetResolvingState: Bool?
|
var resetResolvingState: Bool?
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +62,7 @@ struct WatchExecApprovalResolvedMessage: Codable, Equatable {
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var decision: WatchExecApprovalDecision?
|
var decision: WatchExecApprovalDecision?
|
||||||
var resolvedAtMs: Int?
|
var resolvedAtMs: Int64?
|
||||||
var source: String?
|
var source: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,19 +70,19 @@ struct WatchExecApprovalExpiredMessage: Codable, Equatable {
|
||||||
var approvalId: String
|
var approvalId: String
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var reason: WatchExecApprovalCloseReason
|
var reason: WatchExecApprovalCloseReason
|
||||||
var expiredAtMs: Int?
|
var expiredAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
|
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
|
||||||
var approvals: [WatchExecApprovalItem]
|
var approvals: [WatchExecApprovalItem]
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var snapshotId: String?
|
var snapshotId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
|
struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
|
||||||
var requestId: String
|
var requestId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchExecApprovalResolveMessage: Codable, Equatable {
|
struct WatchExecApprovalResolveMessage: Codable, Equatable {
|
||||||
|
|
@ -89,7 +90,7 @@ struct WatchExecApprovalResolveMessage: Codable, Equatable {
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var decision: WatchExecApprovalDecision
|
var decision: WatchExecApprovalDecision
|
||||||
var replyId: String
|
var replyId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchAppSnapshotMessage: Codable, Equatable {
|
struct WatchAppSnapshotMessage: Codable, Equatable {
|
||||||
|
|
@ -107,26 +108,26 @@ struct WatchAppSnapshotMessage: Codable, Equatable {
|
||||||
var pendingApprovalCount: Int
|
var pendingApprovalCount: Int
|
||||||
var chatItems: [WatchChatItem]?
|
var chatItems: [WatchChatItem]?
|
||||||
var chatStatusText: String?
|
var chatStatusText: String?
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var snapshotId: String?
|
var snapshotId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchChatCompletionMessage: Codable, Equatable {
|
struct WatchChatCompletionMessage: Codable, Equatable {
|
||||||
var commandId: String
|
var commandId: String
|
||||||
var replyText: String
|
var replyText: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchChatItem: Codable, Equatable, Identifiable {
|
struct WatchChatItem: Codable, Equatable, Identifiable {
|
||||||
var id: String
|
var id: String
|
||||||
var role: String
|
var role: String
|
||||||
var text: String
|
var text: String
|
||||||
var timestampMs: Int?
|
var timestampMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchAppSnapshotRequestMessage: Codable, Equatable {
|
struct WatchAppSnapshotRequestMessage: Codable, Equatable {
|
||||||
var requestId: String
|
var requestId: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WatchAppCommand: String, Codable, Equatable {
|
enum WatchAppCommand: String, Codable, Equatable {
|
||||||
|
|
@ -143,7 +144,7 @@ struct WatchAppCommandMessage: Codable, Equatable {
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var text: String?
|
var text: String?
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchPromptAction: Codable, Equatable, Identifiable {
|
struct WatchPromptAction: Codable, Equatable, Identifiable {
|
||||||
|
|
@ -156,13 +157,13 @@ struct WatchNotifyMessage: Codable {
|
||||||
var id: String?
|
var id: String?
|
||||||
var title: String
|
var title: String
|
||||||
var body: String
|
var body: String
|
||||||
var sentAtMs: Int?
|
var sentAtMs: Int64?
|
||||||
var promptId: String?
|
var promptId: String?
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var kind: String?
|
var kind: String?
|
||||||
var details: String?
|
var details: String?
|
||||||
var expiresAtMs: Int?
|
var expiresAtMs: Int64?
|
||||||
var risk: String?
|
var risk: String?
|
||||||
var actions: [WatchPromptAction]
|
var actions: [WatchPromptAction]
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +209,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var sentAtMs: Int? {
|
var sentAtMs: Int64? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .notification(message, _):
|
case let .notification(message, _):
|
||||||
message.sentAtMs
|
message.sentAtMs
|
||||||
|
|
@ -223,7 +224,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var expiresAtMs: Int? {
|
var expiresAtMs: Int64? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .notification(message, _):
|
case let .notification(message, _):
|
||||||
message.expiresAtMs
|
message.expiresAtMs
|
||||||
|
|
@ -260,7 +261,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var kind: String?
|
var kind: String?
|
||||||
var details: String?
|
var details: String?
|
||||||
var expiresAtMs: Int?
|
var expiresAtMs: Int64?
|
||||||
var risk: String?
|
var risk: String?
|
||||||
var actions: [WatchPromptAction]?
|
var actions: [WatchPromptAction]?
|
||||||
var replyStatusText: String?
|
var replyStatusText: String?
|
||||||
|
|
@ -269,7 +270,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
var selectedExecApprovalID: String?
|
var selectedExecApprovalID: String?
|
||||||
var lastExecApprovalSnapshotID: String?
|
var lastExecApprovalSnapshotID: String?
|
||||||
var lastExecApprovalSnapshotGatewayStableID: String?
|
var lastExecApprovalSnapshotGatewayStableID: String?
|
||||||
var lastExecApprovalSnapshotSentAtMs: Int?
|
var lastExecApprovalSnapshotSentAtMs: Int64?
|
||||||
var lastExecApprovalOutcomeText: String?
|
var lastExecApprovalOutcomeText: String?
|
||||||
var lastExecApprovalOutcomeAt: Date?
|
var lastExecApprovalOutcomeAt: Date?
|
||||||
var appSnapshot: WatchAppSnapshotMessage?
|
var appSnapshot: WatchAppSnapshotMessage?
|
||||||
|
|
@ -294,7 +295,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
var gatewayStableID: String?
|
var gatewayStableID: String?
|
||||||
var kind: String?
|
var kind: String?
|
||||||
var details: String?
|
var details: String?
|
||||||
var expiresAtMs: Int?
|
var expiresAtMs: Int64?
|
||||||
var risk: String?
|
var risk: String?
|
||||||
var actions: [WatchPromptAction] = []
|
var actions: [WatchPromptAction] = []
|
||||||
var replyStatusText: String?
|
var replyStatusText: String?
|
||||||
|
|
@ -315,7 +316,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
var execApprovalReviewStatusAt: Date?
|
var execApprovalReviewStatusAt: Date?
|
||||||
private var lastExecApprovalSnapshotID: String?
|
private var lastExecApprovalSnapshotID: String?
|
||||||
private var lastExecApprovalSnapshotGatewayStableID: String?
|
private var lastExecApprovalSnapshotGatewayStableID: String?
|
||||||
private var lastExecApprovalSnapshotSentAtMs: Int?
|
private var lastExecApprovalSnapshotSentAtMs: Int64?
|
||||||
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
||||||
private var lastDeliveryKey: String?
|
private var lastDeliveryKey: String?
|
||||||
/// WatchConnectivity does not order application-context updates against user-info
|
/// WatchConnectivity does not order application-context updates against user-info
|
||||||
|
|
@ -339,8 +340,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
|
|
||||||
var sortedExecApprovals: [WatchExecApprovalRecord] {
|
var sortedExecApprovals: [WatchExecApprovalRecord] {
|
||||||
self.execApprovals.sorted { lhs, rhs in
|
self.execApprovals.sorted { lhs, rhs in
|
||||||
let lhsExpires = lhs.approval.expiresAtMs ?? Int.max
|
let lhsExpires = lhs.approval.expiresAtMs ?? Int64.max
|
||||||
let rhsExpires = rhs.approval.expiresAtMs ?? Int.max
|
let rhsExpires = rhs.approval.expiresAtMs ?? Int64.max
|
||||||
if lhsExpires != rhsExpires {
|
if lhsExpires != rhsExpires {
|
||||||
return lhsExpires < rhsExpires
|
return lhsExpires < rhsExpires
|
||||||
}
|
}
|
||||||
|
|
@ -1003,7 +1004,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)"
|
return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pruneExpiredExecApprovals(nowMs: Int) {
|
private func pruneExpiredExecApprovals(nowMs: Int64) {
|
||||||
let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
||||||
guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil }
|
guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil }
|
||||||
return record.approval
|
return record.approval
|
||||||
|
|
@ -1137,7 +1138,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
self.defaults.set(data, forKey: Self.persistedStateKey)
|
self.defaults.set(data, forKey: Self.persistedStateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String {
|
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int64?) -> String {
|
||||||
if let messageID, messageID.isEmpty == false {
|
if let messageID, messageID.isEmpty == false {
|
||||||
return "id:\(messageID)"
|
return "id:\(messageID)"
|
||||||
}
|
}
|
||||||
|
|
@ -1253,7 +1254,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func nowMs() -> Int {
|
private static func nowMs() -> Int64 {
|
||||||
Int(Date().timeIntervalSince1970 * 1000)
|
Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import WatchKit
|
||||||
|
|
||||||
struct WatchInboxView: View {
|
struct WatchInboxView: View {
|
||||||
var store: WatchInboxStore
|
var store: WatchInboxStore
|
||||||
|
var directNode: WatchDirectNode
|
||||||
var onAction: ((WatchPromptAction) -> Void)?
|
var onAction: ((WatchPromptAction) -> Void)?
|
||||||
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
||||||
var onRefreshExecApprovalReview: (() -> Void)?
|
var onRefreshExecApprovalReview: (() -> Void)?
|
||||||
|
|
@ -14,6 +15,7 @@ struct WatchInboxView: View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
WatchControlSurfaceView(
|
WatchControlSurfaceView(
|
||||||
store: self.store,
|
store: self.store,
|
||||||
|
directNode: self.directNode,
|
||||||
onAction: self.onAction,
|
onAction: self.onAction,
|
||||||
onExecApprovalDecision: self.onExecApprovalDecision,
|
onExecApprovalDecision: self.onExecApprovalDecision,
|
||||||
onRefreshExecApprovalReview: self.onRefreshExecApprovalReview,
|
onRefreshExecApprovalReview: self.onRefreshExecApprovalReview,
|
||||||
|
|
@ -27,6 +29,7 @@ struct WatchInboxView: View {
|
||||||
|
|
||||||
private struct WatchControlSurfaceView: View {
|
private struct WatchControlSurfaceView: View {
|
||||||
var store: WatchInboxStore
|
var store: WatchInboxStore
|
||||||
|
var directNode: WatchDirectNode
|
||||||
var onAction: ((WatchPromptAction) -> Void)?
|
var onAction: ((WatchPromptAction) -> Void)?
|
||||||
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
|
||||||
var onRefreshExecApprovalReview: (() -> Void)?
|
var onRefreshExecApprovalReview: (() -> Void)?
|
||||||
|
|
@ -43,6 +46,8 @@ private struct WatchControlSurfaceView: View {
|
||||||
.tag(1)
|
.tag(1)
|
||||||
self.approvalsFace
|
self.approvalsFace
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
self.connectionFace
|
||||||
|
.tag(3)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.background(WatchClawStyle.background.ignoresSafeArea())
|
.background(WatchClawStyle.background.ignoresSafeArea())
|
||||||
|
|
@ -50,7 +55,7 @@ private struct WatchControlSurfaceView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var faceCount: Int {
|
private var faceCount: Int {
|
||||||
3
|
4
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pageRail: some View {
|
private var pageRail: some View {
|
||||||
|
|
@ -295,6 +300,49 @@ private struct WatchControlSurfaceView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var connectionFace: some View {
|
||||||
|
WatchFaceScroll {
|
||||||
|
self.pageRail
|
||||||
|
WatchFaceHeader(
|
||||||
|
section: "Connection",
|
||||||
|
title: self.directNode.isConnected ? "Watch node online" : "Direct Gateway",
|
||||||
|
subtitle: self.directNode.statusText,
|
||||||
|
isOnline: self.directNode.isConnected,
|
||||||
|
avatarImageSource: self.avatarImageSource,
|
||||||
|
avatarText: self.avatarText)
|
||||||
|
|
||||||
|
WatchHeroCard(
|
||||||
|
label: self.directNode.isConnected ? "Direct" : "Setup",
|
||||||
|
title: self.directNode.endpointText ?? "Enable from iPhone",
|
||||||
|
subtitle: self.directNode.isConfigured
|
||||||
|
? "Uses Wi-Fi or cellular while OpenClaw is active"
|
||||||
|
: "Open iPhone Settings → Apple Watch",
|
||||||
|
accessory: self.directNode.isConnected ? "Online" : "Offline")
|
||||||
|
|
||||||
|
WatchDetailText(
|
||||||
|
text: "Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.")
|
||||||
|
|
||||||
|
if self.directNode.isConfigured {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { self.directNode.isEnabled },
|
||||||
|
set: { self.directNode.setEnabled($0) }))
|
||||||
|
{
|
||||||
|
Text("Direct connection")
|
||||||
|
.font(WatchClawType.body(size: 13))
|
||||||
|
}
|
||||||
|
.tint(WatchClawStyle.accent)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
|
WatchSecondaryButton(title: "Forget direct setup") {
|
||||||
|
self.directNode.forget()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WatchDetailText(
|
||||||
|
text: "The iPhone securely sends a one-time setup code. Existing relay features stay available.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var chatItems: [WatchChatItem] {
|
private var chatItems: [WatchChatItem] {
|
||||||
self.store.appSnapshot?.chatItems ?? []
|
self.store.appSnapshot?.chatItems ?? []
|
||||||
}
|
}
|
||||||
|
|
@ -497,9 +545,9 @@ private struct WatchControlSurfaceView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func expiryText(_ expiresAtMs: Int?) -> String? {
|
private func expiryText(_ expiresAtMs: Int64?) -> String? {
|
||||||
guard let expiresAtMs else { return nil }
|
guard let expiresAtMs else { return nil }
|
||||||
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
|
||||||
if deltaSeconds < 60 {
|
if deltaSeconds < 60 {
|
||||||
return "<1m"
|
return "<1m"
|
||||||
}
|
}
|
||||||
|
|
@ -1346,9 +1394,9 @@ private struct WatchExecApprovalListView: View {
|
||||||
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
|
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
private static func expiresText(_ expiresAtMs: Int64?) -> String? {
|
||||||
guard let expiresAtMs else { return nil }
|
guard let expiresAtMs else { return nil }
|
||||||
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
|
||||||
if deltaSeconds < 60 {
|
if deltaSeconds < 60 {
|
||||||
return "Expires in <1m"
|
return "Expires in <1m"
|
||||||
}
|
}
|
||||||
|
|
@ -1437,9 +1485,9 @@ private struct WatchExecApprovalDetailView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
private static func expiresText(_ expiresAtMs: Int64?) -> String? {
|
||||||
guard let expiresAtMs else { return nil }
|
guard let expiresAtMs else { return nil }
|
||||||
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
|
||||||
if deltaSeconds < 60 {
|
if deltaSeconds < 60 {
|
||||||
return "<1 minute"
|
return "<1 minute"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@ targets:
|
||||||
- path: Resources/Localizable.xcstrings
|
- path: Resources/Localizable.xcstrings
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
dependencies:
|
dependencies:
|
||||||
|
- package: OpenClawKit
|
||||||
- sdk: AppIntents.framework
|
- sdk: AppIntents.framework
|
||||||
- sdk: AVFAudio.framework
|
- sdk: AVFAudio.framework
|
||||||
- sdk: WatchConnectivity.framework
|
- sdk: WatchConnectivity.framework
|
||||||
|
|
@ -356,6 +357,8 @@ targets:
|
||||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_COMPANION_APP_BUNDLE_ID)"
|
WKCompanionAppBundleIdentifier: "$(OPENCLAW_COMPANION_APP_BUNDLE_ID)"
|
||||||
WKApplication: true
|
WKApplication: true
|
||||||
|
WKPrefersNetworkUponForeground: true
|
||||||
|
NSLocalNetworkUsageDescription: OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks.
|
||||||
UIAppFonts:
|
UIAppFonts:
|
||||||
- RedHatDisplay[wght].ttf
|
- RedHatDisplay[wght].ttf
|
||||||
- Inter[opsz,wght].ttf
|
- Inter[opsz,wght].ttf
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ actor GatewayWizardClient {
|
||||||
}
|
}
|
||||||
let connectNonce = try await self.waitForConnectChallenge()
|
let connectNonce = try await self.waitForConnectChallenge()
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
|
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
|
||||||
deviceId: identity.deviceId,
|
deviceId: identity.deviceId,
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ let package = Package(
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v18),
|
.iOS(.v18),
|
||||||
.macOS(.v15),
|
.macOS(.v15),
|
||||||
|
.watchOS(.v11),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]),
|
.library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]),
|
||||||
|
|
@ -33,7 +34,10 @@ let package = Package(
|
||||||
name: "OpenClawKit",
|
name: "OpenClawKit",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"OpenClawProtocol",
|
"OpenClawProtocol",
|
||||||
.product(name: "ElevenLabsKit", package: "ElevenLabsKit", condition: .when(traits: ["Talk"])),
|
.product(
|
||||||
|
name: "ElevenLabsKit",
|
||||||
|
package: "ElevenLabsKit",
|
||||||
|
condition: .when(platforms: [.iOS, .macOS], traits: ["Talk"])),
|
||||||
],
|
],
|
||||||
path: "Sources/OpenClawKit",
|
path: "Sources/OpenClawKit",
|
||||||
resources: [
|
resources: [
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
#if Talk
|
#if Talk && canImport(ElevenLabsKit)
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
#if !os(watchOS)
|
||||||
public enum BonjourServiceResolverSupport {
|
public enum BonjourServiceResolverSupport {
|
||||||
public static func start(_ service: NetService, timeout: TimeInterval = 2.0) {
|
public static func start(_ service: NetService, timeout: TimeInterval = 2.0) {
|
||||||
service.schedule(in: .main, forMode: .common)
|
service.schedule(in: .main, forMode: .common)
|
||||||
|
|
@ -12,3 +13,4 @@ public enum BonjourServiceResolverSupport {
|
||||||
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
|
#if !os(watchOS)
|
||||||
public enum CameraAuthorization {
|
public enum CameraAuthorization {
|
||||||
public static func isAuthorized(for mediaType: AVMediaType) async -> Bool {
|
public static func isAuthorized(for mediaType: AVMediaType) async -> Bool {
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||||
|
|
@ -19,3 +20,4 @@ public enum CameraAuthorization {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
#if !os(watchOS)
|
||||||
public enum CameraCapturePipelineSupport {
|
public enum CameraCapturePipelineSupport {
|
||||||
public static func preparePhotoSession(
|
public static func preparePhotoSession(
|
||||||
preferFrontCamera: Bool,
|
preferFrontCamera: Bool,
|
||||||
|
|
@ -149,3 +150,4 @@ public enum CameraCapturePipelineSupport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
|
|
||||||
|
#if !os(watchOS)
|
||||||
public enum CameraSessionConfigurationError: LocalizedError {
|
public enum CameraSessionConfigurationError: LocalizedError {
|
||||||
case addCameraInputFailed
|
case addCameraInputFailed
|
||||||
case addPhotoOutputFailed
|
case addPhotoOutputFailed
|
||||||
|
|
@ -68,3 +69,4 @@ public enum CameraSessionConfiguration {
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ public enum GatewayDeviceAuthPayload {
|
||||||
clientMode: String,
|
clientMode: String,
|
||||||
role: String,
|
role: String,
|
||||||
scopes: [String],
|
scopes: [String],
|
||||||
signedAtMs: Int,
|
signedAtMs: Int64,
|
||||||
token: String?,
|
token: String?,
|
||||||
nonce: String) -> String
|
nonce: String) -> String
|
||||||
{
|
{
|
||||||
|
|
@ -36,7 +36,7 @@ public enum GatewayDeviceAuthPayload {
|
||||||
clientMode: String,
|
clientMode: String,
|
||||||
role: String,
|
role: String,
|
||||||
scopes: [String],
|
scopes: [String],
|
||||||
signedAtMs: Int,
|
signedAtMs: Int64,
|
||||||
token: String?,
|
token: String?,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
|
|
@ -85,7 +85,7 @@ public enum GatewayDeviceAuthPayload {
|
||||||
public static func signedDeviceDictionary(
|
public static func signedDeviceDictionary(
|
||||||
payload: String,
|
payload: String,
|
||||||
identity: DeviceIdentity,
|
identity: DeviceIdentity,
|
||||||
signedAtMs: Int,
|
signedAtMs: Int64,
|
||||||
nonce: String) -> [String: OpenClawProtocol.AnyCodable]?
|
nonce: String) -> [String: OpenClawProtocol.AnyCodable]?
|
||||||
{
|
{
|
||||||
guard let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
guard let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ public struct DeviceAuthEntry: Codable, Sendable {
|
||||||
public let token: String
|
public let token: String
|
||||||
public let role: String
|
public let role: String
|
||||||
public let scopes: [String]
|
public let scopes: [String]
|
||||||
public let updatedAtMs: Int
|
public let updatedAtMs: Int64
|
||||||
public let gatewayID: String?
|
public let gatewayID: String?
|
||||||
|
|
||||||
public init(token: String, role: String, scopes: [String], updatedAtMs: Int, gatewayID: String? = nil) {
|
public init(token: String, role: String, scopes: [String], updatedAtMs: Int64, gatewayID: String? = nil) {
|
||||||
self.token = token
|
self.token = token
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
|
|
@ -50,6 +50,25 @@ public enum DeviceAuthStore {
|
||||||
profile: profile).entry
|
profile: profile).entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a token and reports whether the durable write succeeded.
|
||||||
|
@discardableResult
|
||||||
|
public static func storeTokenPersisted(
|
||||||
|
deviceId: String,
|
||||||
|
role: String,
|
||||||
|
token: String,
|
||||||
|
scopes: [String] = [],
|
||||||
|
gatewayID: String? = nil,
|
||||||
|
profile: GatewayDeviceIdentityProfile = .primary) -> Bool
|
||||||
|
{
|
||||||
|
self.storeTokenResult(
|
||||||
|
deviceId: deviceId,
|
||||||
|
role: role,
|
||||||
|
token: token,
|
||||||
|
scopes: scopes,
|
||||||
|
gatewayID: gatewayID,
|
||||||
|
profile: profile).persisted
|
||||||
|
}
|
||||||
|
|
||||||
static func storeTokenResult(
|
static func storeTokenResult(
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
|
@ -67,7 +86,7 @@ public enum DeviceAuthStore {
|
||||||
token: token,
|
token: token,
|
||||||
role: normalizedRole,
|
role: normalizedRole,
|
||||||
scopes: normalizeScopes(scopes),
|
scopes: normalizeScopes(scopes),
|
||||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
updatedAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
gatewayID: self.normalizeGatewayID(gatewayID))
|
gatewayID: self.normalizeGatewayID(gatewayID))
|
||||||
if next == nil {
|
if next == nil {
|
||||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ public struct DeviceIdentity: Codable, Sendable {
|
||||||
public var deviceId: String
|
public var deviceId: String
|
||||||
public var publicKey: String
|
public var publicKey: String
|
||||||
public var privateKey: String
|
public var privateKey: String
|
||||||
public var createdAtMs: Int
|
public var createdAtMs: Int64
|
||||||
|
|
||||||
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
|
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int64) {
|
||||||
self.deviceId = deviceId
|
self.deviceId = deviceId
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
|
|
@ -196,6 +196,15 @@ public enum DeviceIdentityStore {
|
||||||
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
|
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads or creates an identity, returning nil unless its key material was durably persisted.
|
||||||
|
public static func loadOrCreatePersisted(
|
||||||
|
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceIdentity?
|
||||||
|
{
|
||||||
|
self.loadOrCreatePersisted(
|
||||||
|
fileURL: self.fileURL(profile: profile),
|
||||||
|
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
|
||||||
|
}
|
||||||
|
|
||||||
static func loadOrCreate(
|
static func loadOrCreate(
|
||||||
fileURL url: URL,
|
fileURL url: URL,
|
||||||
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity
|
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity
|
||||||
|
|
@ -221,6 +230,22 @@ public enum DeviceIdentityStore {
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func loadOrCreatePersisted(
|
||||||
|
fileURL url: URL,
|
||||||
|
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity?
|
||||||
|
{
|
||||||
|
let identity = self.loadOrCreate(fileURL: url, migrationSource: migrationSource)
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
case let .identity(stored) = self.decodeStoredIdentity(data),
|
||||||
|
stored.deviceId == identity.deviceId,
|
||||||
|
stored.publicKey == identity.publicKey,
|
||||||
|
stored.privateKey == identity.privateKey
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
/// One-time upgrade path for builds that lost App Group storage: it runs only while the
|
/// One-time upgrade path for builds that lost App Group storage: it runs only while the
|
||||||
/// selected store has no identity file, so steady state never re-reads the old container.
|
/// selected store has no identity file, so steady state never re-reads the old container.
|
||||||
private static func migratedIdentity(
|
private static func migratedIdentity(
|
||||||
|
|
@ -314,7 +339,7 @@ public enum DeviceIdentityStore {
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
publicKey: publicKeyData.base64EncodedString(),
|
publicKey: publicKeyData.base64EncodedString(),
|
||||||
privateKey: privateKeyData.base64EncodedString(),
|
privateKey: privateKeyData.base64EncodedString(),
|
||||||
createdAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
createdAtMs: Int64(Date().timeIntervalSince1970 * 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func base64UrlEncode(_ data: Data) -> String {
|
private static func base64UrlEncode(_ data: Data) -> String {
|
||||||
|
|
@ -417,5 +442,5 @@ private struct PemDeviceIdentity: Codable {
|
||||||
var deviceId: String
|
var deviceId: String
|
||||||
var publicKeyPem: String
|
var publicKeyPem: String
|
||||||
var privateKeyPem: String
|
var privateKeyPem: String
|
||||||
var createdAtMs: Int
|
var createdAtMs: Int64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
#if Talk
|
#if Talk && canImport(ElevenLabsKit)
|
||||||
@_exported import ElevenLabsKit
|
@_exported import ElevenLabsKit
|
||||||
|
|
||||||
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice
|
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,7 @@ public actor GatewayChannelActor {
|
||||||
} else if let password = selectedAuth.authPassword {
|
} else if let password = selectedAuth.authPassword {
|
||||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let connectNonce = try await self.waitForConnectChallenge()
|
let connectNonce = try await self.waitForConnectChallenge()
|
||||||
if includeDeviceIdentity, let identity {
|
if includeDeviceIdentity, let identity {
|
||||||
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
|
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
|
#elseif os(watchOS)
|
||||||
|
import WatchKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public enum InstanceIdentity {
|
public enum InstanceIdentity {
|
||||||
|
|
@ -12,7 +14,7 @@ public enum InstanceIdentity {
|
||||||
UserDefaults(suiteName: suiteName) ?? .standard
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(watchOS)
|
||||||
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
|
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
return MainActor.assumeIsolated { body() }
|
return MainActor.assumeIsolated { body() }
|
||||||
|
|
@ -38,11 +40,16 @@ public enum InstanceIdentity {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static let displayName: String = {
|
public static let displayName: String = {
|
||||||
#if canImport(UIKit)
|
#if os(iOS)
|
||||||
let name = Self.readMainActor {
|
let name = Self.readMainActor {
|
||||||
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
return name.isEmpty ? "openclaw" : name
|
return name.isEmpty ? "openclaw" : name
|
||||||
|
#elseif os(watchOS)
|
||||||
|
let name = Self.readMainActor {
|
||||||
|
WKInterfaceDevice.current().name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return name.isEmpty ? "Apple Watch" : name
|
||||||
#else
|
#else
|
||||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!name.isEmpty
|
!name.isEmpty
|
||||||
|
|
@ -54,7 +61,7 @@ public enum InstanceIdentity {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static let modelIdentifier: String? = {
|
public static let modelIdentifier: String? = {
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(watchOS)
|
||||||
var systemInfo = utsname()
|
var systemInfo = utsname()
|
||||||
uname(&systemInfo)
|
uname(&systemInfo)
|
||||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||||
|
|
@ -77,7 +84,7 @@ public enum InstanceIdentity {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static let deviceFamily: String = {
|
public static let deviceFamily: String = {
|
||||||
#if canImport(UIKit)
|
#if os(iOS)
|
||||||
return Self.readMainActor {
|
return Self.readMainActor {
|
||||||
switch UIDevice.current.userInterfaceIdiom {
|
switch UIDevice.current.userInterfaceIdiom {
|
||||||
case .pad: "iPad"
|
case .pad: "iPad"
|
||||||
|
|
@ -85,6 +92,8 @@ public enum InstanceIdentity {
|
||||||
default: "iOS"
|
default: "iOS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#elseif os(watchOS)
|
||||||
|
return "Apple Watch"
|
||||||
#else
|
#else
|
||||||
return "Mac"
|
return "Mac"
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -92,7 +101,7 @@ public enum InstanceIdentity {
|
||||||
|
|
||||||
public static let platformString: String = {
|
public static let platformString: String = {
|
||||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
#if canImport(UIKit)
|
#if os(iOS)
|
||||||
let name = Self.readMainActor {
|
let name = Self.readMainActor {
|
||||||
switch UIDevice.current.userInterfaceIdiom {
|
switch UIDevice.current.userInterfaceIdiom {
|
||||||
case .pad: "iPadOS"
|
case .pad: "iPadOS"
|
||||||
|
|
@ -101,6 +110,8 @@ public enum InstanceIdentity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
|
#elseif os(watchOS)
|
||||||
|
return "watchOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
#else
|
#else
|
||||||
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
|
||||||
|
|
||||||
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
|
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
|
||||||
case notify = "watch.notify"
|
case notify = "watch.notify"
|
||||||
|
case directNodeSetup = "watch.node.setup"
|
||||||
case reply = "watch.reply"
|
case reply = "watch.reply"
|
||||||
case appSnapshot = "watch.app.snapshot"
|
case appSnapshot = "watch.app.snapshot"
|
||||||
case appSnapshotRequest = "watch.app.snapshotRequest"
|
case appSnapshotRequest = "watch.app.snapshotRequest"
|
||||||
|
|
@ -59,7 +60,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
|
||||||
public var host: String?
|
public var host: String?
|
||||||
public var nodeId: String?
|
public var nodeId: String?
|
||||||
public var agentId: String?
|
public var agentId: String?
|
||||||
public var expiresAtMs: Int?
|
public var expiresAtMs: Int64?
|
||||||
public var allowedDecisions: [OpenClawWatchExecApprovalDecision]
|
public var allowedDecisions: [OpenClawWatchExecApprovalDecision]
|
||||||
public var risk: OpenClawWatchRisk?
|
public var risk: OpenClawWatchRisk?
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
|
||||||
host: String? = nil,
|
host: String? = nil,
|
||||||
nodeId: String? = nil,
|
nodeId: String? = nil,
|
||||||
agentId: String? = nil,
|
agentId: String? = nil,
|
||||||
expiresAtMs: Int? = nil,
|
expiresAtMs: Int64? = nil,
|
||||||
allowedDecisions: [OpenClawWatchExecApprovalDecision] = [],
|
allowedDecisions: [OpenClawWatchExecApprovalDecision] = [],
|
||||||
risk: OpenClawWatchRisk? = nil)
|
risk: OpenClawWatchRisk? = nil)
|
||||||
{
|
{
|
||||||
|
|
@ -91,13 +92,13 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
|
||||||
public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
|
public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
|
||||||
public var type: OpenClawWatchPayloadType
|
public var type: OpenClawWatchPayloadType
|
||||||
public var approval: OpenClawWatchExecApprovalItem
|
public var approval: OpenClawWatchExecApprovalItem
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
public var deliveryId: String?
|
public var deliveryId: String?
|
||||||
public var resetResolvingState: Bool?
|
public var resetResolvingState: Bool?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
approval: OpenClawWatchExecApprovalItem,
|
approval: OpenClawWatchExecApprovalItem,
|
||||||
sentAtMs: Int? = nil,
|
sentAtMs: Int64? = nil,
|
||||||
deliveryId: String? = nil,
|
deliveryId: String? = nil,
|
||||||
resetResolvingState: Bool? = nil)
|
resetResolvingState: Bool? = nil)
|
||||||
{
|
{
|
||||||
|
|
@ -115,14 +116,14 @@ public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equata
|
||||||
public var gatewayStableID: String?
|
public var gatewayStableID: String?
|
||||||
public var decision: OpenClawWatchExecApprovalDecision
|
public var decision: OpenClawWatchExecApprovalDecision
|
||||||
public var replyId: String
|
public var replyId: String
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
approvalId: String,
|
approvalId: String,
|
||||||
gatewayStableID: String? = nil,
|
gatewayStableID: String? = nil,
|
||||||
decision: OpenClawWatchExecApprovalDecision,
|
decision: OpenClawWatchExecApprovalDecision,
|
||||||
replyId: String,
|
replyId: String,
|
||||||
sentAtMs: Int? = nil)
|
sentAtMs: Int64? = nil)
|
||||||
{
|
{
|
||||||
self.type = .execApprovalResolve
|
self.type = .execApprovalResolve
|
||||||
self.approvalId = approvalId
|
self.approvalId = approvalId
|
||||||
|
|
@ -138,14 +139,14 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat
|
||||||
public var approvalId: String
|
public var approvalId: String
|
||||||
public var gatewayStableID: String?
|
public var gatewayStableID: String?
|
||||||
public var decision: OpenClawWatchExecApprovalDecision?
|
public var decision: OpenClawWatchExecApprovalDecision?
|
||||||
public var resolvedAtMs: Int?
|
public var resolvedAtMs: Int64?
|
||||||
public var source: String?
|
public var source: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
approvalId: String,
|
approvalId: String,
|
||||||
gatewayStableID: String? = nil,
|
gatewayStableID: String? = nil,
|
||||||
decision: OpenClawWatchExecApprovalDecision? = nil,
|
decision: OpenClawWatchExecApprovalDecision? = nil,
|
||||||
resolvedAtMs: Int? = nil,
|
resolvedAtMs: Int64? = nil,
|
||||||
source: String? = nil)
|
source: String? = nil)
|
||||||
{
|
{
|
||||||
self.type = .execApprovalResolved
|
self.type = .execApprovalResolved
|
||||||
|
|
@ -162,13 +163,13 @@ public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equata
|
||||||
public var approvalId: String
|
public var approvalId: String
|
||||||
public var gatewayStableID: String?
|
public var gatewayStableID: String?
|
||||||
public var reason: OpenClawWatchExecApprovalCloseReason
|
public var reason: OpenClawWatchExecApprovalCloseReason
|
||||||
public var expiredAtMs: Int?
|
public var expiredAtMs: Int64?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
approvalId: String,
|
approvalId: String,
|
||||||
gatewayStableID: String? = nil,
|
gatewayStableID: String? = nil,
|
||||||
reason: OpenClawWatchExecApprovalCloseReason,
|
reason: OpenClawWatchExecApprovalCloseReason,
|
||||||
expiredAtMs: Int? = nil)
|
expiredAtMs: Int64? = nil)
|
||||||
{
|
{
|
||||||
self.type = .execApprovalExpired
|
self.type = .execApprovalExpired
|
||||||
self.approvalId = approvalId
|
self.approvalId = approvalId
|
||||||
|
|
@ -182,13 +183,13 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat
|
||||||
public var type: OpenClawWatchPayloadType
|
public var type: OpenClawWatchPayloadType
|
||||||
public var approvals: [OpenClawWatchExecApprovalItem]
|
public var approvals: [OpenClawWatchExecApprovalItem]
|
||||||
public var gatewayStableID: String?
|
public var gatewayStableID: String?
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
public var snapshotId: String?
|
public var snapshotId: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
approvals: [OpenClawWatchExecApprovalItem],
|
approvals: [OpenClawWatchExecApprovalItem],
|
||||||
gatewayStableID: String? = nil,
|
gatewayStableID: String? = nil,
|
||||||
sentAtMs: Int? = nil,
|
sentAtMs: Int64? = nil,
|
||||||
snapshotId: String? = nil)
|
snapshotId: String? = nil)
|
||||||
{
|
{
|
||||||
self.type = .execApprovalSnapshot
|
self.type = .execApprovalSnapshot
|
||||||
|
|
@ -202,9 +203,9 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat
|
||||||
public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
|
public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
|
||||||
public var type: OpenClawWatchPayloadType
|
public var type: OpenClawWatchPayloadType
|
||||||
public var requestId: String
|
public var requestId: String
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
|
|
||||||
public init(requestId: String, sentAtMs: Int? = nil) {
|
public init(requestId: String, sentAtMs: Int64? = nil) {
|
||||||
self.type = .execApprovalSnapshotRequest
|
self.type = .execApprovalSnapshotRequest
|
||||||
self.requestId = requestId
|
self.requestId = requestId
|
||||||
self.sentAtMs = sentAtMs
|
self.sentAtMs = sentAtMs
|
||||||
|
|
@ -215,13 +216,13 @@ public struct OpenClawWatchChatItem: Codable, Sendable, Equatable, Identifiable
|
||||||
public var id: String
|
public var id: String
|
||||||
public var role: String
|
public var role: String
|
||||||
public var text: String
|
public var text: String
|
||||||
public var timestampMs: Int?
|
public var timestampMs: Int64?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
role: String,
|
role: String,
|
||||||
text: String,
|
text: String,
|
||||||
timestampMs: Int? = nil)
|
timestampMs: Int64? = nil)
|
||||||
{
|
{
|
||||||
self.id = id
|
self.id = id
|
||||||
self.role = role
|
self.role = role
|
||||||
|
|
@ -234,9 +235,9 @@ public struct OpenClawWatchChatCompletionMessage: Codable, Sendable, Equatable {
|
||||||
public var type: OpenClawWatchPayloadType
|
public var type: OpenClawWatchPayloadType
|
||||||
public var commandId: String
|
public var commandId: String
|
||||||
public var replyText: String
|
public var replyText: String
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
|
|
||||||
public init(commandId: String, replyText: String, sentAtMs: Int? = nil) {
|
public init(commandId: String, replyText: String, sentAtMs: Int64? = nil) {
|
||||||
self.type = .chatCompletion
|
self.type = .chatCompletion
|
||||||
self.commandId = commandId
|
self.commandId = commandId
|
||||||
self.replyText = replyText
|
self.replyText = replyText
|
||||||
|
|
@ -260,7 +261,7 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
|
||||||
public var pendingApprovalCount: Int
|
public var pendingApprovalCount: Int
|
||||||
public var chatItems: [OpenClawWatchChatItem]?
|
public var chatItems: [OpenClawWatchChatItem]?
|
||||||
public var chatStatusText: String?
|
public var chatStatusText: String?
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
public var snapshotId: String?
|
public var snapshotId: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|
@ -278,7 +279,7 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
|
||||||
pendingApprovalCount: Int,
|
pendingApprovalCount: Int,
|
||||||
chatItems: [OpenClawWatchChatItem]? = nil,
|
chatItems: [OpenClawWatchChatItem]? = nil,
|
||||||
chatStatusText: String? = nil,
|
chatStatusText: String? = nil,
|
||||||
sentAtMs: Int? = nil,
|
sentAtMs: Int64? = nil,
|
||||||
snapshotId: String? = nil)
|
snapshotId: String? = nil)
|
||||||
{
|
{
|
||||||
self.type = .appSnapshot
|
self.type = .appSnapshot
|
||||||
|
|
@ -304,9 +305,9 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
|
||||||
public struct OpenClawWatchAppSnapshotRequestMessage: Codable, Sendable, Equatable {
|
public struct OpenClawWatchAppSnapshotRequestMessage: Codable, Sendable, Equatable {
|
||||||
public var type: OpenClawWatchPayloadType
|
public var type: OpenClawWatchPayloadType
|
||||||
public var requestId: String
|
public var requestId: String
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
|
|
||||||
public init(requestId: String, sentAtMs: Int? = nil) {
|
public init(requestId: String, sentAtMs: Int64? = nil) {
|
||||||
self.type = .appSnapshotRequest
|
self.type = .appSnapshotRequest
|
||||||
self.requestId = requestId
|
self.requestId = requestId
|
||||||
self.sentAtMs = sentAtMs
|
self.sentAtMs = sentAtMs
|
||||||
|
|
@ -328,7 +329,7 @@ public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable {
|
||||||
public var sessionKey: String?
|
public var sessionKey: String?
|
||||||
public var gatewayStableID: String?
|
public var gatewayStableID: String?
|
||||||
public var text: String?
|
public var text: String?
|
||||||
public var sentAtMs: Int?
|
public var sentAtMs: Int64?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: OpenClawWatchAppCommand,
|
command: OpenClawWatchAppCommand,
|
||||||
|
|
@ -336,7 +337,7 @@ public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable {
|
||||||
sessionKey: String? = nil,
|
sessionKey: String? = nil,
|
||||||
gatewayStableID: String? = nil,
|
gatewayStableID: String? = nil,
|
||||||
text: String? = nil,
|
text: String? = nil,
|
||||||
sentAtMs: Int? = nil)
|
sentAtMs: Int64? = nil)
|
||||||
{
|
{
|
||||||
self.type = .appCommand
|
self.type = .appCommand
|
||||||
self.command = command
|
self.command = command
|
||||||
|
|
@ -379,7 +380,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||||
public var gatewayStableID: String?
|
public var gatewayStableID: String?
|
||||||
public var kind: String?
|
public var kind: String?
|
||||||
public var details: String?
|
public var details: String?
|
||||||
public var expiresAtMs: Int?
|
public var expiresAtMs: Int64?
|
||||||
public var risk: OpenClawWatchRisk?
|
public var risk: OpenClawWatchRisk?
|
||||||
public var actions: [OpenClawWatchAction]?
|
public var actions: [OpenClawWatchAction]?
|
||||||
|
|
||||||
|
|
@ -392,7 +393,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||||
gatewayStableID: String? = nil,
|
gatewayStableID: String? = nil,
|
||||||
kind: String? = nil,
|
kind: String? = nil,
|
||||||
details: String? = nil,
|
details: String? = nil,
|
||||||
expiresAtMs: Int? = nil,
|
expiresAtMs: Int64? = nil,
|
||||||
risk: OpenClawWatchRisk? = nil,
|
risk: OpenClawWatchRisk? = nil,
|
||||||
actions: [OpenClawWatchAction]? = nil)
|
actions: [OpenClawWatchAction]? = nil)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(WebKit)
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
public enum WebViewJavaScriptSupport {
|
public enum WebViewJavaScriptSupport {
|
||||||
|
|
@ -55,3 +56,4 @@ public enum WebViewJavaScriptSupport {
|
||||||
return "null"
|
return "null"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||||
|
if let int64Val = try? container.decode(Int64.self) { self.value = int64Val; return }
|
||||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||||
if container.decodeNil() { self.value = NSNull(); return }
|
if container.decodeNil() { self.value = NSNull(); return }
|
||||||
|
|
@ -27,6 +28,7 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||||
switch self.value {
|
switch self.value {
|
||||||
case let boolVal as Bool: try container.encode(boolVal)
|
case let boolVal as Bool: try container.encode(boolVal)
|
||||||
case let intVal as Int: try container.encode(intVal)
|
case let intVal as Int: try container.encode(intVal)
|
||||||
|
case let int64Val as Int64: try container.encode(int64Val)
|
||||||
case let doubleVal as Double: try container.encode(doubleVal)
|
case let doubleVal as Double: try container.encode(doubleVal)
|
||||||
case let stringVal as String: try container.encode(stringVal)
|
case let stringVal as String: try container.encode(stringVal)
|
||||||
case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID():
|
case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID():
|
||||||
|
|
@ -66,6 +68,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||||
switch (lhs.value, rhs.value) {
|
switch (lhs.value, rhs.value) {
|
||||||
case let (l as Bool, r as Bool): l == r
|
case let (l as Bool, r as Bool): l == r
|
||||||
case let (l as Int, r as Int): l == r
|
case let (l as Int, r as Int): l == r
|
||||||
|
case let (l as Int64, r as Int64): l == r
|
||||||
|
case let (l as Int, r as Int64): Int64(l) == r
|
||||||
|
case let (l as Int64, r as Int): l == Int64(r)
|
||||||
case let (l as Double, r as Double): l == r
|
case let (l as Double, r as Double): l == r
|
||||||
case let (l as String, r as String): l == r
|
case let (l as String, r as String): l == r
|
||||||
case (_ as NSNull, _ as NSNull): true
|
case (_ as NSNull, _ as NSNull): true
|
||||||
|
|
@ -81,6 +86,8 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||||
case let v as Bool:
|
case let v as Bool:
|
||||||
hasher.combine(2); hasher.combine(v)
|
hasher.combine(2); hasher.combine(v)
|
||||||
case let v as Int:
|
case let v as Int:
|
||||||
|
hasher.combine(0); hasher.combine(Int64(v))
|
||||||
|
case let v as Int64:
|
||||||
hasher.combine(0); hasher.combine(v)
|
hasher.combine(0); hasher.combine(v)
|
||||||
case let v as Double:
|
case let v as Double:
|
||||||
hasher.combine(1); hasher.combine(v)
|
hasher.combine(1); hasher.combine(v)
|
||||||
|
|
|
||||||
|
|
@ -8679,21 +8679,25 @@ public struct DevicePairSetupCodeParams: Codable, Sendable {
|
||||||
public let publicurl: String?
|
public let publicurl: String?
|
||||||
public let preferremoteurl: Bool?
|
public let preferremoteurl: Bool?
|
||||||
public let includeqr: Bool?
|
public let includeqr: Bool?
|
||||||
|
public let bootstrapprofile: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
publicurl: String?,
|
publicurl: String?,
|
||||||
preferremoteurl: Bool?,
|
preferremoteurl: Bool?,
|
||||||
includeqr: Bool?)
|
includeqr: Bool?,
|
||||||
|
bootstrapprofile: String?)
|
||||||
{
|
{
|
||||||
self.publicurl = publicurl
|
self.publicurl = publicurl
|
||||||
self.preferremoteurl = preferremoteurl
|
self.preferremoteurl = preferremoteurl
|
||||||
self.includeqr = includeqr
|
self.includeqr = includeqr
|
||||||
|
self.bootstrapprofile = bootstrapprofile
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case publicurl = "publicUrl"
|
case publicurl = "publicUrl"
|
||||||
case preferremoteurl = "preferRemoteUrl"
|
case preferremoteurl = "preferRemoteUrl"
|
||||||
case includeqr = "includeQr"
|
case includeqr = "includeQr"
|
||||||
|
case bootstrapprofile = "bootstrapProfile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,22 @@ import Testing
|
||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
|
|
||||||
struct AnyCodableTests {
|
struct AnyCodableTests {
|
||||||
|
@Test
|
||||||
|
func roundTripsEpochMillisecondsBeyondInt32() throws {
|
||||||
|
let epochMilliseconds: Int64 = 1_800_000_000_000
|
||||||
|
let original = AnyCodable(epochMilliseconds)
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(original)
|
||||||
|
let decoded = try JSONDecoder().decode(AnyCodable.self, from: data)
|
||||||
|
let decodedMilliseconds = (decoded.value as? Int64)
|
||||||
|
?? (decoded.value as? Int).map(Int64.init)
|
||||||
|
|
||||||
|
#expect(String(decoding: data, as: UTF8.self) == "1800000000000")
|
||||||
|
#expect(decodedMilliseconds == epochMilliseconds)
|
||||||
|
#expect(decoded == original)
|
||||||
|
#expect(Set([decoded, original]).count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func encodesNSNumberBooleansAsJSONBooleans() throws {
|
func encodesNSNumberBooleansAsJSONBooleans() throws {
|
||||||
let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true)))
|
let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true)))
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import OpenClawKit
|
@testable import OpenClawKit
|
||||||
|
|
||||||
|
|
@ -5,29 +7,31 @@ import Testing
|
||||||
struct DeviceAuthPayloadTests {
|
struct DeviceAuthPayloadTests {
|
||||||
@Test
|
@Test
|
||||||
func `builds Swift connect compatibility payload with v2 canonical fields`() {
|
func `builds Swift connect compatibility payload with v2 canonical fields`() {
|
||||||
|
let signedAtMs: Int64 = 1_800_000_000_000
|
||||||
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
|
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
|
||||||
deviceId: "dev-1",
|
deviceId: "dev-1",
|
||||||
clientId: "openclaw-macos",
|
clientId: "openclaw-macos",
|
||||||
clientMode: "ui",
|
clientMode: "ui",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.admin", "operator.read"],
|
scopes: ["operator.admin", "operator.read"],
|
||||||
signedAtMs: 1_700_000_000_000,
|
signedAtMs: signedAtMs,
|
||||||
token: "tok-123",
|
token: "tok-123",
|
||||||
nonce: "nonce-abc")
|
nonce: "nonce-abc")
|
||||||
#expect(
|
#expect(
|
||||||
payload
|
payload
|
||||||
== "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc")
|
== "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1800000000000|tok-123|nonce-abc")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `builds canonical v3 payload vector`() {
|
func `builds canonical v3 payload vector`() {
|
||||||
|
let signedAtMs: Int64 = 1_800_000_000_000
|
||||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||||
deviceId: "dev-1",
|
deviceId: "dev-1",
|
||||||
clientId: "openclaw-macos",
|
clientId: "openclaw-macos",
|
||||||
clientMode: "ui",
|
clientMode: "ui",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.admin", "operator.read"],
|
scopes: ["operator.admin", "operator.read"],
|
||||||
signedAtMs: 1_700_000_000_000,
|
signedAtMs: signedAtMs,
|
||||||
token: "tok-123",
|
token: "tok-123",
|
||||||
nonce: "nonce-abc",
|
nonce: "nonce-abc",
|
||||||
platform: " IOS ",
|
platform: " IOS ",
|
||||||
|
|
@ -35,7 +39,47 @@ struct DeviceAuthPayloadTests {
|
||||||
#expect(
|
#expect(
|
||||||
payload
|
payload
|
||||||
==
|
==
|
||||||
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
|
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1800000000000|tok-123|nonce-abc|ios|iphone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `signed device dictionary preserves 64-bit timestamp`() throws {
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false)
|
||||||
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
|
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||||
|
let signedAtMs: Int64 = 1_800_000_000_000
|
||||||
|
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: "openclaw-watchos",
|
||||||
|
clientMode: "node",
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
signedAtMs: signedAtMs,
|
||||||
|
token: "device-token",
|
||||||
|
nonce: "nonce-abc",
|
||||||
|
platform: "watchOS",
|
||||||
|
deviceFamily: "Apple Watch")
|
||||||
|
|
||||||
|
let device = try #require(GatewayDeviceAuthPayload.signedDeviceDictionary(
|
||||||
|
payload: payload,
|
||||||
|
identity: identity,
|
||||||
|
signedAtMs: signedAtMs,
|
||||||
|
nonce: "nonce-abc"))
|
||||||
|
let signature = try #require(device["signature"]?.value as? String)
|
||||||
|
let signatureBase64 = signature
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
let signaturePadding = String(repeating: "=", count: (4 - signatureBase64.count % 4) % 4)
|
||||||
|
let signatureData = try #require(Data(base64Encoded: signatureBase64 + signaturePadding))
|
||||||
|
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
|
||||||
|
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
|
||||||
|
let data = try JSONEncoder().encode(device)
|
||||||
|
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
|
||||||
|
#expect(publicKey.isValidSignature(signatureData, for: Data(payload.utf8)))
|
||||||
|
#expect((object["signedAt"] as? NSNumber)?.int64Value == signedAtMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,49 @@ struct DeviceIdentityStoreTests {
|
||||||
deviceId: "unwritable-device",
|
deviceId: "unwritable-device",
|
||||||
role: "node",
|
role: "node",
|
||||||
token: "must-not-be-acknowledged")
|
token: "must-not-be-acknowledged")
|
||||||
|
let publicWritePersisted = DeviceAuthStore.storeTokenPersisted(
|
||||||
|
deviceId: "unwritable-device",
|
||||||
|
role: "node",
|
||||||
|
token: "also-must-not-be-acknowledged")
|
||||||
|
let durableIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
|
||||||
|
|
||||||
#expect(compatibleEntry.token == "must-not-be-acknowledged")
|
#expect(compatibleEntry.token == "must-not-be-acknowledged")
|
||||||
#expect(!stored.persisted)
|
#expect(!stored.persisted)
|
||||||
|
#expect(!publicWritePersisted)
|
||||||
|
#expect(durableIdentity == nil)
|
||||||
#expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil)
|
#expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `device auth entry round-trips epoch milliseconds beyond Int32`() throws {
|
||||||
|
let epochMilliseconds: Int64 = 1_800_000_000_000
|
||||||
|
let entry = DeviceAuthEntry(
|
||||||
|
token: "device-token",
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
updatedAtMs: epochMilliseconds)
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(entry)
|
||||||
|
let decoded = try JSONDecoder().decode(DeviceAuthEntry.self, from: data)
|
||||||
|
|
||||||
|
#expect(decoded.updatedAtMs == epochMilliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `durable identity creation verifies persisted key material`() throws {
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false)
|
||||||
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
|
|
||||||
|
let identity = try #require(DeviceIdentityStore.loadOrCreatePersisted(fileURL: identityURL))
|
||||||
|
let reloaded = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||||
|
|
||||||
|
#expect(reloaded.deviceId == identity.deviceId)
|
||||||
|
#expect(reloaded.publicKey == identity.publicKey)
|
||||||
|
#expect(reloaded.privateKey == identity.privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
@Test(.stateDirectoryIsolated)
|
@Test(.stateDirectoryIsolated)
|
||||||
func `device auth tokens are isolated by gateway owner`() {
|
func `device auth tokens are isolated by gateway owner`() {
|
||||||
let deviceID = "test-device"
|
let deviceID = "test-device"
|
||||||
|
|
@ -262,6 +299,7 @@ struct DeviceIdentityStoreTests {
|
||||||
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
|
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
|
||||||
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||||
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||||
|
#expect(identity.createdAtMs == 1_800_000_000_000)
|
||||||
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
|
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
|
||||||
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
|
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
|
||||||
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
|
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
|
||||||
|
|
@ -500,7 +538,7 @@ struct DeviceIdentityStoreTests {
|
||||||
"deviceId": "stale-device-id",
|
"deviceId": "stale-device-id",
|
||||||
"publicKeyPem": publicKeyPem,
|
"publicKeyPem": publicKeyPem,
|
||||||
"privateKeyPem": privateKeyPem,
|
"privateKeyPem": privateKeyPem,
|
||||||
"createdAtMs": 1_700_000_000_000,
|
"createdAtMs": Int64(1_800_000_000_000),
|
||||||
]
|
]
|
||||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||||
return String(decoding: data, as: UTF8.self) + "\n"
|
return String(decoding: data, as: UTF8.self) + "\n"
|
||||||
|
|
|
||||||
|
|
@ -1575,6 +1575,20 @@ struct GatewayNodeSessionTests {
|
||||||
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
|
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `watch invoke payload decodes without bridge frame type`() throws {
|
||||||
|
let data = Data(
|
||||||
|
#"{"id":"invoke-1","nodeId":"watch-1","command":"device.info","paramsJSON":null,"timeoutMs":2000}"#
|
||||||
|
.utf8)
|
||||||
|
let request = try JSONDecoder().decode(NodeInvokeRequestEvent.self, from: data)
|
||||||
|
|
||||||
|
#expect(request.id == "invoke-1")
|
||||||
|
#expect(request.nodeid == "watch-1")
|
||||||
|
#expect(request.command == "device.info")
|
||||||
|
#expect(request.paramsjson == nil)
|
||||||
|
#expect(request.timeoutms == 2000)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `invoke with timeout returns underlying response before timeout`() async {
|
func `invoke with timeout returns underlying response before timeout`() async {
|
||||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ openclaw nodes location get --node <id> --accuracy precise
|
||||||
openclaw nodes screen record --node <id> --duration 10s --fps 10 --out ./clip.mp4
|
openclaw nodes screen record --node <id> --duration 10s --fps 10 --out ./clip.mp4
|
||||||
```
|
```
|
||||||
|
|
||||||
- `notify` sends a local notification on a node (macOS only). Requires `--title` or `--body`. Options: `--sound <name>`, `--priority <passive|active|timeSensitive>`, `--delivery <system|overlay|auto>` (default `system`), `--invoke-timeout <ms>` (default `15000`).
|
- `notify` sends a local notification on a node that declares `system.notify`, including macOS, iOS, Android, and direct watchOS nodes. Direct watchOS delivery requires OpenClaw to be active. Requires `--title` or `--body`. Options: `--sound <name>`, `--priority <passive|active|timeSensitive>`, `--delivery <system|overlay|auto>` (default `system`), `--invoke-timeout <ms>` (default `15000`).
|
||||||
- `push` sends an APNs test push to an iOS node. Options: `--title <text>` (default `OpenClaw`), `--body <text>`, `--environment <sandbox|production>` to override the detected APNs environment.
|
- `push` sends an APNs test push to an iOS node. Options: `--title <text>` (default `OpenClaw`), `--body <text>`, `--environment <sandbox|production>` to override the detected APNs environment.
|
||||||
- `location get` fetches the node's current location. Options: `--max-age <ms>` (reuse a cached fix), `--accuracy <coarse|balanced|precise>`, `--location-timeout <ms>` (default `10000`), `--invoke-timeout <ms>` (default `20000`).
|
- `location get` fetches the node's current location. Options: `--max-age <ms>` (reuse a cached fix), `--accuracy <coarse|balanced|precise>`, `--location-timeout <ms>` (default `10000`), `--invoke-timeout <ms>` (default `20000`).
|
||||||
- `screen record` captures a short clip and prints the saved path (or writes JSON with `--json`). Options: `--screen <index>` (default `0`), `--duration <ms|10s>` (default `10000`), `--fps <fps>` (default `10`), `--no-audio`, `--out <path>`, `--invoke-timeout <ms>` (default `120000`).
|
- `screen record` captures a short clip and prints the saved path (or writes JSON with `--json`). Options: `--screen <index>` (default `0`), `--duration <ms|10s>` (default `10000`), `--fps <fps>` (default `10`), `--no-audio`, `--out <path>`, `--invoke-timeout <ms>` (default `120000`).
|
||||||
|
|
|
||||||
|
|
@ -4943,6 +4943,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||||
- H2: What it does
|
- H2: What it does
|
||||||
- H2: Requirements
|
- H2: Requirements
|
||||||
- H2: Quick start (pair + connect)
|
- H2: Quick start (pair + connect)
|
||||||
|
- H2: Optional direct Apple Watch node
|
||||||
- H2: Relay-backed push for official builds
|
- H2: Relay-backed push for official builds
|
||||||
- H2: Background alive beacons
|
- H2: Background alive beacons
|
||||||
- H2: Authentication and trust flow
|
- H2: Authentication and trust flow
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/device/notifications/system"
|
summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/device/notifications/system"
|
||||||
read_when:
|
read_when:
|
||||||
- Pairing iOS/Android nodes to a gateway
|
- Pairing iOS/watchOS/Android nodes to a gateway
|
||||||
- Using node canvas/camera for agent context
|
- Using node canvas/camera for agent context
|
||||||
- Adding new node commands or CLI helpers
|
- Adding new node commands or CLI helpers
|
||||||
title: "Nodes"
|
title: "Nodes"
|
||||||
---
|
---
|
||||||
|
|
||||||
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
|
A **node** is a companion device (macOS/iOS/watchOS/Android/headless) that connects to the Gateway with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Most nodes use the Gateway WebSocket on the operator port. The optional direct Apple Watch node uses signed HTTPS polling on that same port because watchOS blocks generic low-level networking for ordinary apps. Protocol details: [Gateway protocol](/gateway/protocol).
|
||||||
|
|
||||||
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes).
|
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes).
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ Troubleshooting runbook: [/nodes/troubleshooting](/nodes/troubleshooting)
|
||||||
|
|
||||||
## Pairing + status
|
## Pairing + status
|
||||||
|
|
||||||
WS nodes use **device pairing**. A node presents a device identity during `connect`; the Gateway creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
|
Nodes use **device pairing**. A node presents a signed device identity during connect; the Gateway creates a device pairing request for `role: node`. Approve via the devices CLI (or UI). The direct Apple Watch setup uses an admin-minted, short-lived node-only setup code to approve its fixed low-risk command surface; later capability expansion still requires normal approval.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw devices list
|
openclaw devices list
|
||||||
|
|
@ -33,7 +33,7 @@ Pending pairing requests expire 5 minutes after the device's last retry — a de
|
||||||
|
|
||||||
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
|
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
|
||||||
- The device pairing record is the durable approved-role contract. Token rotation stays inside that contract; it cannot upgrade a paired node into a role that pairing approval never granted.
|
- The device pairing record is the durable approved-role contract. Token rotation stays inside that contract; it cannot upgrade a paired node into a role that pairing approval never granted.
|
||||||
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate, gateway-owned node pairing store that tracks the node's approved command/capability surface across reconnects. It does **not** gate the WS `connect` handshake — device pairing does that.
|
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate, gateway-owned node pairing store that tracks the node's approved command/capability surface across reconnects. It does **not** gate transport authentication — device pairing does that.
|
||||||
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a device-backed node it revokes the device's `node` role in `devices/paired.json` and disconnects that device's node-role sessions: a mixed-role device keeps its row and only loses the `node` role, while a node-only device row is deleted. It also clears any matching entry from the separate node pairing store. `operator.pairing` may remove non-operator node rows on other devices; a device-token caller revoking its own node role on a mixed-role device additionally needs `operator.admin`.
|
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a device-backed node it revokes the device's `node` role in `devices/paired.json` and disconnects that device's node-role sessions: a mixed-role device keeps its row and only loses the `node` role, while a node-only device row is deleted. It also clears any matching entry from the separate node pairing store. `operator.pairing` may remove non-operator node rows on other devices; a device-token caller revoking its own node role on a mixed-role device additionally needs `operator.admin`.
|
||||||
- Approval scope follows the pending request's declared commands:
|
- Approval scope follows the pending request's declared commands:
|
||||||
- commandless request: `operator.pairing`
|
- commandless request: `operator.pairing`
|
||||||
|
|
@ -42,7 +42,7 @@ Pending pairing requests expire 5 minutes after the device's last retry — a de
|
||||||
|
|
||||||
## Version skew and upgrade order
|
## Version skew and upgrade order
|
||||||
|
|
||||||
The Gateway accepts authenticated node clients across an N-1 protocol window.
|
The Gateway WebSocket accepts authenticated node clients across an N-1 protocol window.
|
||||||
The current v4 Gateway therefore accepts v3 nodes when the connection declares
|
The current v4 Gateway therefore accepts v3 nodes when the connection declares
|
||||||
both `role: "node"` and `client.mode: "node"`. Operator and UI sessions must
|
both `role: "node"` and `client.mode: "node"`. Operator and UI sessions must
|
||||||
still use the current protocol.
|
still use the current protocol.
|
||||||
|
|
@ -55,6 +55,9 @@ Plugin-owned capabilities and commands stay hidden until the node upgrades to
|
||||||
the current protocol. Nodes older than N-1 require an out-of-band upgrade before
|
the current protocol. Nodes older than N-1 require an out-of-band upgrade before
|
||||||
reconnecting.
|
reconnecting.
|
||||||
|
|
||||||
|
The direct watchOS HTTPS transport requires the current protocol version; update
|
||||||
|
the watch app with the Gateway before enabling direct mode.
|
||||||
|
|
||||||
## Remote node host (system.run)
|
## Remote node host (system.run)
|
||||||
|
|
||||||
Use a **node host** when your Gateway runs on one machine and you want commands to execute on another. The model still talks to the **gateway**; the gateway forwards `exec` calls to the **node host** when `host=node` is selected.
|
Use a **node host** when your Gateway runs on one machine and you want commands to execute on another. The model still talks to the **gateway**; the gateway forwards `exec` calls to the **node host** when `host=node` is selected.
|
||||||
|
|
@ -203,7 +206,7 @@ openclaw nodes invoke --node <idOrNameOrIp> --command canvas.eval --params '{"ja
|
||||||
|
|
||||||
Node commands must pass two gates before they can be invoked:
|
Node commands must pass two gates before they can be invoked:
|
||||||
|
|
||||||
1. The node must declare the command in its WebSocket `connect.commands` list.
|
1. The node must declare the command in its authenticated connect metadata (`connect.commands`).
|
||||||
2. The gateway's platform-and-approval-derived allowlist must include the declared command.
|
2. The gateway's platform-and-approval-derived allowlist must include the declared command.
|
||||||
|
|
||||||
Default allowlists by platform (before plugin defaults and `allowCommands`/`denyCommands` overrides):
|
Default allowlists by platform (before plugin defaults and `allowCommands`/`denyCommands` overrides):
|
||||||
|
|
@ -211,6 +214,7 @@ Default allowlists by platform (before plugin defaults and `allowCommands`/`deny
|
||||||
| Platform | Commands allowed by default |
|
| Platform | Commands allowed by default |
|
||||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| iOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` |
|
| iOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` |
|
||||||
|
| watchOS | `device.info`, `device.status`, `system.notify` |
|
||||||
| Android | `camera.list`, `location.get`, `notifications.list`, `notifications.actions`, `system.notify`, `device.info`, `device.status`, `device.permissions`, `device.health`, `device.apps`, `contacts.search`, `calendar.events`, `callLog.search`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer` |
|
| Android | `camera.list`, `location.get`, `notifications.list`, `notifications.actions`, `system.notify`, `device.info`, `device.status`, `device.permissions`, `device.health`, `device.apps`, `contacts.search`, `calendar.events`, `callLog.search`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer` |
|
||||||
| macOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` |
|
| macOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` |
|
||||||
| Windows | `camera.list`, `location.get`, `device.info`, `device.status`, `system.notify` |
|
| Windows | `camera.list`, `location.get`, `device.info`, `device.status`, `system.notify` |
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
summary: "iOS node app: connect to the Gateway, pairing, canvas, and troubleshooting"
|
summary: "iOS node app: connect to the Gateway, pairing, canvas, and troubleshooting"
|
||||||
read_when:
|
read_when:
|
||||||
- Pairing or reconnecting the iOS node
|
- Pairing or reconnecting the iOS node
|
||||||
|
- Enabling or troubleshooting the direct Apple Watch node
|
||||||
- Running the iOS app from source
|
- Running the iOS app from source
|
||||||
- Debugging gateway discovery or canvas commands
|
- Debugging gateway discovery or canvas commands
|
||||||
title: "iOS app"
|
title: "iOS app"
|
||||||
|
|
@ -53,12 +54,6 @@ creation has a token or password auth path.
|
||||||
4. The official app connects automatically. If **Pending approval** shows a
|
4. The official app connects automatically. If **Pending approval** shows a
|
||||||
request, review its role and scopes before approving it.
|
request, review its role and scopes before approving it.
|
||||||
|
|
||||||
The Apple Watch companion does not have a separate OpenClaw pairing approval.
|
|
||||||
Pair the Watch with the iPhone in Apple's Watch app, install OpenClaw from
|
|
||||||
**Watch app -> My Watch -> Available Apps**, then open OpenClaw once on both
|
|
||||||
devices. OpenClaw follows Apple Watch pairing and install changes immediately;
|
|
||||||
the Gateway's device approval covers the iPhone node.
|
|
||||||
|
|
||||||
The Control UI button requires an already paired session with `operator.admin`.
|
The Control UI button requires an already paired session with `operator.admin`.
|
||||||
As a terminal fallback, pick a discovered gateway in the iOS app (or enable
|
As a terminal fallback, pick a discovered gateway in the iOS app (or enable
|
||||||
Manual Host and enter host/port), then approve the request on the Gateway host:
|
Manual Host and enter host/port), then approve the request on the Gateway host:
|
||||||
|
|
@ -93,6 +88,55 @@ openclaw nodes status
|
||||||
openclaw gateway call node.list --params "{}"
|
openclaw gateway call node.list --params "{}"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default, the Apple Watch companion keeps using the existing iPhone relay and
|
||||||
|
does not need a separate Gateway pairing. Pair the Watch with the iPhone in
|
||||||
|
Apple's Watch app, install OpenClaw from **Watch app -> My Watch -> Available
|
||||||
|
Apps**, then open OpenClaw once on both devices.
|
||||||
|
|
||||||
|
## Optional direct Apple Watch node
|
||||||
|
|
||||||
|
Direct mode gives the watch its own signed node identity and Gateway connection.
|
||||||
|
Supported node commands continue to work over watch Wi-Fi or cellular while
|
||||||
|
OpenClaw is active, even when the paired iPhone is unavailable.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- The iPhone is connected to the Gateway with `operator.admin` scope.
|
||||||
|
- The setup code advertises a `wss://` Gateway endpoint with a certificate trusted
|
||||||
|
by watchOS; the watch polls the corresponding `https://` origin. Plain HTTP and
|
||||||
|
self-signed or fingerprint-only trust are unsupported. See [Gateway-owned
|
||||||
|
pairing](/gateway/pairing) for endpoint configuration. Loopback, iPhone-only,
|
||||||
|
and tailnet-only routes are not independently reachable by the watch.
|
||||||
|
- Cellular use requires a cellular-capable Apple Watch with active service.
|
||||||
|
- OpenClaw is active on the watch. Apple does not allow ordinary watchOS apps to
|
||||||
|
keep generic WebSocket/TCP connections, so the direct node uses short HTTPS
|
||||||
|
polls and reconnects when the app returns to the foreground. See Apple's
|
||||||
|
[watchOS low-level networking guidance](https://developer.apple.com/documentation/technotes/tn3135-low-level-networking-on-watchOS).
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
1. On iPhone, open **Settings -> Apple Watch**.
|
||||||
|
2. Tap **Enable Direct Gateway Connection**.
|
||||||
|
3. Open OpenClaw on the watch before the short-lived setup code expires.
|
||||||
|
4. Verify the separate Apple Watch row with `openclaw nodes status`.
|
||||||
|
|
||||||
|
The setup code contains a short-lived, node-only bootstrap credential; treat it
|
||||||
|
like a password until it expires. It never contains the iPhone's saved Gateway
|
||||||
|
password or token. After pairing, the watch stores its own device token and
|
||||||
|
deletes the bootstrap credential. Direct mode covers only the commands below.
|
||||||
|
Chat, Talk, approvals, and the existing `watch.*` notification flow remain
|
||||||
|
iPhone-relay features and still require the paired iPhone.
|
||||||
|
|
||||||
|
Direct watchOS node commands:
|
||||||
|
|
||||||
|
| Surface | Commands | Notes |
|
||||||
|
| ------------- | ------------------------------ | ------------------------------------------------------- |
|
||||||
|
| Device | `device.info`, `device.status` | Watch identity, battery, thermal, storage, and network. |
|
||||||
|
| Notifications | `system.notify` | While the app is active; requires watch permission. |
|
||||||
|
|
||||||
|
watchOS does not expose WebKit to third-party apps, so the direct watch node
|
||||||
|
does not advertise Canvas commands.
|
||||||
|
|
||||||
## Relay-backed push for official builds
|
## Relay-backed push for official builds
|
||||||
|
|
||||||
Official distributed iOS builds use an external push relay instead of publishing the raw APNs token to the gateway. Official App Store builds from the public release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`; this base URL is hardcoded for App Store distribution and does not read any override.
|
Official distributed iOS builds use an external push relay instead of publishing the raw APNs token to the gateway. Official App Store builds from the public release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`; this base URL is hardcoded for App Store distribution and does not read any override.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const GATEWAY_CLIENT_IDS = {
|
||||||
GATEWAY_CLIENT: "gateway-client",
|
GATEWAY_CLIENT: "gateway-client",
|
||||||
MACOS_APP: "openclaw-macos",
|
MACOS_APP: "openclaw-macos",
|
||||||
IOS_APP: "openclaw-ios",
|
IOS_APP: "openclaw-ios",
|
||||||
|
WATCHOS_APP: "openclaw-watchos",
|
||||||
ANDROID_APP: "openclaw-android",
|
ANDROID_APP: "openclaw-android",
|
||||||
NODE_HOST: "node-host",
|
NODE_HOST: "node-host",
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,15 @@ const SetupCodeQrDataUrlSchema = Type.String({
|
||||||
* a short-lived bootstrap token that hands off broad operator scopes
|
* a short-lived bootstrap token that hands off broad operator scopes
|
||||||
* (read/write/approvals/talk.secrets), so this method requires operator.admin
|
* (read/write/approvals/talk.secrets), so this method requires operator.admin
|
||||||
* (enforced by the core method descriptor's method-scope policy, not the handler)
|
* (enforced by the core method descriptor's method-scope policy, not the handler)
|
||||||
* and is not advertised.
|
* and is not advertised. `bootstrapProfile: "node"` narrows the handoff to a
|
||||||
|
* node role with no operator scopes for companion devices such as watchOS.
|
||||||
*/
|
*/
|
||||||
export const DevicePairSetupCodeParamsSchema = Type.Object(
|
export const DevicePairSetupCodeParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
publicUrl: Type.Optional(NonEmptyString),
|
publicUrl: Type.Optional(NonEmptyString),
|
||||||
preferRemoteUrl: Type.Optional(Type.Boolean()),
|
preferRemoteUrl: Type.Optional(Type.Boolean()),
|
||||||
includeQr: Type.Optional(Type.Boolean()),
|
includeQr: Type.Optional(Type.Boolean()),
|
||||||
|
bootstrapProfile: Type.Optional(Type.Literal("node")),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export function registerNodesNotifyCommand(nodes: Command) {
|
||||||
nodesCallOpts(
|
nodesCallOpts(
|
||||||
nodes
|
nodes
|
||||||
.command("notify")
|
.command("notify")
|
||||||
.description("Send a local notification on a node (mac only)")
|
.description("Send a local notification on a node")
|
||||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||||
.option("--title <text>", "Notification title")
|
.option("--title <text>", "Notification title")
|
||||||
.option("--body <text>", "Notification body")
|
.option("--body <text>", "Notification body")
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ export const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval";
|
||||||
// device signature can queue the bootstrap-pairing flow behind their
|
// device signature can queue the bootstrap-pairing flow behind their
|
||||||
// requests, blocking legitimate node onboarding during the attack.
|
// requests, blocking legitimate node onboarding during the attack.
|
||||||
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
|
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
|
||||||
|
// Public watchOS challenge issuance is throttled separately from credential
|
||||||
|
// failures so challenge floods cannot displace legitimate device handshakes.
|
||||||
|
export const AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE = "watch-challenge";
|
||||||
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
|
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
|
||||||
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
|
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
|
||||||
const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:";
|
const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:";
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,42 @@ describe("gateway/node-command-policy", () => {
|
||||||
expect(macAllowlist.has("system.run")).toBe(false);
|
expect(macAllowlist.has("system.run")).toBe(false);
|
||||||
expect(macAllowlist.has("system.which")).toBe(false);
|
expect(macAllowlist.has("system.which")).toBe(false);
|
||||||
expect(macAllowlist.has("screen.snapshot")).toBe(false);
|
expect(macAllowlist.has("screen.snapshot")).toBe(false);
|
||||||
|
|
||||||
|
const watchAllowlist = resolveNodeCommandAllowlist(cfg, {
|
||||||
|
platform: "watchOS 11.5.0",
|
||||||
|
deviceFamily: "Apple Watch",
|
||||||
|
});
|
||||||
|
expect(watchAllowlist.has("device.info")).toBe(true);
|
||||||
|
expect(watchAllowlist.has("device.status")).toBe(true);
|
||||||
|
expect(watchAllowlist.has("system.notify")).toBe(true);
|
||||||
|
expect(watchAllowlist.has("camera.list")).toBe(false);
|
||||||
|
expect(watchAllowlist.has("system.run")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires matching watchOS platform and device-family metadata", () => {
|
||||||
|
const cfg = {} as OpenClawConfig;
|
||||||
|
const mismatch = resolveNodeCommandAllowlist(cfg, {
|
||||||
|
platform: "watchOS 11.5.0",
|
||||||
|
deviceFamily: "iPhone",
|
||||||
|
});
|
||||||
|
expect(mismatch.has("device.info")).toBe(false);
|
||||||
|
|
||||||
|
const familyOnly = resolveNodeCommandAllowlist(cfg, { deviceFamily: "Apple Watch" });
|
||||||
|
expect(familyOnly.has("device.info")).toBe(true);
|
||||||
|
expect(familyOnly.has("system.run")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps plugin defaults out of the fixed watchOS command surface", () => {
|
||||||
|
installCanvasPluginDefaults();
|
||||||
|
|
||||||
|
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
|
||||||
|
platform: "watchOS 11.5.0",
|
||||||
|
deviceFamily: "Apple Watch",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allowlist.has("device.info")).toBe(true);
|
||||||
|
expect(allowlist.has("canvas.snapshot")).toBe(false);
|
||||||
|
expect(allowlist.has("canvas.present")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps explicitly approved host commands for desktop platforms", () => {
|
it("keeps explicitly approved host commands for desktop platforms", () => {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
||||||
...MOTION_COMMANDS,
|
...MOTION_COMMANDS,
|
||||||
...IOS_SYSTEM_COMMANDS,
|
...IOS_SYSTEM_COMMANDS,
|
||||||
],
|
],
|
||||||
|
watchos: [...DEVICE_COMMANDS, ...IOS_SYSTEM_COMMANDS],
|
||||||
android: [
|
android: [
|
||||||
...CAMERA_COMMANDS,
|
...CAMERA_COMMANDS,
|
||||||
...LOCATION_COMMANDS,
|
...LOCATION_COMMANDS,
|
||||||
|
|
@ -140,10 +141,11 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
||||||
unknown: [...UNKNOWN_PLATFORM_COMMANDS],
|
unknown: [...UNKNOWN_PLATFORM_COMMANDS],
|
||||||
};
|
};
|
||||||
|
|
||||||
type PlatformId = "ios" | "android" | "macos" | "windows" | "linux" | "unknown";
|
type PlatformId = "ios" | "watchos" | "android" | "macos" | "windows" | "linux" | "unknown";
|
||||||
|
|
||||||
const CANONICAL_PLATFORM_IDS = new Set<Exclude<PlatformId, "unknown">>([
|
const CANONICAL_PLATFORM_IDS = new Set<Exclude<PlatformId, "unknown">>([
|
||||||
"ios",
|
"ios",
|
||||||
|
"watchos",
|
||||||
"android",
|
"android",
|
||||||
"macos",
|
"macos",
|
||||||
"windows",
|
"windows",
|
||||||
|
|
@ -155,6 +157,7 @@ const DEVICE_FAMILY_TOKEN_RULES: ReadonlyArray<{
|
||||||
tokens: readonly string[];
|
tokens: readonly string[];
|
||||||
}> = [
|
}> = [
|
||||||
{ id: "ios", tokens: ["iphone", "ipad", "ios"] },
|
{ id: "ios", tokens: ["iphone", "ipad", "ios"] },
|
||||||
|
{ id: "watchos", tokens: ["apple watch", "watchos"] },
|
||||||
{ id: "android", tokens: ["android"] },
|
{ id: "android", tokens: ["android"] },
|
||||||
{ id: "macos", tokens: ["mac"] },
|
{ id: "macos", tokens: ["mac"] },
|
||||||
{ id: "windows", tokens: ["windows"] },
|
{ id: "windows", tokens: ["windows"] },
|
||||||
|
|
@ -175,6 +178,8 @@ function platformMatchesDeviceFamily(
|
||||||
switch (platformId) {
|
switch (platformId) {
|
||||||
case "ios":
|
case "ios":
|
||||||
return family === "" || /^(?:iphone|ipad|ios)$/.test(family);
|
return family === "" || /^(?:iphone|ipad|ios)$/.test(family);
|
||||||
|
case "watchos":
|
||||||
|
return family === "apple watch" || family === "watchos";
|
||||||
case "android":
|
case "android":
|
||||||
return family === "" || family === "android";
|
return family === "" || family === "android";
|
||||||
case "macos":
|
case "macos":
|
||||||
|
|
@ -194,6 +199,9 @@ function resolvePlatformIdByNativeLabel(
|
||||||
if (/^(?:ios|ipados) \d+(?:\.\d+){0,2}$/.test(platform)) {
|
if (/^(?:ios|ipados) \d+(?:\.\d+){0,2}$/.test(platform)) {
|
||||||
return /^(?:iphone|ipad|ios)$/.test(deviceFamily) ? "ios" : undefined;
|
return /^(?:iphone|ipad|ios)$/.test(deviceFamily) ? "ios" : undefined;
|
||||||
}
|
}
|
||||||
|
if (/^watchos \d+(?:\.\d+){0,2}$/.test(platform)) {
|
||||||
|
return /^(?:apple watch|watchos)$/.test(deviceFamily) ? "watchos" : undefined;
|
||||||
|
}
|
||||||
if (/^macos \d+(?:\.\d+){0,2}$/.test(platform)) {
|
if (/^macos \d+(?:\.\d+){0,2}$/.test(platform)) {
|
||||||
return deviceFamily === "mac" ? "macos" : undefined;
|
return deviceFamily === "mac" ? "macos" : undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +257,11 @@ export function listDangerousPluginNodeCommands(): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function listDefaultPluginNodeCommands(platformId: PlatformId): string[] {
|
function listDefaultPluginNodeCommands(platformId: PlatformId): string[] {
|
||||||
|
// The direct watch transport has a fixed, minimal command surface. Do not let
|
||||||
|
// generic plugin defaults silently expand it when plugins are installed.
|
||||||
|
if (platformId === "watchos") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const registry = getActivePluginGatewayNodePolicyRegistry();
|
const registry = getActivePluginGatewayNodePolicyRegistry();
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ type NodeInvokeResult = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Connectivity probe result for a registered node. */
|
/** Connectivity probe result for a registered node. */
|
||||||
type NodeConnectivityResult =
|
export type NodeConnectivityResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; error: { code: string; message: string } };
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
|
@ -98,6 +98,13 @@ export type SerializedEventPayload = {
|
||||||
readonly [SERIALIZED_EVENT_PAYLOAD]: true;
|
readonly [SERIALIZED_EVENT_PAYLOAD]: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Event transport for nodes that cannot keep a WebSocket open, such as watchOS. */
|
||||||
|
export type NodeEventTransport = {
|
||||||
|
send: (event: string, payload: unknown) => boolean;
|
||||||
|
sendRaw: (event: string, payloadJSON?: SerializedEventPayload | null) => boolean;
|
||||||
|
checkConnectivity?: (timeoutMs: number) => Promise<NodeConnectivityResult>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Serialize an event payload once so fanout can reuse the same JSON string. */
|
/** Serialize an event payload once so fanout can reuse the same JSON string. */
|
||||||
export function serializeEventPayload(payload: unknown): SerializedEventPayload | null {
|
export function serializeEventPayload(payload: unknown): SerializedEventPayload | null {
|
||||||
if (payload === undefined) {
|
if (payload === undefined) {
|
||||||
|
|
@ -177,11 +184,29 @@ function withSystemRunEventRunId(params: { command: string; params?: unknown }):
|
||||||
export class NodeRegistry {
|
export class NodeRegistry {
|
||||||
private nodesById = new Map<string, NodeSession>();
|
private nodesById = new Map<string, NodeSession>();
|
||||||
private nodesByConn = new Map<string, string>();
|
private nodesByConn = new Map<string, string>();
|
||||||
|
private eventTransportsByConn = new Map<string, NodeEventTransport>();
|
||||||
private pendingInvokes = new Map<string, PendingInvoke>();
|
private pendingInvokes = new Map<string, PendingInvoke>();
|
||||||
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
|
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
|
||||||
|
|
||||||
/** Register a websocket client as the current connection for its node id. */
|
/** Register a websocket client as the current connection for its node id. */
|
||||||
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
|
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
|
||||||
|
return this.registerSession(client, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a node whose events are delivered by an HTTP polling transport. */
|
||||||
|
registerTransport(
|
||||||
|
client: GatewayWsClient,
|
||||||
|
opts: { remoteIp?: string | undefined },
|
||||||
|
transport: NodeEventTransport,
|
||||||
|
) {
|
||||||
|
return this.registerSession(client, opts, transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerSession(
|
||||||
|
client: GatewayWsClient,
|
||||||
|
opts: { remoteIp?: string | undefined },
|
||||||
|
transport?: NodeEventTransport,
|
||||||
|
) {
|
||||||
const connect = client.connect;
|
const connect = client.connect;
|
||||||
const nodeId = connect.device?.id ?? connect.client.id;
|
const nodeId = connect.device?.id ?? connect.client.id;
|
||||||
const caps = Array.isArray(connect.caps) ? connect.caps : [];
|
const caps = Array.isArray(connect.caps) ? connect.caps : [];
|
||||||
|
|
@ -249,6 +274,11 @@ export class NodeRegistry {
|
||||||
};
|
};
|
||||||
this.nodesById.set(nodeId, session);
|
this.nodesById.set(nodeId, session);
|
||||||
this.nodesByConn.set(client.connId, nodeId);
|
this.nodesByConn.set(client.connId, nodeId);
|
||||||
|
if (transport) {
|
||||||
|
this.eventTransportsByConn.set(client.connId, transport);
|
||||||
|
} else {
|
||||||
|
this.eventTransportsByConn.delete(client.connId);
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,6 +289,7 @@ export class NodeRegistry {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
this.nodesByConn.delete(connId);
|
this.nodesByConn.delete(connId);
|
||||||
|
this.eventTransportsByConn.delete(connId);
|
||||||
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
|
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
|
||||||
if (unregistersCurrentNode) {
|
if (unregistersCurrentNode) {
|
||||||
this.nodesById.delete(nodeId);
|
this.nodesById.delete(nodeId);
|
||||||
|
|
@ -298,6 +329,10 @@ export class NodeRegistry {
|
||||||
error: { code: "NOT_CONNECTED", message: "node not connected" },
|
error: { code: "NOT_CONNECTED", message: "node not connected" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const eventTransport = this.eventTransportsByConn.get(node.connId);
|
||||||
|
if (eventTransport) {
|
||||||
|
return eventTransport.checkConnectivity?.(timeoutMs) ?? { ok: true };
|
||||||
|
}
|
||||||
const socket = node.client.socket as PingableSocket;
|
const socket = node.client.socket as PingableSocket;
|
||||||
if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) {
|
if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -707,6 +742,10 @@ export class NodeRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean {
|
private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean {
|
||||||
|
const eventTransport = this.eventTransportsByConn.get(node.connId);
|
||||||
|
if (eventTransport) {
|
||||||
|
return eventTransport.send(event, payload);
|
||||||
|
}
|
||||||
if (this.rejectSlowNodeSocket(node)) {
|
if (this.rejectSlowNodeSocket(node)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -736,6 +775,10 @@ export class NodeRegistry {
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const eventTransport = this.eventTransportsByConn.get(node.connId);
|
||||||
|
if (eventTransport) {
|
||||||
|
return eventTransport.sendRaw(event, payloadJSON);
|
||||||
|
}
|
||||||
if (this.rejectSlowNodeSocket(node)) {
|
if (this.rejectSlowNodeSocket(node)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ type PluginHttpRequestHandler = (
|
||||||
},
|
},
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
type WatchNodeHttpRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||||
|
|
||||||
type PluginHttpUpgradeHandler = (
|
type PluginHttpUpgradeHandler = (
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
socket: import("node:stream").Duplex,
|
socket: import("node:stream").Duplex,
|
||||||
|
|
@ -442,6 +444,7 @@ export function createGatewayHttpServer(opts: {
|
||||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
strictTransportSecurityHeader?: string;
|
strictTransportSecurityHeader?: string;
|
||||||
handleHooksRequest: HooksRequestHandler;
|
handleHooksRequest: HooksRequestHandler;
|
||||||
|
handleWatchNodeRequest?: WatchNodeHttpRequestHandler;
|
||||||
handlePluginRequest?: PluginHttpRequestHandler;
|
handlePluginRequest?: PluginHttpRequestHandler;
|
||||||
handlePluginUpgrade?: PluginHttpUpgradeHandler;
|
handlePluginUpgrade?: PluginHttpUpgradeHandler;
|
||||||
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
||||||
|
|
@ -556,6 +559,12 @@ export function createGatewayHttpServer(opts: {
|
||||||
run: () => handleHooksRequest(req, res),
|
run: () => handleHooksRequest(req, res),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
if (opts.handleWatchNodeRequest && scopedRequestPath.startsWith("/api/nodes/watch/")) {
|
||||||
|
requestStages.push({
|
||||||
|
name: "watch-node",
|
||||||
|
run: () => opts.handleWatchNodeRequest?.(req, res) ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) {
|
if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) {
|
||||||
requestStages.push({
|
requestStages.push({
|
||||||
name: "models",
|
name: "models",
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,21 @@ describe("device.pair.setupCode", () => {
|
||||||
expect(payload.setupCode).toBe("SETUP-CODE-XYZ");
|
expect(payload.setupCode).toBe("SETUP-CODE-XYZ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests a node-only bootstrap profile for companion setup", async () => {
|
||||||
|
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
|
||||||
|
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
||||||
|
|
||||||
|
const { options } = createOptions({ includeQr: false, bootstrapProfile: "node" });
|
||||||
|
await devicePairSetupHandlers["device.pair.setupCode"](options);
|
||||||
|
|
||||||
|
expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({
|
||||||
|
bootstrapProfile: { roles: ["node"], scopes: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("omits an oversized QR but still returns the setup code", async () => {
|
it("omits an oversized QR but still returns the setup code", async () => {
|
||||||
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
|
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
|
||||||
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||||
import { renderQrPngDataUrl } from "../../media/qr-image.js";
|
import { renderQrPngDataUrl } from "../../media/qr-image.js";
|
||||||
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../../pairing/setup-code.js";
|
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../../pairing/setup-code.js";
|
||||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||||
|
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../../shared/device-bootstrap-profile.js";
|
||||||
import { formatForLog } from "../ws-log.js";
|
import { formatForLog } from "../ws-log.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
import { assertValidParams } from "./validation.js";
|
import { assertValidParams } from "./validation.js";
|
||||||
|
|
@ -49,6 +50,9 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
preferRemoteUrl: params.preferRemoteUrl === true,
|
preferRemoteUrl: params.preferRemoteUrl === true,
|
||||||
|
...(params.bootstrapProfile === "node"
|
||||||
|
? { bootstrapProfile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE }
|
||||||
|
: {}),
|
||||||
// Lets Tailscale serve/funnel URLs resolve, mirroring the `openclaw qr` CLI.
|
// Lets Tailscale serve/funnel URLs resolve, mirroring the `openclaw qr` CLI.
|
||||||
runCommandWithTimeout: async (argv, runOpts) =>
|
runCommandWithTimeout: async (argv, runOpts) =>
|
||||||
await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs }),
|
await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs }),
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,11 @@ describe("createGatewayRequestContext", () => {
|
||||||
socket: { close: vi.fn() },
|
socket: { close: vi.fn() },
|
||||||
};
|
};
|
||||||
const clients = new Set([target, unrelated]) as never;
|
const clients = new Set([target, unrelated]) as never;
|
||||||
|
const invalidateDeviceTransports = vi.fn();
|
||||||
|
|
||||||
const context = createGatewayRequestContext(makeContextParams({ clients }));
|
const context = createGatewayRequestContext(
|
||||||
|
makeContextParams({ clients, invalidateDeviceTransports }),
|
||||||
|
);
|
||||||
context.invalidateClientsForDevice?.("device-1", { reason: "device-token-rotated" });
|
context.invalidateClientsForDevice?.("device-1", { reason: "device-token-rotated" });
|
||||||
|
|
||||||
expect((target as { invalidated?: boolean }).invalidated).toBe(true);
|
expect((target as { invalidated?: boolean }).invalidated).toBe(true);
|
||||||
|
|
@ -164,6 +167,9 @@ describe("createGatewayRequestContext", () => {
|
||||||
|
|
||||||
expect((unrelated as { invalidated?: boolean }).invalidated).toBeUndefined();
|
expect((unrelated as { invalidated?: boolean }).invalidated).toBeUndefined();
|
||||||
expect(unrelated.socket.close).not.toHaveBeenCalled();
|
expect(unrelated.socket.close).not.toHaveBeenCalled();
|
||||||
|
expect(invalidateDeviceTransports).toHaveBeenCalledWith("device-1", {
|
||||||
|
reason: "device-token-rotated",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnectClientsForDevice also marks the invalidated flag before closing", () => {
|
it("disconnectClientsForDevice also marks the invalidated flag before closing", () => {
|
||||||
|
|
@ -173,13 +179,17 @@ describe("createGatewayRequestContext", () => {
|
||||||
socket: { close: vi.fn() },
|
socket: { close: vi.fn() },
|
||||||
};
|
};
|
||||||
const clients = new Set([target]) as never;
|
const clients = new Set([target]) as never;
|
||||||
|
const disconnectDeviceTransports = vi.fn();
|
||||||
|
|
||||||
const context = createGatewayRequestContext(makeContextParams({ clients }));
|
const context = createGatewayRequestContext(
|
||||||
|
makeContextParams({ clients, disconnectDeviceTransports }),
|
||||||
|
);
|
||||||
context.disconnectClientsForDevice?.("device-1");
|
context.disconnectClientsForDevice?.("device-1");
|
||||||
|
|
||||||
expect((target as { invalidated?: boolean }).invalidated).toBe(true);
|
expect((target as { invalidated?: boolean }).invalidated).toBe(true);
|
||||||
expect((target as { invalidatedReason?: string }).invalidatedReason).toBe("device-removed");
|
expect((target as { invalidatedReason?: string }).invalidatedReason).toBe("device-removed");
|
||||||
expect(target.socket.close).toHaveBeenCalledWith(4001, "device removed");
|
expect(target.socket.close).toHaveBeenCalledWith(4001, "device removed");
|
||||||
|
expect(disconnectDeviceTransports).toHaveBeenCalledWith("device-1", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalidateClientsForDevice filters by role when provided", () => {
|
it("invalidateClientsForDevice filters by role when provided", () => {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ export type GatewayRequestContextParams = {
|
||||||
nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"];
|
nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"];
|
||||||
hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"];
|
hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"];
|
||||||
clients: Set<GatewayRequestContextClient>;
|
clients: Set<GatewayRequestContextClient>;
|
||||||
|
invalidateDeviceTransports?: (
|
||||||
|
deviceId: string,
|
||||||
|
opts?: { role?: string; reason?: string },
|
||||||
|
) => void;
|
||||||
|
disconnectDeviceTransports?: (deviceId: string, opts?: { role?: string }) => void;
|
||||||
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void;
|
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void;
|
||||||
nodeRegistry: GatewayRequestContext["nodeRegistry"];
|
nodeRegistry: GatewayRequestContext["nodeRegistry"];
|
||||||
terminalSessions?: GatewayRequestContext["terminalSessions"];
|
terminalSessions?: GatewayRequestContext["terminalSessions"];
|
||||||
|
|
@ -165,6 +170,7 @@ export function createGatewayRequestContext(
|
||||||
gatewayClient.invalidated = true;
|
gatewayClient.invalidated = true;
|
||||||
gatewayClient.invalidatedReason = reason;
|
gatewayClient.invalidatedReason = reason;
|
||||||
}
|
}
|
||||||
|
params.invalidateDeviceTransports?.(deviceId, opts);
|
||||||
},
|
},
|
||||||
disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => {
|
disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => {
|
||||||
for (const gatewayClient of params.clients) {
|
for (const gatewayClient of params.clients) {
|
||||||
|
|
@ -185,6 +191,7 @@ export function createGatewayRequestContext(
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
params.disconnectDeviceTransports?.(deviceId, opts);
|
||||||
},
|
},
|
||||||
disconnectClientsUsingSharedGatewayAuth: () => {
|
disconnectClientsUsingSharedGatewayAuth: () => {
|
||||||
disconnectAllSharedGatewayAuthClients(params.clients);
|
disconnectAllSharedGatewayAuthClients(params.clients);
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ export async function createGatewayRuntimeState(params: {
|
||||||
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
||||||
getReadiness?: ReadinessChecker;
|
getReadiness?: ReadinessChecker;
|
||||||
isTerminalEnabled: () => boolean;
|
isTerminalEnabled: () => boolean;
|
||||||
|
handleWatchNodeRequest?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
releasePluginRouteRegistry: () => void;
|
releasePluginRouteRegistry: () => void;
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
|
|
@ -261,6 +262,7 @@ export async function createGatewayRuntimeState(params: {
|
||||||
openResponsesEnabled: params.openResponsesEnabled,
|
openResponsesEnabled: params.openResponsesEnabled,
|
||||||
openResponsesConfig: params.openResponsesConfig,
|
openResponsesConfig: params.openResponsesConfig,
|
||||||
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
|
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
|
||||||
|
handleWatchNodeRequest: params.handleWatchNodeRequest,
|
||||||
handleHooksRequest,
|
handleHooksRequest,
|
||||||
handlePluginRequest,
|
handlePluginRequest,
|
||||||
shouldEnforcePluginGatewayAuth,
|
shouldEnforcePluginGatewayAuth,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
// Gateway server implementation builds runtime state, method registries, HTTP
|
// Gateway server implementation builds runtime state, method registries, HTTP
|
||||||
// and WebSocket surfaces, config reload hooks, and graceful restart/shutdown.
|
// and WebSocket surfaces, config reload hooks, and graceful restart/shutdown.
|
||||||
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
||||||
|
|
@ -40,6 +41,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||||
import { readGatewayRestartHandoffSync } from "../infra/restart-handoff.js";
|
import { readGatewayRestartHandoffSync } from "../infra/restart-handoff.js";
|
||||||
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
|
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
|
import { upsertPresence } from "../infra/system-presence.js";
|
||||||
import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js";
|
import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js";
|
||||||
import { withDiagnosticPhase } from "../logging/diagnostic-phase.js";
|
import { withDiagnosticPhase } from "../logging/diagnostic-phase.js";
|
||||||
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
|
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
|
||||||
|
|
@ -62,6 +64,7 @@ import {
|
||||||
} from "../secrets/runtime-state.js";
|
} from "../secrets/runtime-state.js";
|
||||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||||
import { createLazyPromise } from "../shared/lazy-runtime.js";
|
import { createLazyPromise } from "../shared/lazy-runtime.js";
|
||||||
|
import { recordRemoteNodeInfo, removeRemoteNodeInfo } from "../skills/runtime/remote.js";
|
||||||
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import { resolveGatewayAuth } from "./auth.js";
|
import { resolveGatewayAuth } from "./auth.js";
|
||||||
import type { RestartRecoveryCandidate } from "./chat-abort.js";
|
import type { RestartRecoveryCandidate } from "./chat-abort.js";
|
||||||
|
|
@ -99,6 +102,7 @@ import { createLazyGatewayCronState } from "./server-cron-lazy.js";
|
||||||
import { applyGatewayLaneConcurrency } from "./server-lanes.js";
|
import { applyGatewayLaneConcurrency } from "./server-lanes.js";
|
||||||
import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js";
|
import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js";
|
||||||
import { GATEWAY_EVENTS } from "./server-methods-list.js";
|
import { GATEWAY_EVENTS } from "./server-methods-list.js";
|
||||||
|
import { clearNodeWakeState } from "./server-methods/nodes-wake-state.js";
|
||||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./server-methods/types.js";
|
import type { GatewayRequestContext, GatewayRequestHandlers } from "./server-methods/types.js";
|
||||||
import { setFallbackGatewayContextResolver } from "./server-plugins.js";
|
import { setFallbackGatewayContextResolver } from "./server-plugins.js";
|
||||||
import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
|
import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
|
||||||
|
|
@ -119,6 +123,7 @@ import {
|
||||||
refreshGatewayHealthSnapshot,
|
refreshGatewayHealthSnapshot,
|
||||||
} from "./server/health-state.js";
|
} from "./server/health-state.js";
|
||||||
import { resolveHookClientIpConfig } from "./server/hook-client-ip-config.js";
|
import { resolveHookClientIpConfig } from "./server/hook-client-ip-config.js";
|
||||||
|
import { broadcastPresenceSnapshot } from "./server/presence-events.js";
|
||||||
import { createReadinessChecker } from "./server/readiness.js";
|
import { createReadinessChecker } from "./server/readiness.js";
|
||||||
import { loadGatewayTlsRuntime } from "./server/tls.js";
|
import { loadGatewayTlsRuntime } from "./server/tls.js";
|
||||||
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
|
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
|
||||||
|
|
@ -880,6 +885,9 @@ export async function startGatewayServer(
|
||||||
});
|
});
|
||||||
log.info("starting HTTP server...");
|
log.info("starting HTTP server...");
|
||||||
let currentPluginRegistryGatewayContext: GatewayRequestContext | undefined;
|
let currentPluginRegistryGatewayContext: GatewayRequestContext | undefined;
|
||||||
|
const watchNodeRequestHandler: {
|
||||||
|
current?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||||
|
} = {};
|
||||||
const {
|
const {
|
||||||
releasePluginRouteRegistry,
|
releasePluginRouteRegistry,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
|
@ -931,6 +939,8 @@ export async function startGatewayServer(
|
||||||
logHooks,
|
logHooks,
|
||||||
logPlugins,
|
logPlugins,
|
||||||
getReadiness,
|
getReadiness,
|
||||||
|
handleWatchNodeRequest: async (req, res) =>
|
||||||
|
(await watchNodeRequestHandler.current?.(req, res)) ?? false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const restartRecoveryCandidates = new Map<string, RestartRecoveryCandidate>();
|
const restartRecoveryCandidates = new Map<string, RestartRecoveryCandidate>();
|
||||||
|
|
@ -948,6 +958,48 @@ export async function startGatewayServer(
|
||||||
broadcastVoiceWakeChanged,
|
broadcastVoiceWakeChanged,
|
||||||
hasTalkNodeConnected,
|
hasTalkNodeConnected,
|
||||||
} = createGatewayNodeSessionRuntime({ broadcast });
|
} = createGatewayNodeSessionRuntime({ broadcast });
|
||||||
|
const { createWatchNodeHttpRuntime } = await import("./watch-node-http.js");
|
||||||
|
const watchNodeHttpRuntime = createWatchNodeHttpRuntime({
|
||||||
|
nodeRegistry,
|
||||||
|
getConfig: getRuntimeConfig,
|
||||||
|
broadcast,
|
||||||
|
rateLimiter: authRateLimiter,
|
||||||
|
nodeReapprovalCoordinator,
|
||||||
|
onNodeConnected: (session) => {
|
||||||
|
upsertPresence(session.nodeId, {
|
||||||
|
host: session.displayName ?? session.clientId ?? session.nodeId,
|
||||||
|
ip: session.remoteIp,
|
||||||
|
version: session.version,
|
||||||
|
platform: session.platform,
|
||||||
|
deviceFamily: session.deviceFamily,
|
||||||
|
modelIdentifier: session.modelIdentifier,
|
||||||
|
mode: session.clientMode,
|
||||||
|
deviceId: session.nodeId,
|
||||||
|
roles: ["node"],
|
||||||
|
scopes: [],
|
||||||
|
instanceId: session.nodeId,
|
||||||
|
reason: "connect",
|
||||||
|
});
|
||||||
|
incrementPresenceVersion();
|
||||||
|
recordRemoteNodeInfo({
|
||||||
|
nodeId: session.nodeId,
|
||||||
|
displayName: session.displayName,
|
||||||
|
platform: session.platform,
|
||||||
|
deviceFamily: session.deviceFamily,
|
||||||
|
commands: session.commands,
|
||||||
|
remoteIp: session.remoteIp,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onNodeDisconnected: (nodeId) => {
|
||||||
|
upsertPresence(nodeId, { reason: "disconnect" });
|
||||||
|
broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
|
||||||
|
removeRemoteNodeInfo(nodeId);
|
||||||
|
nodeUnsubscribeAll(nodeId);
|
||||||
|
clearNodeWakeState(nodeId);
|
||||||
|
},
|
||||||
|
onError: (message, error) => log.warn(`${message}: ${String(error)}`),
|
||||||
|
});
|
||||||
|
watchNodeRequestHandler.current = watchNodeHttpRuntime.handleRequest;
|
||||||
const { TerminalSessionManager, DEFAULT_TERMINAL_DETACH_SECONDS } =
|
const { TerminalSessionManager, DEFAULT_TERMINAL_DETACH_SECONDS } =
|
||||||
await import("./terminal/session-manager.js");
|
await import("./terminal/session-manager.js");
|
||||||
// One PTY store per gateway. Emits each session's bytes only to the owning
|
// One PTY store per gateway. Emits each session's bytes only to the owning
|
||||||
|
|
@ -994,6 +1046,7 @@ export async function startGatewayServer(
|
||||||
};
|
};
|
||||||
const runClosePrelude = async () => {
|
const runClosePrelude = async () => {
|
||||||
markClosePreludeStarted();
|
markClosePreludeStarted();
|
||||||
|
watchNodeHttpRuntime.close();
|
||||||
clearPluginMetadataLifecycleCaches();
|
clearPluginMetadataLifecycleCaches();
|
||||||
const { runGatewayClosePrelude } = await loadGatewayCloseModule();
|
const { runGatewayClosePrelude } = await loadGatewayCloseModule();
|
||||||
await runGatewayClosePrelude({
|
await runGatewayClosePrelude({
|
||||||
|
|
@ -1466,6 +1519,8 @@ export async function startGatewayServer(
|
||||||
nodeUnsubscribeAll,
|
nodeUnsubscribeAll,
|
||||||
hasConnectedTalkNode: hasTalkNodeConnected,
|
hasConnectedTalkNode: hasTalkNodeConnected,
|
||||||
clients,
|
clients,
|
||||||
|
invalidateDeviceTransports: watchNodeHttpRuntime.invalidateSessionsForDevice,
|
||||||
|
disconnectDeviceTransports: watchNodeHttpRuntime.disconnectSessionsForDevice,
|
||||||
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => {
|
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => {
|
||||||
enforceSharedGatewaySessionGenerationForConfigWrite({
|
enforceSharedGatewaySessionGenerationForConfigWrite({
|
||||||
state: sharedGatewaySessionGenerationState,
|
state: sharedGatewaySessionGenerationState,
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,15 @@ function makeConnectParams(clientId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("connect params client id validation", () => {
|
describe("connect params client id validation", () => {
|
||||||
test.each([GATEWAY_CLIENT_IDS.IOS_APP, GATEWAY_CLIENT_IDS.ANDROID_APP])(
|
test.each([
|
||||||
"accepts %s as a valid gateway client id",
|
GATEWAY_CLIENT_IDS.IOS_APP,
|
||||||
(clientId) => {
|
GATEWAY_CLIENT_IDS.WATCHOS_APP,
|
||||||
const ok = validateConnectParams(makeConnectParams(clientId));
|
GATEWAY_CLIENT_IDS.ANDROID_APP,
|
||||||
expect(ok).toBe(true);
|
])("accepts %s as a valid gateway client id", (clientId) => {
|
||||||
expect(validateConnectParams.errors ?? []).toHaveLength(0);
|
const ok = validateConnectParams(makeConnectParams(clientId));
|
||||||
},
|
expect(ok).toBe(true);
|
||||||
);
|
expect(validateConnectParams.errors ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects unknown client ids", () => {
|
test("rejects unknown client ids", () => {
|
||||||
const ok = validateConnectParams(makeConnectParams("openclaw-mobile"));
|
const ok = validateConnectParams(makeConnectParams("openclaw-mobile"));
|
||||||
|
|
|
||||||
579
src/gateway/watch-node-http.test.ts
Normal file
579
src/gateway/watch-node-http.test.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
GATEWAY_CLIENT_IDS,
|
||||||
|
GATEWAY_CLIENT_MODES,
|
||||||
|
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||||
|
import { PROTOCOL_VERSION, type ConnectParams } from "../../packages/gateway-protocol/src/index.js";
|
||||||
|
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||||
|
import {
|
||||||
|
loadOrCreateDeviceIdentity,
|
||||||
|
publicKeyRawBase64UrlFromPem,
|
||||||
|
signDevicePayload,
|
||||||
|
} from "../infra/device-identity.js";
|
||||||
|
import { revokeDeviceToken } from "../infra/device-pairing.js";
|
||||||
|
import { listNodePairing } from "../infra/node-pairing.js";
|
||||||
|
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||||
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||||
|
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
|
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
|
||||||
|
import { NodeRegistry, serializeEventPayload } from "./node-registry.js";
|
||||||
|
import { createWatchNodeHttpRuntime, testing } from "./watch-node-http.js";
|
||||||
|
|
||||||
|
const tempDirs = createTrackedTempDirs();
|
||||||
|
const servers: Server[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const server of servers.splice(0)) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tempDirs.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeConnectParams(params: {
|
||||||
|
identity: ReturnType<typeof loadOrCreateDeviceIdentity>;
|
||||||
|
nonce: string;
|
||||||
|
bootstrapToken?: string;
|
||||||
|
deviceToken?: string;
|
||||||
|
}): ConnectParams {
|
||||||
|
const publicKey = publicKeyRawBase64UrlFromPem(params.identity.publicKeyPem);
|
||||||
|
const auth = params.deviceToken
|
||||||
|
? { deviceToken: params.deviceToken }
|
||||||
|
: { bootstrapToken: params.bootstrapToken };
|
||||||
|
const signedAt = Date.now();
|
||||||
|
const client = {
|
||||||
|
id: GATEWAY_CLIENT_IDS.WATCHOS_APP,
|
||||||
|
displayName: "Test Watch",
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "watchOS 11.5.0",
|
||||||
|
deviceFamily: "Apple Watch",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||||
|
instanceId: "watch-test",
|
||||||
|
} as const;
|
||||||
|
const scopes: string[] = [];
|
||||||
|
const signaturePayload = buildDeviceAuthPayloadV3({
|
||||||
|
deviceId: params.identity.deviceId,
|
||||||
|
clientId: client.id,
|
||||||
|
clientMode: client.mode,
|
||||||
|
role: "node",
|
||||||
|
scopes,
|
||||||
|
signedAtMs: signedAt,
|
||||||
|
token: params.deviceToken ?? params.bootstrapToken ?? null,
|
||||||
|
nonce: params.nonce,
|
||||||
|
platform: client.platform,
|
||||||
|
deviceFamily: client.deviceFamily,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
minProtocol: PROTOCOL_VERSION,
|
||||||
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
|
client,
|
||||||
|
caps: [],
|
||||||
|
commands: ["device.info", "device.status", "system.notify"],
|
||||||
|
permissions: { notifications: true },
|
||||||
|
role: "node",
|
||||||
|
scopes,
|
||||||
|
auth,
|
||||||
|
device: {
|
||||||
|
id: params.identity.deviceId,
|
||||||
|
publicKey,
|
||||||
|
signature: signDevicePayload(params.identity.privateKeyPem, signaturePayload),
|
||||||
|
signedAt,
|
||||||
|
nonce: params.nonce,
|
||||||
|
},
|
||||||
|
} as ConnectParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRuntime(
|
||||||
|
baseDir: string,
|
||||||
|
options?: { rateLimiter?: AuthRateLimiter; abortConnectResponse?: boolean },
|
||||||
|
) {
|
||||||
|
const nodeRegistry = new NodeRegistry();
|
||||||
|
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
||||||
|
const connectedNodes: string[] = [];
|
||||||
|
const disconnectedNodes: Array<{ nodeId: string; reason: string }> = [];
|
||||||
|
const runtime = createWatchNodeHttpRuntime({
|
||||||
|
nodeRegistry,
|
||||||
|
getConfig: () => ({}),
|
||||||
|
pairingBaseDir: baseDir,
|
||||||
|
broadcast: (event, payload) => broadcasts.push({ event, payload }),
|
||||||
|
onNodeConnected: (session) => connectedNodes.push(session.nodeId),
|
||||||
|
onNodeDisconnected: (nodeId, reason) => disconnectedNodes.push({ nodeId, reason }),
|
||||||
|
...(options?.rateLimiter ? { rateLimiter: options.rateLimiter } : {}),
|
||||||
|
});
|
||||||
|
let resolveConnectHandled: () => void = () => undefined;
|
||||||
|
const connectHandled = new Promise<void>((resolve) => {
|
||||||
|
resolveConnectHandled = resolve;
|
||||||
|
});
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const isConnect = req.url === "/api/nodes/watch/connect";
|
||||||
|
if (isConnect && options?.abortConnectResponse) {
|
||||||
|
res.end = (() => {
|
||||||
|
res.destroy();
|
||||||
|
return res;
|
||||||
|
}) as typeof res.end;
|
||||||
|
}
|
||||||
|
void runtime
|
||||||
|
.handleRequest(req, res)
|
||||||
|
.then((handled) => {
|
||||||
|
if (!handled && !res.writableEnded) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isConnect) {
|
||||||
|
resolveConnectHandled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("expected TCP server address");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nodeRegistry,
|
||||||
|
broadcasts,
|
||||||
|
connectedNodes,
|
||||||
|
disconnectedNodes,
|
||||||
|
runtime,
|
||||||
|
connectHandled,
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}/api/nodes/watch`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
||||||
|
return (await response.json()) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Promise<void> {
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const paired = (await listNodePairing(baseDir)).paired.find((entry) => entry.nodeId === nodeId);
|
||||||
|
expect(paired?.lastConnectedAtMs).toEqual(expect.any(Number));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("watch node HTTP transport", () => {
|
||||||
|
it("accepts only the canonical bounded watch surface", async () => {
|
||||||
|
const baseDir = await tempDirs.make("openclaw-watch-node-surface-");
|
||||||
|
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
|
||||||
|
const bounded = makeConnectParams({ identity, nonce: "nonce", bootstrapToken: "token" });
|
||||||
|
expect(testing.isCanonicalWatchNode(bounded)).toBe(true);
|
||||||
|
expect(
|
||||||
|
testing.isCanonicalWatchNode({
|
||||||
|
...bounded,
|
||||||
|
permissions: { notifications: false },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
testing.isCanonicalWatchNode({
|
||||||
|
...bounded,
|
||||||
|
commands: [...(bounded.commands ?? []), "system.run"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(testing.isCanonicalWatchNode({ ...bounded, caps: ["canvas"] })).toBe(false);
|
||||||
|
expect(
|
||||||
|
testing.isCanonicalWatchNode({
|
||||||
|
...bounded,
|
||||||
|
commands: [...(bounded.commands ?? []), "canvas.present"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
testing.isCanonicalWatchNode({
|
||||||
|
...bounded,
|
||||||
|
permissions: { ...bounded.permissions, canvas: true },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
testing.isCanonicalWatchNode({
|
||||||
|
...bounded,
|
||||||
|
client: { ...bounded.client, deviceFamily: "iPhone" },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 3, maxProtocol: 3 })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 4, maxProtocol: 3 })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const challenges = testing.createChallengeStore();
|
||||||
|
const legitimate = challenges.issue("legitimate-client", 1_000);
|
||||||
|
for (let index = 0; index < 32; index += 1) {
|
||||||
|
challenges.issue("attacker", 1_000 + index);
|
||||||
|
}
|
||||||
|
expect(challenges.consume(legitimate.nonce, "legitimate-client", 2_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an authenticated disconnect and emits one lifecycle teardown", async () => {
|
||||||
|
const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-");
|
||||||
|
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
});
|
||||||
|
const { nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } =
|
||||||
|
await startRuntime(baseDir);
|
||||||
|
|
||||||
|
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
|
||||||
|
const connectResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity,
|
||||||
|
nonce: String(challenge.nonce),
|
||||||
|
bootstrapToken: issued.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(connectResponse.status).toBe(200);
|
||||||
|
const connected = await readJson(connectResponse);
|
||||||
|
const sessionToken = String(connected.sessionToken);
|
||||||
|
expect(connectedNodes).toEqual([identity.deviceId]);
|
||||||
|
|
||||||
|
const unauthenticated = await fetch(`${baseUrl}/disconnect`, { method: "POST" });
|
||||||
|
expect(unauthenticated.status).toBe(401);
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)).toBeDefined();
|
||||||
|
expect(disconnectedNodes).toEqual([]);
|
||||||
|
|
||||||
|
const wrongMethod = await fetch(`${baseUrl}/disconnect`, {
|
||||||
|
headers: { authorization: `Bearer ${sessionToken}` },
|
||||||
|
});
|
||||||
|
expect(wrongMethod.status).toBe(405);
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)).toBeDefined();
|
||||||
|
|
||||||
|
const disconnectResponse = await fetch(`${baseUrl}/disconnect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${sessionToken}` },
|
||||||
|
});
|
||||||
|
expect(disconnectResponse.status).toBe(200);
|
||||||
|
await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true });
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
|
||||||
|
expect(disconnectedNodes).toEqual([
|
||||||
|
{ nodeId: identity.deviceId, reason: "watch disconnected" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const repeatedDisconnect = await fetch(`${baseUrl}/disconnect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${sessionToken}` },
|
||||||
|
});
|
||||||
|
expect(repeatedDisconnect.status).toBe(401);
|
||||||
|
runtime.close();
|
||||||
|
expect(disconnectedNodes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty shadow credentials without consuming the challenge", async () => {
|
||||||
|
const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-");
|
||||||
|
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
});
|
||||||
|
const { baseUrl, runtime } = await startRuntime(baseDir);
|
||||||
|
|
||||||
|
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
|
||||||
|
const connect = makeConnectParams({
|
||||||
|
identity,
|
||||||
|
nonce: String(challenge.nonce),
|
||||||
|
bootstrapToken: issued.token,
|
||||||
|
});
|
||||||
|
const shadowedResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...connect,
|
||||||
|
auth: { ...connect.auth, token: "" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(shadowedResponse.status).toBe(401);
|
||||||
|
|
||||||
|
const connectResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(connect),
|
||||||
|
});
|
||||||
|
expect(connectResponse.status).toBe(200);
|
||||||
|
await readJson(connectResponse);
|
||||||
|
await waitForLastConnectedMetadata(baseDir, identity.deviceId);
|
||||||
|
runtime.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps challenge throttling after abort and resets it after completion", async () => {
|
||||||
|
const limiterConfig = {
|
||||||
|
maxAttempts: 1,
|
||||||
|
windowMs: 60_000,
|
||||||
|
lockoutMs: 60_000,
|
||||||
|
exemptLoopback: false,
|
||||||
|
pruneIntervalMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortedBaseDir = await tempDirs.make("openclaw-watch-node-aborted-connect-");
|
||||||
|
const abortedIdentity = loadOrCreateDeviceIdentity(
|
||||||
|
path.join(abortedBaseDir, "watch-identity.json"),
|
||||||
|
);
|
||||||
|
const abortedBootstrap = await issueDeviceBootstrapToken({
|
||||||
|
baseDir: abortedBaseDir,
|
||||||
|
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
});
|
||||||
|
const abortedLimiter = createAuthRateLimiter(limiterConfig);
|
||||||
|
try {
|
||||||
|
const abortedRuntime = await startRuntime(abortedBaseDir, {
|
||||||
|
rateLimiter: abortedLimiter,
|
||||||
|
abortConnectResponse: true,
|
||||||
|
});
|
||||||
|
const challenge = await readJson(await fetch(`${abortedRuntime.baseUrl}/challenge`));
|
||||||
|
await expect(
|
||||||
|
fetch(`${abortedRuntime.baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity: abortedIdentity,
|
||||||
|
nonce: String(challenge.nonce),
|
||||||
|
bootstrapToken: abortedBootstrap.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await abortedRuntime.connectHandled;
|
||||||
|
const stillLimited = await fetch(`${abortedRuntime.baseUrl}/challenge`);
|
||||||
|
expect(stillLimited.status).toBe(429);
|
||||||
|
abortedRuntime.runtime.close();
|
||||||
|
} finally {
|
||||||
|
abortedLimiter.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedBaseDir = await tempDirs.make("openclaw-watch-node-completed-connect-");
|
||||||
|
const completedIdentity = loadOrCreateDeviceIdentity(
|
||||||
|
path.join(completedBaseDir, "watch-identity.json"),
|
||||||
|
);
|
||||||
|
const completedBootstrap = await issueDeviceBootstrapToken({
|
||||||
|
baseDir: completedBaseDir,
|
||||||
|
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
});
|
||||||
|
const completedLimiter = createAuthRateLimiter(limiterConfig);
|
||||||
|
try {
|
||||||
|
const completedRuntime = await startRuntime(completedBaseDir, {
|
||||||
|
rateLimiter: completedLimiter,
|
||||||
|
});
|
||||||
|
const challenge = await readJson(await fetch(`${completedRuntime.baseUrl}/challenge`));
|
||||||
|
const connectResponse = await fetch(`${completedRuntime.baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity: completedIdentity,
|
||||||
|
nonce: String(challenge.nonce),
|
||||||
|
bootstrapToken: completedBootstrap.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(connectResponse.status).toBe(200);
|
||||||
|
await readJson(connectResponse);
|
||||||
|
await completedRuntime.connectHandled;
|
||||||
|
await waitForLastConnectedMetadata(completedBaseDir, completedIdentity.deviceId);
|
||||||
|
const resetAfterCompletion = await fetch(`${completedRuntime.baseUrl}/challenge`);
|
||||||
|
expect(resetAfterCompletion.status).toBe(200);
|
||||||
|
completedRuntime.runtime.close();
|
||||||
|
} finally {
|
||||||
|
completedLimiter.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bootstraps, registers, polls an invoke, and accepts its result", async () => {
|
||||||
|
const baseDir = await tempDirs.make("openclaw-watch-node-http-");
|
||||||
|
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
});
|
||||||
|
const { nodeRegistry, broadcasts, connectedNodes, disconnectedNodes, runtime, baseUrl } =
|
||||||
|
await startRuntime(baseDir);
|
||||||
|
|
||||||
|
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
|
||||||
|
const connectResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity,
|
||||||
|
nonce: String(challenge.nonce),
|
||||||
|
bootstrapToken: issued.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(connectResponse.status).toBe(200);
|
||||||
|
const connected = await readJson(connectResponse);
|
||||||
|
expect(connected.sessionToken).toEqual(expect.any(String));
|
||||||
|
expect(connected.deviceToken).toEqual(expect.any(String));
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)?.commands).toEqual([
|
||||||
|
"device.info",
|
||||||
|
"device.status",
|
||||||
|
"system.notify",
|
||||||
|
]);
|
||||||
|
expect(broadcasts.map((entry) => entry.event)).toContain("device.pair.resolved");
|
||||||
|
expect(broadcasts.map((entry) => entry.event)).toContain("node.pair.resolved");
|
||||||
|
expect(connectedNodes).toEqual([identity.deviceId]);
|
||||||
|
|
||||||
|
const reconnectChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
|
||||||
|
const reconnectResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity,
|
||||||
|
nonce: String(reconnectChallenge.nonce),
|
||||||
|
deviceToken: String(connected.deviceToken),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(reconnectResponse.status).toBe(200);
|
||||||
|
const reconnected = await readJson(reconnectResponse);
|
||||||
|
expect(reconnected.deviceToken).toBe(connected.deviceToken);
|
||||||
|
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId]);
|
||||||
|
expect(disconnectedNodes).toEqual([]);
|
||||||
|
const stalePollResponse = await fetch(`${baseUrl}/poll`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${String(connected.sessionToken)}` },
|
||||||
|
});
|
||||||
|
expect(stalePollResponse.status).toBe(401);
|
||||||
|
|
||||||
|
const invoke = nodeRegistry.invoke({
|
||||||
|
nodeId: identity.deviceId,
|
||||||
|
command: "device.info",
|
||||||
|
timeoutMs: 2_000,
|
||||||
|
});
|
||||||
|
const pollResponse = await fetch(`${baseUrl}/poll`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
|
||||||
|
});
|
||||||
|
expect(pollResponse.status).toBe(200);
|
||||||
|
const polled = await readJson(pollResponse);
|
||||||
|
const event = polled.event as { event: string; payload: { id: string } };
|
||||||
|
expect(event.event).toBe("node.invoke.request");
|
||||||
|
|
||||||
|
const resultResponse = await fetch(`${baseUrl}/result`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${String(reconnected.sessionToken)}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: event.payload.id, ok: true, payloadJSON: '{"model":"Watch"}' }),
|
||||||
|
});
|
||||||
|
expect(resultResponse.status).toBe(200);
|
||||||
|
await expect(invoke).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: '{"model":"Watch"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lateResultResponse = await fetch(`${baseUrl}/result`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${String(reconnected.sessionToken)}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: event.payload.id, ok: true }),
|
||||||
|
});
|
||||||
|
expect(lateResultResponse.status).toBe(200);
|
||||||
|
await expect(readJson(lateResultResponse)).resolves.toEqual({ ok: true, ignored: true });
|
||||||
|
await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
|
runtime.invalidateSessionsForDevice(identity.deviceId, {
|
||||||
|
role: "node",
|
||||||
|
reason: "device-token-revoked",
|
||||||
|
});
|
||||||
|
await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "NOT_CONNECTED", message: "device-token-revoked" },
|
||||||
|
});
|
||||||
|
runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" });
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
|
||||||
|
expect(disconnectedNodes).toContainEqual({
|
||||||
|
nodeId: identity.deviceId,
|
||||||
|
reason: "device-token-revoked",
|
||||||
|
});
|
||||||
|
const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
|
||||||
|
});
|
||||||
|
expect(invalidatedPollResponse.status).toBe(401);
|
||||||
|
|
||||||
|
const revoked = await revokeDeviceToken({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: "node",
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
expect(revoked.ok).toBe(true);
|
||||||
|
const replacementBootstrap = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
});
|
||||||
|
const replacementChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
|
||||||
|
const replacementResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity,
|
||||||
|
nonce: String(replacementChallenge.nonce),
|
||||||
|
bootstrapToken: replacementBootstrap.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(replacementResponse.status).toBe(200);
|
||||||
|
const replacement = await readJson(replacementResponse);
|
||||||
|
expect(replacement.deviceToken).toEqual(expect.any(String));
|
||||||
|
expect(replacement.deviceToken).not.toBe(connected.deviceToken);
|
||||||
|
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId, identity.deviceId]);
|
||||||
|
|
||||||
|
const replayChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
|
||||||
|
const replayResponse = await fetch(`${baseUrl}/connect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
makeConnectParams({
|
||||||
|
identity,
|
||||||
|
nonce: String(replayChallenge.nonce),
|
||||||
|
bootstrapToken: replacementBootstrap.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(replayResponse.status).toBe(401);
|
||||||
|
|
||||||
|
const rawPayload = serializeEventPayload({ sequence: 1 });
|
||||||
|
expect(rawPayload).not.toBeNull();
|
||||||
|
expect(nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", rawPayload)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const rawPollResponse = await fetch(`${baseUrl}/poll`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${String(replacement.sessionToken)}` },
|
||||||
|
});
|
||||||
|
expect(rawPollResponse.status).toBe(200);
|
||||||
|
await expect(readJson(rawPollResponse)).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
event: { event: "node.invoke.request", payload: { sequence: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const oversizedPayload = serializeEventPayload({ value: "x".repeat(70 * 1024) });
|
||||||
|
expect(oversizedPayload).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload),
|
||||||
|
).toBe(false);
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
|
||||||
|
expect(disconnectedNodes).toContainEqual({
|
||||||
|
nodeId: identity.deviceId,
|
||||||
|
reason: "event payload too large",
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.close();
|
||||||
|
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
1085
src/gateway/watch-node-http.ts
Normal file
1085
src/gateway/watch-node-http.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -113,6 +113,7 @@ describe("pairing setup code", () => {
|
||||||
url?: string;
|
url?: string;
|
||||||
urls?: string[];
|
urls?: string[];
|
||||||
urlSource?: string;
|
urlSource?: string;
|
||||||
|
bootstrapProfile?: { roles: string[]; scopes: string[] };
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
expect(resolved.ok).toBe(true);
|
expect(resolved.ok).toBe(true);
|
||||||
|
|
@ -123,7 +124,7 @@ describe("pairing setup code", () => {
|
||||||
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
|
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
|
||||||
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({
|
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({
|
||||||
baseDir: undefined,
|
baseDir: undefined,
|
||||||
profile: {
|
profile: params.bootstrapProfile ?? {
|
||||||
roles: ["node", "operator"],
|
roles: ["node", "operator"],
|
||||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||||
},
|
},
|
||||||
|
|
@ -155,6 +156,7 @@ describe("pairing setup code", () => {
|
||||||
url: string;
|
url: string;
|
||||||
urls?: string[];
|
urls?: string[];
|
||||||
urlSource: string;
|
urlSource: string;
|
||||||
|
bootstrapProfile?: { roles: string[]; scopes: string[] };
|
||||||
};
|
};
|
||||||
runCommandWithTimeout?: ReturnType<typeof vi.fn>;
|
runCommandWithTimeout?: ReturnType<typeof vi.fn>;
|
||||||
expectedRunCommandCalls?: number;
|
expectedRunCommandCalls?: number;
|
||||||
|
|
@ -261,6 +263,23 @@ describe("pairing setup code", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("issues a node-only bootstrap profile for companion setup", async () => {
|
||||||
|
await expectResolvedSetupSuccessCase({
|
||||||
|
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
|
||||||
|
options: {
|
||||||
|
forceSecure: true,
|
||||||
|
publicUrl: "gateway.example.test:18789/setup",
|
||||||
|
bootstrapProfile: { roles: ["node"], scopes: [] },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
authLabel: "token",
|
||||||
|
url: "wss://gateway.example.test:18789",
|
||||||
|
urlSource: "plugins.entries.device-pair.config.publicUrl",
|
||||||
|
bootstrapProfile: { roles: ["node"], scopes: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
|
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
|
||||||
await expectResolvedSetupFailureCase({
|
await expectResolvedSetupFailureCase({
|
||||||
config: {
|
config: {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ import {
|
||||||
pickMatchingExternalInterfaceAddress,
|
pickMatchingExternalInterfaceAddress,
|
||||||
safeNetworkInterfaces,
|
safeNetworkInterfaces,
|
||||||
} from "../infra/network-interfaces.js";
|
} from "../infra/network-interfaces.js";
|
||||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
import {
|
||||||
|
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
type DeviceBootstrapProfileInput,
|
||||||
|
} from "../shared/device-bootstrap-profile.js";
|
||||||
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
||||||
import {
|
import {
|
||||||
resolveTailnetHostWithRunner,
|
resolveTailnetHostWithRunner,
|
||||||
|
|
@ -55,6 +58,7 @@ export type ResolvePairingSetupOptions = {
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
preferRemoteUrl?: boolean;
|
preferRemoteUrl?: boolean;
|
||||||
forceSecure?: boolean;
|
forceSecure?: boolean;
|
||||||
|
bootstrapProfile?: DeviceBootstrapProfileInput;
|
||||||
pairingBaseDir?: string;
|
pairingBaseDir?: string;
|
||||||
runCommandWithTimeout?: PairingSetupCommandRunner;
|
runCommandWithTimeout?: PairingSetupCommandRunner;
|
||||||
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
|
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
|
||||||
|
|
@ -435,7 +439,7 @@ export async function resolvePairingSetupFromConfig(
|
||||||
bootstrapToken: (
|
bootstrapToken: (
|
||||||
await issueDeviceBootstrapToken({
|
await issueDeviceBootstrapToken({
|
||||||
baseDir: options.pairingBaseDir,
|
baseDir: options.pairingBaseDir,
|
||||||
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
profile: options.bootstrapProfile ?? PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
})
|
})
|
||||||
).token,
|
).token,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||||
|
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||||
|
isNodePairingSetupBootstrapProfile,
|
||||||
isPairingSetupBootstrapProfile,
|
isPairingSetupBootstrapProfile,
|
||||||
normalizeDeviceBootstrapHandoffProfile,
|
normalizeDeviceBootstrapHandoffProfile,
|
||||||
resolveBootstrapProfileScopesForRole,
|
resolveBootstrapProfileScopesForRole,
|
||||||
|
|
@ -76,6 +78,12 @@ describe("device bootstrap profile", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("node setup profile carries no operator access", () => {
|
||||||
|
expect(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node"], scopes: [] });
|
||||||
|
expect(isNodePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||||
|
expect(isPairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("recognizes only the current setup profile", () => {
|
test("recognizes only the current setup profile", () => {
|
||||||
expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,37 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||||
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
|
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Node-only setup profile for companions that never act as operators. */
|
||||||
|
export const NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||||
|
roles: ["node"],
|
||||||
|
scopes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function matchesBootstrapProfile(
|
||||||
|
input: DeviceBootstrapProfileInput | undefined,
|
||||||
|
expected: DeviceBootstrapProfile,
|
||||||
|
): boolean {
|
||||||
|
const profile = normalizeDeviceBootstrapProfile(input);
|
||||||
|
return (
|
||||||
|
profile.roles.length === expected.roles.length &&
|
||||||
|
profile.scopes.length === expected.scopes.length &&
|
||||||
|
profile.roles.every((role, index) => role === expected.roles[index]) &&
|
||||||
|
profile.scopes.every((scope, index) => scope === expected.scopes[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Return whether an input exactly matches the current setup-code bootstrap profile. */
|
/** Return whether an input exactly matches the current setup-code bootstrap profile. */
|
||||||
export function isPairingSetupBootstrapProfile(
|
export function isPairingSetupBootstrapProfile(
|
||||||
input: DeviceBootstrapProfileInput | undefined,
|
input: DeviceBootstrapProfileInput | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
const profile = normalizeDeviceBootstrapProfile(input);
|
return matchesBootstrapProfile(input, PAIRING_SETUP_BOOTSTRAP_PROFILE);
|
||||||
if (profile.roles.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.roles.length) {
|
}
|
||||||
return false;
|
|
||||||
}
|
/** Return whether an input exactly matches the node-only companion setup profile. */
|
||||||
if (profile.scopes.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes.length) {
|
export function isNodePairingSetupBootstrapProfile(
|
||||||
return false;
|
input: DeviceBootstrapProfileInput | undefined,
|
||||||
}
|
): boolean {
|
||||||
return (
|
return matchesBootstrapProfile(input, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE);
|
||||||
profile.roles.every((role, index) => role === PAIRING_SETUP_BOOTSTRAP_PROFILE.roles[index]) &&
|
|
||||||
profile.scopes.every((scope, index) => scope === PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes[index])
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */
|
/** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue