Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
152
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
152
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
typealias OpenClawCameraSnapResult = (format: String, base64: String, width: Int, height: Int)
|
||||
typealias OpenClawCameraClipResult = (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
|
||||
protocol CameraServicing: Sendable {
|
||||
func listDevices() async -> [CameraController.CameraDeviceInfo]
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult
|
||||
}
|
||||
|
||||
protocol ScreenRecordingServicing: Sendable {
|
||||
func record(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol LocationServicing: Sendable {
|
||||
func authorizationStatus() -> CLAuthorizationStatus
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization
|
||||
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus
|
||||
func currentLocation(
|
||||
params: OpenClawLocationGetParams,
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
func setBackgroundLocationUpdatesEnabled(_ enabled: Bool)
|
||||
func setAuthorizationChangeHandler(
|
||||
_ handler: @escaping @MainActor @Sendable (CLAuthorizationStatus) -> Void)
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
|
||||
func stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol DeviceStatusServicing: Sendable {
|
||||
func status() async throws -> OpenClawDeviceStatusPayload
|
||||
func info() -> OpenClawDeviceInfoPayload
|
||||
}
|
||||
|
||||
protocol PhotosServicing: Sendable {
|
||||
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload
|
||||
}
|
||||
|
||||
protocol ContactsServicing: Sendable {
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload
|
||||
}
|
||||
|
||||
protocol CalendarServicing: Sendable {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload
|
||||
}
|
||||
|
||||
protocol RemindersServicing: Sendable {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload
|
||||
}
|
||||
|
||||
protocol MotionServicing: Sendable {
|
||||
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||
}
|
||||
|
||||
struct WatchMessagingStatus: Equatable {
|
||||
var supported: Bool
|
||||
var paired: Bool
|
||||
var appInstalled: Bool
|
||||
var reachable: Bool
|
||||
var activationState: String
|
||||
}
|
||||
|
||||
struct WatchQuickReplyEvent: Equatable {
|
||||
var replyId: String
|
||||
var promptId: String
|
||||
var actionId: String
|
||||
var actionLabel: String?
|
||||
var sessionKey: String?
|
||||
var note: String?
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveEvent: Equatable {
|
||||
var replyId: String
|
||||
var approvalId: String
|
||||
var decision: OpenClawWatchExecApprovalDecision
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchAppSnapshotRequestEvent: Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchAppCommandEvent: Codable, Equatable {
|
||||
var commandId: String
|
||||
var command: OpenClawWatchAppCommand
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
var text: String?
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchNotificationSendResult: Equatable {
|
||||
var deliveredImmediately: Bool
|
||||
var queuedForDelivery: Bool
|
||||
var transport: String
|
||||
}
|
||||
|
||||
protocol WatchMessagingServicing: AnyObject, Sendable {
|
||||
func status() async -> WatchMessagingStatus
|
||||
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?)
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?)
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?)
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?)
|
||||
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalPrompt(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalResolved(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalExpired(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
|
||||
func syncExecApprovalSnapshot(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
func syncAppSnapshot(
|
||||
_ message: OpenClawWatchAppSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
}
|
||||
|
||||
extension CameraController: CameraServicing {}
|
||||
extension ScreenRecordService: ScreenRecordingServicing {}
|
||||
extension LocationService: LocationServicing {}
|
||||
84
apps/ios/Sources/Services/NotificationService.swift
Normal file
84
apps/ios/Sources/Services/NotificationService.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct NotificationSnapshot: @unchecked Sendable {
|
||||
let identifier: String
|
||||
let userInfo: [AnyHashable: Any]
|
||||
}
|
||||
|
||||
enum NotificationAuthorizationStatus {
|
||||
case notDetermined
|
||||
case denied
|
||||
case authorized
|
||||
case provisional
|
||||
case ephemeral
|
||||
}
|
||||
|
||||
protocol NotificationCentering: Sendable {
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
|
||||
func deliveredNotifications() async -> [NotificationSnapshot]
|
||||
}
|
||||
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter = .current()) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
let settings = await self.center.notificationSettings()
|
||||
return switch settings.authorizationStatus {
|
||||
case .authorized:
|
||||
.authorized
|
||||
case .provisional:
|
||||
.provisional
|
||||
case .ephemeral:
|
||||
.ephemeral
|
||||
case .denied:
|
||||
.denied
|
||||
case .notDetermined:
|
||||
.notDetermined
|
||||
@unknown default:
|
||||
.denied
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
self.center.add(request) { error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
self.center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.center.getDeliveredNotifications { notifications in
|
||||
continuation.resume(
|
||||
returning: notifications.map { notification in
|
||||
NotificationSnapshot(
|
||||
identifier: notification.request.identifier,
|
||||
userInfo: notification.request.content.userInfo)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
437
apps/ios/Sources/Services/WatchConnectivityTransport.swift
Normal file
437
apps/ios/Sources/Services/WatchConnectivityTransport.swift
Normal file
@@ -0,0 +1,437 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
@preconcurrency import WatchConnectivity
|
||||
|
||||
private struct WatchConnectivityTransportCallbacks {
|
||||
var statusUpdateHandler: (@Sendable (WatchMessagingStatus) -> Void)?
|
||||
var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
|
||||
var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
|
||||
var appSnapshotRequestHandler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?
|
||||
var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)?
|
||||
}
|
||||
|
||||
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
// WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly
|
||||
// nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the
|
||||
// Objective-C callback boundary and trap on the reply callback executor check.
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<Void, Error>) in
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { _ in
|
||||
continuation.resume(returning: ())
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "watch.messaging")
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
private var callbacks = WatchConnectivityTransportCallbacks()
|
||||
|
||||
override init() {
|
||||
if WCSession.isSupported() {
|
||||
self.session = WCSession.default
|
||||
} else {
|
||||
self.session = nil
|
||||
}
|
||||
super.init()
|
||||
if let session = self.session {
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||
WCSession.isSupported()
|
||||
}
|
||||
|
||||
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard WCSession.isSupported() else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
paired: false,
|
||||
appInstalled: false,
|
||||
reachable: false,
|
||||
activationState: "unsupported")
|
||||
}
|
||||
return self.status(for: WCSession.default)
|
||||
}
|
||||
|
||||
func status() async -> WatchMessagingStatus {
|
||||
await self.ensureActivated()
|
||||
return self.currentStatusSnapshot()
|
||||
}
|
||||
|
||||
func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard let session = self.session else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
paired: false,
|
||||
appInstalled: false,
|
||||
reachable: false,
|
||||
activationState: "unsupported")
|
||||
}
|
||||
return Self.status(for: session)
|
||||
}
|
||||
|
||||
func setStatusUpdateHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
|
||||
self.updateCallbacks { $0.statusUpdateHandler = handler }
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.updateCallbacks { $0.replyHandler = handler }
|
||||
}
|
||||
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
|
||||
self.updateCallbacks { $0.execApprovalResolveHandler = handler }
|
||||
}
|
||||
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
{
|
||||
self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler }
|
||||
}
|
||||
|
||||
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) {
|
||||
self.updateCallbacks { $0.appSnapshotRequestHandler = handler }
|
||||
}
|
||||
|
||||
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) {
|
||||
self.updateCallbacks { $0.appCommandHandler = handler }
|
||||
}
|
||||
|
||||
func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
|
||||
await self.ensureActivated()
|
||||
let session = try self.requireReadySession()
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await sendReachableWatchMessage(payload, with: session)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: true,
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
} catch {
|
||||
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = session.transferUserInfo(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "transferUserInfo")
|
||||
}
|
||||
|
||||
func sendSnapshotPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
|
||||
await self.ensureActivated()
|
||||
let session = try self.requireReadySession()
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await sendReachableWatchMessage(payload, with: session)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: true,
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
} catch {
|
||||
Self.logger.error(
|
||||
"watch snapshot sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try session.updateApplicationContext(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "applicationContext")
|
||||
} catch {
|
||||
Self.logger.error(
|
||||
"watch updateApplicationContext failed: \(error.localizedDescription, privacy: .public)")
|
||||
_ = session.transferUserInfo(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "transferUserInfo")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCallbacks(_ update: (inout WatchConnectivityTransportCallbacks) -> Void) {
|
||||
self.callbacksLock.lock()
|
||||
defer { self.callbacksLock.unlock() }
|
||||
update(&self.callbacks)
|
||||
}
|
||||
|
||||
private func callbacksSnapshot() -> WatchConnectivityTransportCallbacks {
|
||||
self.callbacksLock.lock()
|
||||
defer { self.callbacksLock.unlock() }
|
||||
return self.callbacks
|
||||
}
|
||||
|
||||
private func requireReadySession() throws -> WCSession {
|
||||
guard let session = self.session else {
|
||||
throw WatchMessagingError.unsupported
|
||||
}
|
||||
let snapshot = Self.status(for: session)
|
||||
guard snapshot.paired else {
|
||||
throw WatchMessagingError.notPaired
|
||||
}
|
||||
guard snapshot.appInstalled else {
|
||||
throw WatchMessagingError.watchAppNotInstalled
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private func ensureActivated() async {
|
||||
guard let session = self.session else { return }
|
||||
if session.activationState == .activated {
|
||||
return
|
||||
}
|
||||
session.activate()
|
||||
for _ in 0..<8 {
|
||||
if session.activationState == .activated {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitStatusUpdate(_ snapshot: WatchMessagingStatus) {
|
||||
guard let handler = self.callbacksSnapshot().statusUpdateHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
guard let handler = self.callbacksSnapshot().replyHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
|
||||
guard let handler = self.callbacksSnapshot().execApprovalResolveHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
|
||||
guard let handler = self.callbacksSnapshot().execApprovalSnapshotRequestHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitAppSnapshotRequest(_ event: WatchAppSnapshotRequestEvent) {
|
||||
guard let handler = self.callbacksSnapshot().appSnapshotRequestHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitAppCommand(_ event: WatchAppCommandEvent) {
|
||||
guard let handler = self.callbacksSnapshot().appCommandHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
appInstalled: session.isWatchAppInstalled,
|
||||
reachable: session.isReachable,
|
||||
activationState: self.activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
private nonisolated static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
case .inactive:
|
||||
"inactive"
|
||||
case .activated:
|
||||
"activated"
|
||||
@unknown default:
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchConnectivityTransport: WCSessionDelegate {
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: (any Error)?)
|
||||
{
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: activation complete "
|
||||
+ "state=\(Self.activationStateLabel(activationState)) "
|
||||
+ "error=\(error?.localizedDescription ?? "none")")
|
||||
if let error {
|
||||
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
Self.logger.debug(
|
||||
"watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||
}
|
||||
self.emitStatusUpdate(Self.status(for: session))
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
GatewayDiagnostics.log("watch messaging: session did deactivate; reactivating")
|
||||
session.activate()
|
||||
self.emitStatusUpdate(Self.status(for: session))
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
let type = (message["type"] as? String) ?? "unknown"
|
||||
GatewayDiagnostics.log("watch messaging: didReceiveMessage type=\(type)")
|
||||
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
|
||||
self.emitReply(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
self.emitExecApprovalResolve(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
self.emitExecApprovalSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
self.emitAppSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseAppCommandPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
self.emitAppCommand(event)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
{
|
||||
let type = (message["type"] as? String) ?? "unknown"
|
||||
GatewayDiagnostics.log("watch messaging: didReceiveMessageWithReply type=\(type)")
|
||||
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
|
||||
replyHandler(["ok": true])
|
||||
self.emitReply(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
replyHandler(["ok": true])
|
||||
self.emitExecApprovalResolve(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
replyHandler(["ok": true])
|
||||
self.emitExecApprovalSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
replyHandler(["ok": true])
|
||||
self.emitAppSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseAppCommandPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
replyHandler(["ok": true])
|
||||
self.emitAppCommand(event)
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": false, "error": "unsupported_payload"])
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
let type = (userInfo["type"] as? String) ?? "unknown"
|
||||
GatewayDiagnostics.log("watch messaging: didReceiveUserInfo type=\(type)")
|
||||
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitReply(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitExecApprovalResolve(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitExecApprovalSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitAppSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseAppCommandPayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitAppCommand(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: reachability changed "
|
||||
+ "reachable=\(session.isReachable) paired=\(session.isPaired) "
|
||||
+ "installed=\(session.isWatchAppInstalled)")
|
||||
self.emitStatusUpdate(Self.status(for: session))
|
||||
}
|
||||
}
|
||||
310
apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift
Normal file
310
apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift
Normal file
@@ -0,0 +1,310 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum WatchMessagingPayloadCodec {
|
||||
static func nowMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func encodeNotificationPayload(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.notify.rawValue,
|
||||
"id": id,
|
||||
"title": params.title,
|
||||
"body": params.body,
|
||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"sentAtMs": self.nowMs(),
|
||||
]
|
||||
if let promptId = nonEmpty(params.promptId) {
|
||||
payload["promptId"] = promptId
|
||||
}
|
||||
if let sessionKey = nonEmpty(params.sessionKey) {
|
||||
payload["sessionKey"] = sessionKey
|
||||
}
|
||||
if let kind = nonEmpty(params.kind) {
|
||||
payload["kind"] = kind
|
||||
}
|
||||
if let details = nonEmpty(params.details) {
|
||||
payload["details"] = details
|
||||
}
|
||||
if let expiresAtMs = params.expiresAtMs {
|
||||
payload["expiresAtMs"] = expiresAtMs
|
||||
}
|
||||
if let risk = params.risk {
|
||||
payload["risk"] = risk.rawValue
|
||||
}
|
||||
if let actions = params.actions, !actions.isEmpty {
|
||||
payload["actions"] = actions.map { action in
|
||||
var encoded: [String: Any] = [
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
]
|
||||
if let style = nonEmpty(action.style) {
|
||||
encoded["style"] = style
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
|
||||
var payload: [String: Any] = [
|
||||
"id": item.id,
|
||||
"commandText": item.commandText,
|
||||
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
|
||||
]
|
||||
if let commandPreview = nonEmpty(item.commandPreview) {
|
||||
payload["commandPreview"] = commandPreview
|
||||
}
|
||||
if let host = nonEmpty(item.host) {
|
||||
payload["host"] = host
|
||||
}
|
||||
if let nodeId = nonEmpty(item.nodeId) {
|
||||
payload["nodeId"] = nodeId
|
||||
}
|
||||
if let agentId = nonEmpty(item.agentId) {
|
||||
payload["agentId"] = agentId
|
||||
}
|
||||
if let expiresAtMs = item.expiresAtMs {
|
||||
payload["expiresAtMs"] = expiresAtMs
|
||||
}
|
||||
if let risk = item.risk {
|
||||
payload["risk"] = risk.rawValue
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalPromptPayload(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
||||
"approval": self.encodeExecApprovalItem(message.approval),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
if let deliveryId = nonEmpty(message.deliveryId) {
|
||||
payload["deliveryId"] = deliveryId
|
||||
}
|
||||
if message.resetResolvingState == true {
|
||||
payload["resetResolvingState"] = true
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalResolvedPayload(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
|
||||
"approvalId": message.approvalId,
|
||||
]
|
||||
if let decision = message.decision {
|
||||
payload["decision"] = decision.rawValue
|
||||
}
|
||||
if let resolvedAtMs = message.resolvedAtMs {
|
||||
payload["resolvedAtMs"] = resolvedAtMs
|
||||
}
|
||||
if let source = nonEmpty(message.source) {
|
||||
payload["source"] = source
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalExpiredPayload(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalExpired.rawValue,
|
||||
"approvalId": message.approvalId,
|
||||
"reason": message.reason.rawValue,
|
||||
]
|
||||
if let expiredAtMs = message.expiredAtMs {
|
||||
payload["expiredAtMs"] = expiredAtMs
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalSnapshotPayload(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||
"approvals": message.approvals.map(self.encodeExecApprovalItem),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
if let snapshotId = nonEmpty(message.snapshotId) {
|
||||
payload["snapshotId"] = snapshotId
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeAppSnapshotPayload(
|
||||
_ message: OpenClawWatchAppSnapshotMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.appSnapshot.rawValue,
|
||||
"gatewayStatusText": message.gatewayStatusText,
|
||||
"gatewayConnected": message.gatewayConnected,
|
||||
"agentName": message.agentName,
|
||||
"sessionKey": message.sessionKey,
|
||||
"talkStatusText": message.talkStatusText,
|
||||
"talkEnabled": message.talkEnabled,
|
||||
"talkListening": message.talkListening,
|
||||
"talkSpeaking": message.talkSpeaking,
|
||||
"pendingApprovalCount": message.pendingApprovalCount,
|
||||
]
|
||||
if let agentAvatarURL = nonEmpty(message.agentAvatarURL) {
|
||||
payload["agentAvatarUrl"] = agentAvatarURL
|
||||
}
|
||||
if let agentAvatarText = nonEmpty(message.agentAvatarText) {
|
||||
payload["agentAvatarText"] = agentAvatarText
|
||||
}
|
||||
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
if let chatItems = message.chatItems {
|
||||
payload["chatItems"] = chatItems.map { item in
|
||||
var encoded: [String: Any] = [
|
||||
"id": item.id,
|
||||
"role": item.role,
|
||||
"text": item.text,
|
||||
]
|
||||
if let timestampMs = item.timestampMs {
|
||||
encoded["timestampMs"] = timestampMs
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
if let chatStatusText = nonEmpty(message.chatStatusText) {
|
||||
payload["chatStatusText"] = chatStatusText
|
||||
}
|
||||
if let snapshotId = nonEmpty(message.snapshotId) {
|
||||
payload["snapshotId"] = snapshotId
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func parseQuickReplyPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchQuickReplyEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.reply.rawValue else {
|
||||
return nil
|
||||
}
|
||||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = self.nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
return WatchQuickReplyEvent(
|
||||
replyId: replyId,
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey,
|
||||
note: note,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
static func parseExecApprovalResolvePayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchExecApprovalResolveEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else {
|
||||
return nil
|
||||
}
|
||||
guard let approvalId = nonEmpty(payload["approvalId"] as? String),
|
||||
let rawDecision = nonEmpty(payload["decision"] as? String),
|
||||
let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalResolveEvent(
|
||||
replyId: replyId,
|
||||
approvalId: approvalId,
|
||||
decision: decision,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
static func parseExecApprovalSnapshotRequestPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchExecApprovalSnapshotRequestEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
||||
return nil
|
||||
}
|
||||
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: requestId,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
static func parseAppSnapshotRequestPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchAppSnapshotRequestEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.appSnapshotRequest.rawValue else {
|
||||
return nil
|
||||
}
|
||||
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchAppSnapshotRequestEvent(
|
||||
requestId: requestId,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
static func parseAppCommandPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchAppCommandEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.appCommand.rawValue else {
|
||||
return nil
|
||||
}
|
||||
guard let rawCommand = nonEmpty(payload["command"] as? String),
|
||||
let command = OpenClawWatchAppCommand(rawValue: rawCommand)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let commandId = self.nonEmpty(payload["commandId"] as? String) ?? UUID().uuidString
|
||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
||||
let text = self.nonEmpty(payload["text"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchAppCommandEvent(
|
||||
commandId: commandId,
|
||||
command: command,
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID,
|
||||
text: text,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
}
|
||||
205
apps/ios/Sources/Services/WatchMessagingService.swift
Normal file
205
apps/ios/Sources/Services/WatchMessagingService.swift
Normal file
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum WatchMessagingError: LocalizedError {
|
||||
case unsupported
|
||||
case notPaired
|
||||
case watchAppNotInstalled
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupported:
|
||||
"WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device"
|
||||
case .notPaired:
|
||||
"WATCH_UNAVAILABLE: no paired Apple Watch"
|
||||
case .watchAppNotInstalled:
|
||||
"WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
|
||||
private let transport: WatchConnectivityTransport
|
||||
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
|
||||
private var lastEmittedStatus: WatchMessagingStatus?
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
|
||||
private var execApprovalSnapshotRequestHandler: (
|
||||
@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
|
||||
private var appSnapshotRequestHandler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?
|
||||
private var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)?
|
||||
|
||||
init(transport: WatchConnectivityTransport = WatchConnectivityTransport()) {
|
||||
self.transport = transport
|
||||
self.transport.setStatusUpdateHandler { [weak self] snapshot in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitStatusIfChanged(snapshot)
|
||||
}
|
||||
}
|
||||
self.transport.setReplyHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitReply(event)
|
||||
}
|
||||
}
|
||||
self.transport.setExecApprovalResolveHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitExecApprovalResolve(event)
|
||||
}
|
||||
}
|
||||
self.transport.setExecApprovalSnapshotRequestHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitExecApprovalSnapshotRequest(event)
|
||||
}
|
||||
}
|
||||
self.transport.setAppSnapshotRequestHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitAppSnapshotRequest(event)
|
||||
}
|
||||
}
|
||||
self.transport.setAppCommandHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitAppCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||
WatchConnectivityTransport.isSupportedOnDevice()
|
||||
}
|
||||
|
||||
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
WatchConnectivityTransport.currentStatusSnapshot()
|
||||
}
|
||||
|
||||
func status() async -> WatchMessagingStatus {
|
||||
await self.transport.status()
|
||||
}
|
||||
|
||||
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
|
||||
self.statusHandler = handler
|
||||
guard let handler else {
|
||||
self.lastEmittedStatus = nil
|
||||
GatewayDiagnostics.log("watch messaging: cleared status handler")
|
||||
return
|
||||
}
|
||||
let snapshot = self.transport.currentStatusSnapshot()
|
||||
self.lastEmittedStatus = snapshot
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: set status handler "
|
||||
+ "supported=\(snapshot.supported) paired=\(snapshot.paired) "
|
||||
+ "appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) "
|
||||
+ "activation=\(snapshot.activationState)")
|
||||
handler(snapshot)
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandler = handler
|
||||
}
|
||||
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
|
||||
self.execApprovalResolveHandler = handler
|
||||
}
|
||||
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
{
|
||||
self.execApprovalSnapshotRequestHandler = handler
|
||||
}
|
||||
|
||||
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) {
|
||||
self.appSnapshotRequestHandler = handler
|
||||
}
|
||||
|
||||
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) {
|
||||
self.appCommandHandler = handler
|
||||
}
|
||||
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params)
|
||||
return try await self.transport.sendPayload(payload)
|
||||
}
|
||||
|
||||
func sendExecApprovalPrompt(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload(message))
|
||||
}
|
||||
|
||||
func sendExecApprovalResolved(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload(message))
|
||||
}
|
||||
|
||||
func sendExecApprovalExpired(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload(message))
|
||||
}
|
||||
|
||||
func syncExecApprovalSnapshot(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendSnapshotPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload(message))
|
||||
}
|
||||
|
||||
func syncAppSnapshot(
|
||||
_ message: OpenClawWatchAppSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendSnapshotPayload(
|
||||
WatchMessagingPayloadCodec.encodeAppSnapshotPayload(message))
|
||||
}
|
||||
|
||||
private func emitStatusIfChanged(_ snapshot: WatchMessagingStatus) {
|
||||
guard snapshot != self.lastEmittedStatus else {
|
||||
return
|
||||
}
|
||||
self.lastEmittedStatus = snapshot
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: status "
|
||||
+ "supported=\(snapshot.supported) paired=\(snapshot.paired) "
|
||||
+ "appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) "
|
||||
+ "activation=\(snapshot.activationState)")
|
||||
self.statusHandler?(snapshot)
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
self.replyHandler?(event)
|
||||
}
|
||||
|
||||
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
|
||||
self.execApprovalResolveHandler?(event)
|
||||
}
|
||||
|
||||
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: snapshot request "
|
||||
+ "id=\(event.requestId) transport=\(event.transport) "
|
||||
+ "sentAtMs=\(event.sentAtMs ?? -1)")
|
||||
self.execApprovalSnapshotRequestHandler?(event)
|
||||
}
|
||||
|
||||
private func emitAppSnapshotRequest(_ event: WatchAppSnapshotRequestEvent) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: app snapshot request "
|
||||
+ "id=\(event.requestId) transport=\(event.transport) "
|
||||
+ "sentAtMs=\(event.sentAtMs ?? -1)")
|
||||
self.appSnapshotRequestHandler?(event)
|
||||
}
|
||||
|
||||
private func emitAppCommand(_ event: WatchAppCommandEvent) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: app command "
|
||||
+ "id=\(event.commandId) command=\(event.command.rawValue) "
|
||||
+ "transport=\(event.transport)")
|
||||
self.appCommandHandler?(event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user