mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:48:38 +00:00
iOS: add Apple Watch companion message MVP (#20054)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 720791ae6b
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
|
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
|
||||||
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
|
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -729,6 +729,9 @@ final class GatewayConnectionController {
|
|||||||
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
||||||
|
|
||||||
caps.append(OpenClawCapability.device.rawValue)
|
caps.append(OpenClawCapability.device.rawValue)
|
||||||
|
if WatchMessagingService.isSupportedOnDevice() {
|
||||||
|
caps.append(OpenClawCapability.watch.rawValue)
|
||||||
|
}
|
||||||
caps.append(OpenClawCapability.photos.rawValue)
|
caps.append(OpenClawCapability.photos.rawValue)
|
||||||
caps.append(OpenClawCapability.contacts.rawValue)
|
caps.append(OpenClawCapability.contacts.rawValue)
|
||||||
caps.append(OpenClawCapability.calendar.rawValue)
|
caps.append(OpenClawCapability.calendar.rawValue)
|
||||||
@@ -772,6 +775,10 @@ final class GatewayConnectionController {
|
|||||||
commands.append(OpenClawDeviceCommand.status.rawValue)
|
commands.append(OpenClawDeviceCommand.status.rawValue)
|
||||||
commands.append(OpenClawDeviceCommand.info.rawValue)
|
commands.append(OpenClawDeviceCommand.info.rawValue)
|
||||||
}
|
}
|
||||||
|
if caps.contains(OpenClawCapability.watch.rawValue) {
|
||||||
|
commands.append(OpenClawWatchCommand.status.rawValue)
|
||||||
|
commands.append(OpenClawWatchCommand.notify.rawValue)
|
||||||
|
}
|
||||||
if caps.contains(OpenClawCapability.photos.rawValue) {
|
if caps.contains(OpenClawCapability.photos.rawValue) {
|
||||||
commands.append(OpenClawPhotosCommand.latest.rawValue)
|
commands.append(OpenClawPhotosCommand.latest.rawValue)
|
||||||
}
|
}
|
||||||
@@ -822,6 +829,12 @@ final class GatewayConnectionController {
|
|||||||
permissions["motion"] =
|
permissions["motion"] =
|
||||||
motionStatus == .authorized || pedometerStatus == .authorized
|
motionStatus == .authorized || pedometerStatus == .authorized
|
||||||
|
|
||||||
|
let watchStatus = WatchMessagingService.currentStatusSnapshot()
|
||||||
|
permissions["watchSupported"] = watchStatus.supported
|
||||||
|
permissions["watchPaired"] = watchStatus.paired
|
||||||
|
permissions["watchAppInstalled"] = watchStatus.appInstalled
|
||||||
|
permissions["watchReachable"] = watchStatus.reachable
|
||||||
|
|
||||||
return permissions
|
return permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ final class NodeAppModel {
|
|||||||
private let calendarService: any CalendarServicing
|
private let calendarService: any CalendarServicing
|
||||||
private let remindersService: any RemindersServicing
|
private let remindersService: any RemindersServicing
|
||||||
private let motionService: any MotionServicing
|
private let motionService: any MotionServicing
|
||||||
|
private let watchMessagingService: any WatchMessagingServicing
|
||||||
var lastAutoA2uiURL: String?
|
var lastAutoA2uiURL: String?
|
||||||
private var pttVoiceWakeSuspended = false
|
private var pttVoiceWakeSuspended = false
|
||||||
private var talkVoiceWakeSuspended = false
|
private var talkVoiceWakeSuspended = false
|
||||||
@@ -147,6 +148,7 @@ final class NodeAppModel {
|
|||||||
calendarService: any CalendarServicing = CalendarService(),
|
calendarService: any CalendarServicing = CalendarService(),
|
||||||
remindersService: any RemindersServicing = RemindersService(),
|
remindersService: any RemindersServicing = RemindersService(),
|
||||||
motionService: any MotionServicing = MotionService(),
|
motionService: any MotionServicing = MotionService(),
|
||||||
|
watchMessagingService: any WatchMessagingServicing = WatchMessagingService(),
|
||||||
talkMode: TalkModeManager = TalkModeManager())
|
talkMode: TalkModeManager = TalkModeManager())
|
||||||
{
|
{
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
@@ -160,6 +162,7 @@ final class NodeAppModel {
|
|||||||
self.calendarService = calendarService
|
self.calendarService = calendarService
|
||||||
self.remindersService = remindersService
|
self.remindersService = remindersService
|
||||||
self.motionService = motionService
|
self.motionService = motionService
|
||||||
|
self.watchMessagingService = watchMessagingService
|
||||||
self.talkMode = talkMode
|
self.talkMode = talkMode
|
||||||
GatewayDiagnostics.bootstrap()
|
GatewayDiagnostics.bootstrap()
|
||||||
|
|
||||||
@@ -1430,6 +1433,14 @@ private extension NodeAppModel {
|
|||||||
return try await self.handleDeviceInvoke(req)
|
return try await self.handleDeviceInvoke(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
register([
|
||||||
|
OpenClawWatchCommand.status.rawValue,
|
||||||
|
OpenClawWatchCommand.notify.rawValue,
|
||||||
|
]) { [weak self] req in
|
||||||
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||||
|
return try await self.handleWatchInvoke(req)
|
||||||
|
}
|
||||||
|
|
||||||
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
|
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
|
||||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||||
return try await self.handlePhotosInvoke(req)
|
return try await self.handlePhotosInvoke(req)
|
||||||
@@ -1480,6 +1491,58 @@ private extension NodeAppModel {
|
|||||||
return NodeCapabilityRouter(handlers: handlers)
|
return NodeCapabilityRouter(handlers: handlers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
switch req.command {
|
||||||
|
case OpenClawWatchCommand.status.rawValue:
|
||||||
|
let status = await self.watchMessagingService.status()
|
||||||
|
let payload = OpenClawWatchStatusPayload(
|
||||||
|
supported: status.supported,
|
||||||
|
paired: status.paired,
|
||||||
|
appInstalled: status.appInstalled,
|
||||||
|
reachable: status.reachable,
|
||||||
|
activationState: status.activationState)
|
||||||
|
let json = try Self.encodePayload(payload)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||||
|
case OpenClawWatchCommand.notify.rawValue:
|
||||||
|
let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
|
||||||
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if title.isEmpty && body.isEmpty {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: OpenClawNodeError(
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: empty watch notification"))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let result = try await self.watchMessagingService.sendNotification(
|
||||||
|
id: req.id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
priority: params.priority)
|
||||||
|
let payload = OpenClawWatchNotifyPayload(
|
||||||
|
deliveredImmediately: result.deliveredImmediately,
|
||||||
|
queuedForDelivery: result.queuedForDelivery,
|
||||||
|
transport: result.transport)
|
||||||
|
let json = try Self.encodePayload(payload)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||||
|
} catch {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: OpenClawNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: error.localizedDescription))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func locationMode() -> OpenClawLocationMode {
|
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
|
||||||
|
|||||||
@@ -65,6 +65,29 @@ protocol MotionServicing: Sendable {
|
|||||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WatchMessagingStatus: Sendable, Equatable {
|
||||||
|
var supported: Bool
|
||||||
|
var paired: Bool
|
||||||
|
var appInstalled: Bool
|
||||||
|
var reachable: Bool
|
||||||
|
var activationState: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchNotificationSendResult: Sendable, Equatable {
|
||||||
|
var deliveredImmediately: Bool
|
||||||
|
var queuedForDelivery: Bool
|
||||||
|
var transport: String
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol WatchMessagingServicing: AnyObject, Sendable {
|
||||||
|
func status() async -> WatchMessagingStatus
|
||||||
|
func sendNotification(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
||||||
|
}
|
||||||
|
|
||||||
extension CameraController: CameraServicing {}
|
extension CameraController: CameraServicing {}
|
||||||
extension ScreenRecordService: ScreenRecordingServicing {}
|
extension ScreenRecordService: ScreenRecordingServicing {}
|
||||||
extension LocationService: LocationServicing {}
|
extension LocationService: LocationServicing {}
|
||||||
|
|||||||
176
apps/ios/Sources/Services/WatchMessagingService.swift
Normal file
176
apps/ios/Sources/Services/WatchMessagingService.swift
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
import OSLog
|
||||||
|
@preconcurrency import WatchConnectivity
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||||
|
private let session: WCSession?
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isSupportedOnDevice() -> Bool {
|
||||||
|
WCSession.isSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||||
|
guard WCSession.isSupported() else {
|
||||||
|
return WatchMessagingStatus(
|
||||||
|
supported: false,
|
||||||
|
paired: false,
|
||||||
|
appInstalled: false,
|
||||||
|
reachable: false,
|
||||||
|
activationState: "unsupported")
|
||||||
|
}
|
||||||
|
let session = WCSession.default
|
||||||
|
return status(for: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func status() async -> WatchMessagingStatus {
|
||||||
|
await self.ensureActivated()
|
||||||
|
guard let session = self.session else {
|
||||||
|
return WatchMessagingStatus(
|
||||||
|
supported: false,
|
||||||
|
paired: false,
|
||||||
|
appInstalled: false,
|
||||||
|
reachable: false,
|
||||||
|
activationState: "unsupported")
|
||||||
|
}
|
||||||
|
return Self.status(for: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNotification(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
||||||
|
{
|
||||||
|
await self.ensureActivated()
|
||||||
|
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 }
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"type": "watch.notify",
|
||||||
|
"id": id,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||||
|
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||||
|
]
|
||||||
|
|
||||||
|
if snapshot.reachable {
|
||||||
|
do {
|
||||||
|
try await self.sendReachableMessage(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")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
session.sendMessage(payload, replyHandler: { _ in
|
||||||
|
continuation.resume()
|
||||||
|
}, errorHandler: { error in
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||||
|
WatchMessagingStatus(
|
||||||
|
supported: true,
|
||||||
|
paired: session.isPaired,
|
||||||
|
appInstalled: session.isWatchAppInstalled,
|
||||||
|
reachable: session.isReachable,
|
||||||
|
activationState: activationStateLabel(session.activationState))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .notActivated:
|
||||||
|
"notActivated"
|
||||||
|
case .inactive:
|
||||||
|
"inactive"
|
||||||
|
case .activated:
|
||||||
|
"activated"
|
||||||
|
@unknown default:
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WatchMessagingService: WCSessionDelegate {
|
||||||
|
func session(
|
||||||
|
_ session: WCSession,
|
||||||
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||||
|
error: (any Error)?)
|
||||||
|
{
|
||||||
|
if let error {
|
||||||
|
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
|
||||||
|
func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||||
|
}
|
||||||
@@ -29,6 +29,39 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
|||||||
return try body()
|
return try body()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable {
|
||||||
|
var currentStatus = WatchMessagingStatus(
|
||||||
|
supported: true,
|
||||||
|
paired: true,
|
||||||
|
appInstalled: true,
|
||||||
|
reachable: true,
|
||||||
|
activationState: "activated")
|
||||||
|
var nextSendResult = WatchNotificationSendResult(
|
||||||
|
deliveredImmediately: true,
|
||||||
|
queuedForDelivery: false,
|
||||||
|
transport: "sendMessage")
|
||||||
|
var sendError: Error?
|
||||||
|
var lastSent: (id: String, title: String, body: String, priority: OpenClawNotificationPriority?)?
|
||||||
|
|
||||||
|
func status() async -> WatchMessagingStatus {
|
||||||
|
self.currentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNotification(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
||||||
|
{
|
||||||
|
self.lastSent = (id: id, title: title, body: body, priority: priority)
|
||||||
|
if let sendError = self.sendError {
|
||||||
|
throw sendError
|
||||||
|
}
|
||||||
|
return self.nextSendResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||||
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
||||||
#expect(throws: Error.self) {
|
#expect(throws: Error.self) {
|
||||||
@@ -156,6 +189,96 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
|||||||
#expect(res.error?.code == .invalidRequest)
|
#expect(res.error?.code == .invalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeWatchStatusReturnsServiceSnapshot() async throws {
|
||||||
|
let watchService = MockWatchMessagingService()
|
||||||
|
watchService.currentStatus = WatchMessagingStatus(
|
||||||
|
supported: true,
|
||||||
|
paired: true,
|
||||||
|
appInstalled: true,
|
||||||
|
reachable: false,
|
||||||
|
activationState: "inactive")
|
||||||
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
|
let req = BridgeInvokeRequest(id: "watch-status", command: OpenClawWatchCommand.status.rawValue)
|
||||||
|
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == true)
|
||||||
|
|
||||||
|
let payloadData = try #require(res.payloadJSON?.data(using: .utf8))
|
||||||
|
let payload = try JSONDecoder().decode(OpenClawWatchStatusPayload.self, from: payloadData)
|
||||||
|
#expect(payload.supported == true)
|
||||||
|
#expect(payload.reachable == false)
|
||||||
|
#expect(payload.activationState == "inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeWatchNotifyRoutesToWatchService() async throws {
|
||||||
|
let watchService = MockWatchMessagingService()
|
||||||
|
watchService.nextSendResult = WatchNotificationSendResult(
|
||||||
|
deliveredImmediately: false,
|
||||||
|
queuedForDelivery: true,
|
||||||
|
transport: "transferUserInfo")
|
||||||
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
|
let params = OpenClawWatchNotifyParams(
|
||||||
|
title: "OpenClaw",
|
||||||
|
body: "Meeting with Peter is at 4pm",
|
||||||
|
priority: .timeSensitive)
|
||||||
|
let paramsData = try JSONEncoder().encode(params)
|
||||||
|
let paramsJSON = String(decoding: paramsData, as: UTF8.self)
|
||||||
|
let req = BridgeInvokeRequest(
|
||||||
|
id: "watch-notify",
|
||||||
|
command: OpenClawWatchCommand.notify.rawValue,
|
||||||
|
paramsJSON: paramsJSON)
|
||||||
|
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == true)
|
||||||
|
#expect(watchService.lastSent?.title == "OpenClaw")
|
||||||
|
#expect(watchService.lastSent?.body == "Meeting with Peter is at 4pm")
|
||||||
|
#expect(watchService.lastSent?.priority == .timeSensitive)
|
||||||
|
|
||||||
|
let payloadData = try #require(res.payloadJSON?.data(using: .utf8))
|
||||||
|
let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData)
|
||||||
|
#expect(payload.deliveredImmediately == false)
|
||||||
|
#expect(payload.queuedForDelivery == true)
|
||||||
|
#expect(payload.transport == "transferUserInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeWatchNotifyRejectsEmptyMessage() async throws {
|
||||||
|
let watchService = MockWatchMessagingService()
|
||||||
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
|
let params = OpenClawWatchNotifyParams(title: " ", body: "\n")
|
||||||
|
let paramsData = try JSONEncoder().encode(params)
|
||||||
|
let paramsJSON = String(decoding: paramsData, as: UTF8.self)
|
||||||
|
let req = BridgeInvokeRequest(
|
||||||
|
id: "watch-notify-empty",
|
||||||
|
command: OpenClawWatchCommand.notify.rawValue,
|
||||||
|
paramsJSON: paramsJSON)
|
||||||
|
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == false)
|
||||||
|
#expect(res.error?.code == .invalidRequest)
|
||||||
|
#expect(watchService.lastSent == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws {
|
||||||
|
let watchService = MockWatchMessagingService()
|
||||||
|
watchService.sendError = NSError(
|
||||||
|
domain: "watch",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "WATCH_UNAVAILABLE: no paired Apple Watch"])
|
||||||
|
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||||
|
let params = OpenClawWatchNotifyParams(title: "OpenClaw", body: "Delivery check")
|
||||||
|
let paramsData = try JSONEncoder().encode(params)
|
||||||
|
let paramsJSON = String(decoding: paramsData, as: UTF8.self)
|
||||||
|
let req = BridgeInvokeRequest(
|
||||||
|
id: "watch-notify-fail",
|
||||||
|
command: OpenClawWatchCommand.notify.rawValue,
|
||||||
|
paramsJSON: paramsJSON)
|
||||||
|
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == false)
|
||||||
|
#expect(res.error?.code == .unavailable)
|
||||||
|
#expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
||||||
let appModel = NodeAppModel()
|
let appModel = NodeAppModel()
|
||||||
let url = URL(string: "openclaw://agent?message=hello")!
|
let url = URL(string: "openclaw://agent?message=hello")!
|
||||||
|
|||||||
28
apps/ios/WatchApp/Info.plist
Normal file
28
apps/ios/WatchApp/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>OpenClaw</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>2026.2.16</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>20260216</string>
|
||||||
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
|
<string>ai.openclaw.ios</string>
|
||||||
|
<key>WKWatchKitApp</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
32
apps/ios/WatchExtension/Info.plist
Normal file
32
apps/ios/WatchExtension/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>OpenClaw</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>2026.2.16</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>20260216</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>WKAppBundleIdentifier</key>
|
||||||
|
<string>ai.openclaw.ios.watchkitapp</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.watchkit</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
20
apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift
Normal file
20
apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct OpenClawWatchApp: App {
|
||||||
|
@State private var inboxStore = WatchInboxStore()
|
||||||
|
@State private var receiver: WatchConnectivityReceiver?
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
WatchInboxView(store: self.inboxStore)
|
||||||
|
.task {
|
||||||
|
if self.receiver == nil {
|
||||||
|
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
|
||||||
|
receiver.activate()
|
||||||
|
self.receiver = receiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||||
|
private let store: WatchInboxStore
|
||||||
|
private let session: WCSession?
|
||||||
|
|
||||||
|
init(store: WatchInboxStore) {
|
||||||
|
self.store = store
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
self.session = WCSession.default
|
||||||
|
} else {
|
||||||
|
self.session = nil
|
||||||
|
}
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
guard let session = self.session else { return }
|
||||||
|
session.delegate = self
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? {
|
||||||
|
guard let type = payload["type"] as? String, type == "watch.notify" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = (payload["title"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let body = (payload["body"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
guard title.isEmpty == false || body.isEmpty == false else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = (payload["id"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||||
|
|
||||||
|
return WatchNotifyMessage(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
sentAtMs: sentAtMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||||
|
func session(
|
||||||
|
_: WCSession,
|
||||||
|
activationDidCompleteWith _: WCSessionActivationState,
|
||||||
|
error _: (any Error)?)
|
||||||
|
{}
|
||||||
|
|
||||||
|
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
guard let incoming = Self.parseNotificationPayload(message) else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.store.consume(message: incoming, transport: "sendMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(
|
||||||
|
_: WCSession,
|
||||||
|
didReceiveMessage message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void)
|
||||||
|
{
|
||||||
|
guard let incoming = Self.parseNotificationPayload(message) else {
|
||||||
|
replyHandler(["ok": false])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
self.store.consume(message: incoming, transport: "sendMessage")
|
||||||
|
replyHandler(["ok": true])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||||
|
guard let incoming = Self.parseNotificationPayload(userInfo) else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.store.consume(message: incoming, transport: "transferUserInfo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
|
guard let incoming = Self.parseNotificationPayload(applicationContext) else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.store.consume(message: incoming, transport: "applicationContext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
apps/ios/WatchExtension/Sources/WatchInboxStore.swift
Normal file
124
apps/ios/WatchExtension/Sources/WatchInboxStore.swift
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import UserNotifications
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
struct WatchNotifyMessage: Sendable {
|
||||||
|
var id: String?
|
||||||
|
var title: String
|
||||||
|
var body: String
|
||||||
|
var sentAtMs: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor @Observable final class WatchInboxStore {
|
||||||
|
private struct PersistedState: Codable {
|
||||||
|
var title: String
|
||||||
|
var body: String
|
||||||
|
var transport: String
|
||||||
|
var updatedAt: Date
|
||||||
|
var lastDeliveryKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let persistedStateKey = "watch.inbox.state.v1"
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
var title = "OpenClaw"
|
||||||
|
var body = "Waiting for messages from your iPhone."
|
||||||
|
var transport = "none"
|
||||||
|
var updatedAt: Date?
|
||||||
|
private var lastDeliveryKey: String?
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.restorePersistedState()
|
||||||
|
Task {
|
||||||
|
await self.ensureNotificationAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func consume(message: WatchNotifyMessage, transport: String) {
|
||||||
|
let messageID = message.id?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let deliveryKey = self.deliveryKey(
|
||||||
|
messageID: messageID,
|
||||||
|
title: message.title,
|
||||||
|
body: message.body,
|
||||||
|
sentAtMs: message.sentAtMs)
|
||||||
|
guard deliveryKey != self.lastDeliveryKey else { return }
|
||||||
|
|
||||||
|
let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title
|
||||||
|
self.title = normalizedTitle
|
||||||
|
self.body = message.body
|
||||||
|
self.transport = transport
|
||||||
|
self.updatedAt = Date()
|
||||||
|
self.lastDeliveryKey = deliveryKey
|
||||||
|
self.persistState()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await self.postLocalNotification(
|
||||||
|
identifier: deliveryKey,
|
||||||
|
title: normalizedTitle,
|
||||||
|
body: message.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restorePersistedState() {
|
||||||
|
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
|
||||||
|
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.title = state.title
|
||||||
|
self.body = state.body
|
||||||
|
self.transport = state.transport
|
||||||
|
self.updatedAt = state.updatedAt
|
||||||
|
self.lastDeliveryKey = state.lastDeliveryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistState() {
|
||||||
|
guard let updatedAt = self.updatedAt else { return }
|
||||||
|
let state = PersistedState(
|
||||||
|
title: self.title,
|
||||||
|
body: self.body,
|
||||||
|
transport: self.transport,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
lastDeliveryKey: self.lastDeliveryKey)
|
||||||
|
guard let data = try? JSONEncoder().encode(state) else { return }
|
||||||
|
self.defaults.set(data, forKey: Self.persistedStateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String {
|
||||||
|
if let messageID, messageID.isEmpty == false {
|
||||||
|
return "id:\(messageID)"
|
||||||
|
}
|
||||||
|
return "content:\(title)|\(body)|\(sentAtMs ?? 0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureNotificationAuthorization() async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
switch settings.authorizationStatus {
|
||||||
|
case .notDetermined:
|
||||||
|
_ = try? await center.requestAuthorization(options: [.alert, .sound])
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postLocalNotification(identifier: String, title: String, body: String) async {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
content.threadIdentifier = "openclaw-watch"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false))
|
||||||
|
|
||||||
|
_ = try? await UNUserNotificationCenter.current().add(request)
|
||||||
|
WKInterfaceDevice.current().play(.notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/ios/WatchExtension/Sources/WatchInboxView.swift
Normal file
27
apps/ios/WatchExtension/Sources/WatchInboxView.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WatchInboxView: View {
|
||||||
|
@Bindable var store: WatchInboxStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(store.title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(store.body)
|
||||||
|
.font(.body)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if let updatedAt = store.updatedAt {
|
||||||
|
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- target: OpenClawShareExtension
|
- target: OpenClawShareExtension
|
||||||
embed: true
|
embed: true
|
||||||
|
- target: OpenClawWatchApp
|
||||||
- package: OpenClawKit
|
- package: OpenClawKit
|
||||||
- package: OpenClawKit
|
- package: OpenClawKit
|
||||||
product: OpenClawChatUI
|
product: OpenClawChatUI
|
||||||
@@ -140,6 +141,55 @@ targets:
|
|||||||
info:
|
info:
|
||||||
path: ShareExtension/Info.plist
|
path: ShareExtension/Info.plist
|
||||||
|
|
||||||
|
OpenClawWatchApp:
|
||||||
|
type: application.watchapp2
|
||||||
|
platform: watchOS
|
||||||
|
deploymentTarget: "11.0"
|
||||||
|
sources:
|
||||||
|
- path: WatchApp
|
||||||
|
dependencies:
|
||||||
|
- target: OpenClawWatchExtension
|
||||||
|
configFiles:
|
||||||
|
Debug: Config/Signing.xcconfig
|
||||||
|
Release: Config/Signing.xcconfig
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.watchkitapp
|
||||||
|
info:
|
||||||
|
path: WatchApp/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: OpenClaw
|
||||||
|
CFBundleShortVersionString: "2026.2.16"
|
||||||
|
CFBundleVersion: "20260216"
|
||||||
|
WKCompanionAppBundleIdentifier: ai.openclaw.ios
|
||||||
|
WKWatchKitApp: true
|
||||||
|
|
||||||
|
OpenClawWatchExtension:
|
||||||
|
type: watchkit2-extension
|
||||||
|
platform: watchOS
|
||||||
|
deploymentTarget: "11.0"
|
||||||
|
sources:
|
||||||
|
- path: WatchExtension/Sources
|
||||||
|
dependencies:
|
||||||
|
- sdk: WatchConnectivity.framework
|
||||||
|
- sdk: UserNotifications.framework
|
||||||
|
configFiles:
|
||||||
|
Debug: Config/Signing.xcconfig
|
||||||
|
Release: Config/Signing.xcconfig
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.watchkitapp.extension
|
||||||
|
info:
|
||||||
|
path: WatchExtension/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: OpenClaw
|
||||||
|
CFBundleShortVersionString: "2026.2.16"
|
||||||
|
CFBundleVersion: "20260216"
|
||||||
|
NSExtension:
|
||||||
|
NSExtensionAttributes:
|
||||||
|
WKAppBundleIdentifier: ai.openclaw.ios.watchkitapp
|
||||||
|
NSExtensionPointIdentifier: com.apple.watchkit
|
||||||
|
|
||||||
OpenClawTests:
|
OpenClawTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public enum OpenClawCapability: String, Codable, Sendable {
|
|||||||
case voiceWake
|
case voiceWake
|
||||||
case location
|
case location
|
||||||
case device
|
case device
|
||||||
|
case watch
|
||||||
case photos
|
case photos
|
||||||
case contacts
|
case contacts
|
||||||
case calendar
|
case calendar
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawWatchCommand: String, Codable, Sendable {
|
||||||
|
case status = "watch.status"
|
||||||
|
case notify = "watch.notify"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable {
|
||||||
|
public var supported: Bool
|
||||||
|
public var paired: Bool
|
||||||
|
public var appInstalled: Bool
|
||||||
|
public var reachable: Bool
|
||||||
|
public var activationState: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
supported: Bool,
|
||||||
|
paired: Bool,
|
||||||
|
appInstalled: Bool,
|
||||||
|
reachable: Bool,
|
||||||
|
activationState: String)
|
||||||
|
{
|
||||||
|
self.supported = supported
|
||||||
|
self.paired = paired
|
||||||
|
self.appInstalled = appInstalled
|
||||||
|
self.reachable = reachable
|
||||||
|
self.activationState = activationState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||||
|
public var title: String
|
||||||
|
public var body: String
|
||||||
|
public var priority: OpenClawNotificationPriority?
|
||||||
|
|
||||||
|
public init(title: String, body: String, priority: OpenClawNotificationPriority? = nil) {
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
self.priority = priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawWatchNotifyPayload: Codable, Sendable, Equatable {
|
||||||
|
public var deliveredImmediately: Bool
|
||||||
|
public var queuedForDelivery: Bool
|
||||||
|
public var transport: String
|
||||||
|
|
||||||
|
public init(deliveredImmediately: Bool, queuedForDelivery: Bool, transport: String) {
|
||||||
|
self.deliveredImmediately = deliveredImmediately
|
||||||
|
self.queuedForDelivery = queuedForDelivery
|
||||||
|
self.transport = transport
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
export const callGatewayMock = vi.fn();
|
type GatewayMockFn = ((opts: unknown) => unknown) & {
|
||||||
|
mockReset: () => void;
|
||||||
|
mockResolvedValue: (value: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callGatewayMock = vi.fn() as GatewayMockFn;
|
||||||
|
|
||||||
vi.mock("../../gateway/call.js", () => ({
|
vi.mock("../../gateway/call.js", () => ({
|
||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
|
|||||||
Reference in New Issue
Block a user