Files
openclaw/apps/ios/WatchApp/Sources/WatchDirectNode.swift
Peter Steinberger 5ca0e634bc fix(ios): connect iPhone and Watch to protocol v3 gateways (#106294)
* fix(ios): support gateway protocol v3

* chore(i18n): refresh native source metadata

* fix(watchos): keep direct transport on protocol v4

* chore(i18n): refresh Watch source metadata
2026-07-13 05:44:49 -07:00

839 lines
32 KiB
Swift

import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import UserNotifications
import WatchKit
@MainActor @Observable
final class WatchDirectNode {
private struct PersistedConfiguration: Codable {
var link: GatewayConnectDeepLink
var gatewayID: String
var setupSentAtMs: Int64?
}
private struct ActiveSession: Equatable {
let baseURL: URL
let token: String
}
private struct ConnectResponse: Decodable {
let sessionToken: String
let deviceToken: String
}
private enum ConnectCredential {
case bootstrap(String)
case device(String)
var token: String {
switch self {
case let .bootstrap(token), let .device(token): token
}
}
}
private struct ChallengeResponse: Decodable {
let nonce: String
}
private struct PollResponse: Decodable {
let event: NodeEvent?
}
private struct NodeEvent: Decodable {
let event: String
let payload: InvokeRequest?
}
private struct InvokeRequest: Decodable {
let id: String
let nodeId: String
let command: String
let paramsJSON: String?
}
private struct HTTPError: LocalizedError {
let status: Int
let detail: String
var errorDescription: String? {
self.detail.isEmpty
? String(
format: String(localized: "Gateway HTTP error (%@)"),
self.status.formatted())
: self.detail
}
}
private static let keychainService = "ai.openclaw.watch.direct-node"
private static let keychainAccount = "gateway"
private static let enabledDefaultsKey = "watch.directNode.enabled"
private static let lastSetupSentAtDefaultsKey = "watch.directNode.lastSetupSentAtMs"
private static let maximumSetupAgeMs: Int64 = 12 * 60 * 1000
private static let maximumSetupClockSkewMs: Int64 = 2 * 60 * 1000
private static let commands = [
OpenClawDeviceCommand.info.rawValue,
OpenClawDeviceCommand.status.rawValue,
OpenClawSystemCommand.notify.rawValue,
]
private let networkMetrics: WatchURLSessionMetrics
private let urlSession: URLSession
private var configuration: PersistedConfiguration?
private var connectTask: Task<Void, Never>?
private var activeSession: ActiveSession?
private var isForeground = false
private var connectionGeneration = 0
private(set) var isEnabled: Bool
private(set) var isConnected = false
private(set) var statusText = String(
localized: "Use iPhone Settings to enable direct connection.")
private(set) var endpointText: String?
var isConfigured: Bool {
self.configuration != nil
}
init() {
let sessionConfiguration = URLSessionConfiguration.ephemeral
sessionConfiguration.waitsForConnectivity = true
sessionConfiguration.timeoutIntervalForRequest = 30
sessionConfiguration.timeoutIntervalForResource = 35
let networkMetrics = WatchURLSessionMetrics()
self.networkMetrics = networkMetrics
self.urlSession = URLSession(
configuration: sessionConfiguration,
delegate: networkMetrics,
delegateQueue: nil)
self.isEnabled = UserDefaults.standard.bool(forKey: Self.enabledDefaultsKey)
self.configuration = Self.loadConfiguration()
if let setupSentAtMs = configuration?.setupSentAtMs,
setupSentAtMs > Self.lastAcceptedSetupSentAtMs()
{
Self.saveLastAcceptedSetupSentAtMs(setupSentAtMs)
}
self.endpointText = self.configuration.map(Self.endpointText)
if self.configuration != nil {
self.statusText = self.isEnabled
? String(localized: "Ready to connect")
: String(localized: "Direct connection is off")
}
}
func configure(setupCode: String, sentAtMs: Int64) {
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let oldestAcceptedMs = nowMs - Self.maximumSetupAgeMs
let newestAcceptedMs = nowMs + Self.maximumSetupClockSkewMs
guard (oldestAcceptedMs...newestAcceptedMs).contains(sentAtMs) else {
self.statusText = String(
localized: "Ignored an expired direct connection setup. Send setup again from iPhone.")
return
}
let newestInstalledSetupMs = configuration?.setupSentAtMs ?? 0
guard sentAtMs > max(Self.lastAcceptedSetupSentAtMs(), newestInstalledSetupMs) else { return }
guard let link = GatewayConnectDeepLink.fromSetupCode(setupCode),
link.isValidEndpoint,
link.bootstrapToken != nil,
link.token == nil,
link.password == nil,
let secureEndpoint = link.connectionEndpoints.first(where: \.tls)
else {
self.statusText = String(
localized: "Direct mode requires a trusted HTTPS Gateway endpoint.")
return
}
let secureEndpoints = link.connectionEndpoints.filter(\.tls)
let secureLink = GatewayConnectDeepLink(
host: secureEndpoint.host,
port: secureEndpoint.port,
tls: true,
bootstrapToken: link.bootstrapToken,
token: nil,
password: nil,
fallbackEndpoints: Array(secureEndpoints.dropFirst()))
let previousConfiguration = self.configuration
let configuration = PersistedConfiguration(
link: secureLink,
gatewayID: Self.gatewayID(for: secureLink),
setupSentAtMs: sentAtMs)
guard Self.saveConfiguration(configuration) else {
self.statusText = String(localized: "Could not save direct connection securely.")
return
}
if let previousConfiguration,
previousConfiguration.gatewayID != configuration.gatewayID,
let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
{
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: previousConfiguration.gatewayID,
profile: .primary)
}
Self.saveLastAcceptedSetupSentAtMs(sentAtMs)
self.disconnectActiveSession()
self.configuration = configuration
self.endpointText = Self.endpointText(configuration)
self.statusText = String(localized: "Setup received. Connecting…")
self.setEnabled(true)
}
func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled
UserDefaults.standard.set(enabled, forKey: Self.enabledDefaultsKey)
if enabled {
self.connect()
} else {
self.disconnectActiveSession()
self.connectionGeneration &+= 1
self.connectTask?.cancel()
self.connectTask = nil
self.isConnected = false
self.statusText = self.isConfigured
? String(localized: "Direct connection is off")
: String(localized: "Use iPhone Settings to enable direct connection.")
}
}
func connect() {
guard self.isForeground, self.isEnabled, let configuration else { return }
self.disconnectActiveSession()
self.connectTask?.cancel()
self.connectionGeneration &+= 1
let generation = self.connectionGeneration
self.connectTask = Task { [weak self] in
await self?.run(configuration, generation: generation)
}
}
func connectForForeground() {
self.isForeground = true
self.connect()
}
func disconnectForBackground() {
self.isForeground = false
self.disconnectActiveSession()
self.connectionGeneration &+= 1
self.connectTask?.cancel()
self.connectTask = nil
self.isConnected = false
if self.isEnabled, self.isConfigured {
self.statusText = String(localized: "Reconnects when OpenClaw is active")
}
}
func forget() {
self.disconnectActiveSession()
self.connectionGeneration &+= 1
self.connectTask?.cancel()
self.connectTask = nil
if let configuration {
if let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) {
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: configuration.gatewayID,
profile: .primary)
}
}
_ = GenericPasswordKeychainStore.delete(
service: Self.keychainService,
account: Self.keychainAccount)
configuration = nil
self.endpointText = nil
self.isConnected = false
self.setEnabled(false)
}
private func run(_ configuration: PersistedConfiguration, generation: Int) async {
while self.isCurrentConnection(generation, configuration: configuration) {
var lastError: Error?
for endpoint in configuration.link.connectionEndpoints {
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
let link = configuration.link.selectingEndpoint(endpoint)
guard let baseURL = Self.httpBaseURL(for: link) else { continue }
do {
try await self.connectAndPoll(
configuration: configuration,
link: link,
baseURL: baseURL,
generation: generation)
return
} catch is CancellationError {
return
} catch let error as URLError where error.code == .cancelled {
return
} catch {
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
lastError = error
self.isConnected = false
}
}
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
self.statusText = lastError.map {
String(
format: String(localized: "Direct connection failed: %@"),
$0.localizedDescription)
} ?? String(localized: "No usable Gateway endpoint")
do {
try await Task.sleep(for: .seconds(3))
} catch {
return
}
}
}
private func connectAndPoll(
configuration: PersistedConfiguration,
link: GatewayConnectDeepLink,
baseURL: URL,
generation: Int) async throws
{
try self.requireCurrentConnection(generation, configuration: configuration)
self.statusText = String(localized: "Connecting directly…")
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) else {
throw HTTPError(
status: 0,
detail: String(localized: "Could not save the watch device identity"))
}
let storedToken = DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: configuration.gatewayID,
profile: .primary)?.token
guard storedToken != nil || link.bootstrapToken != nil else {
throw HTTPError(status: 401, detail: String(localized: "No watch device credential"))
}
let response: ConnectResponse
if let bootstrapToken = link.bootstrapToken {
do {
response = try await self.establishSession(
identity: identity,
baseURL: baseURL,
credential: .bootstrap(bootstrapToken))
} catch let error as HTTPError where error.status == 401 {
guard let storedToken else { throw error }
response = try await self.establishSession(
identity: identity,
baseURL: baseURL,
credential: .device(storedToken))
}
} else if let storedToken {
response = try await self.establishSession(
identity: identity,
baseURL: baseURL,
credential: .device(storedToken))
} else {
throw HTTPError(status: 401, detail: String(localized: "No watch device credential"))
}
// A successful bootstrap response has already consumed the one-time code.
// Finish that durable handoff across background/toggle cancellation, but
// never let an obsolete attempt overwrite a forgotten or newer setup.
do {
guard self.isInstalledConfiguration(configuration) else { throw CancellationError() }
guard DeviceAuthStore.storeTokenPersisted(
deviceId: identity.deviceId,
role: "node",
token: response.deviceToken,
scopes: [],
gatewayID: configuration.gatewayID,
profile: .primary)
else {
throw HTTPError(
status: 0,
detail: String(localized: "Could not save the watch device credential"))
}
if link.bootstrapToken != nil {
try self.finishCredentialHandoff(configuration: configuration)
}
try self.requireCurrentConnection(generation, configuration: configuration)
} catch {
self.sendDisconnect(ActiveSession(baseURL: baseURL, token: response.sessionToken))
throw error
}
let session = ActiveSession(baseURL: baseURL, token: response.sessionToken)
self.activeSession = session
defer { releaseActiveSession(session) }
self.isConnected = true
self.statusText = String(localized: "Connected directly")
while self.isCurrentConnection(generation, configuration: configuration) {
let pollData = try await request(
baseURL: baseURL,
path: "poll",
method: "POST",
token: response.sessionToken)
try self.requireCurrentConnection(generation, configuration: configuration)
let poll = try JSONDecoder().decode(PollResponse.self, from: pollData)
guard let event = poll.event else { continue }
guard event.event == "node.invoke.request", let invoke = event.payload else { continue }
let invokeRequest = BridgeInvokeRequest(
id: invoke.id,
command: invoke.command,
paramsJSON: invoke.paramsJSON,
nodeId: invoke.nodeId)
let result = await handleInvoke(invokeRequest)
try requireCurrentConnection(generation, configuration: configuration)
_ = try await self.request(
baseURL: baseURL,
path: "result",
method: "POST",
token: response.sessionToken,
body: result)
try self.requireCurrentConnection(generation, configuration: configuration)
}
}
private func isCurrentConnection(
_ generation: Int,
configuration: PersistedConfiguration) -> Bool
{
!Task.isCancelled
&& generation == self.connectionGeneration
&& self.isForeground
&& self.isEnabled
&& self.isInstalledConfiguration(configuration)
}
private func isInstalledConfiguration(_ configuration: PersistedConfiguration) -> Bool {
configuration.gatewayID == self.configuration?.gatewayID
&& configuration.setupSentAtMs == self.configuration?.setupSentAtMs
}
private func requireCurrentConnection(
_ generation: Int,
configuration: PersistedConfiguration) throws
{
guard self.isCurrentConnection(generation, configuration: configuration) else {
throw CancellationError()
}
}
private func establishSession(
identity: DeviceIdentity,
baseURL: URL,
credential: ConnectCredential) async throws -> ConnectResponse
{
let challengeData = try await request(
baseURL: baseURL,
path: "challenge",
method: "GET",
token: nil)
let challenge = try JSONDecoder().decode(ChallengeResponse.self, from: challengeData)
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
let params = try connectParams(
identity: identity,
nonce: challenge.nonce,
credential: credential,
notificationsAuthorized: notificationSettings.authorizationStatus == .authorized
|| notificationSettings.authorizationStatus == .provisional)
let connectData = try await request(
baseURL: baseURL,
path: "connect",
method: "POST",
token: nil,
body: params)
return try JSONDecoder().decode(ConnectResponse.self, from: connectData)
}
private func connectParams(
identity: DeviceIdentity,
nonce: String,
credential: ConnectCredential,
notificationsAuthorized: Bool) throws -> ConnectParams
{
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildV3(
fields: .init(
deviceId: identity.deviceId,
client: .init(id: "openclaw-watchos", mode: "node"),
role: "node",
scopes: [],
signedAtMs: signedAtMs,
token: credential.token,
nonce: nonce),
platform: InstanceIdentity.platformString,
deviceFamily: InstanceIdentity.deviceFamily)
guard let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
payload: payload,
identity: identity,
signedAtMs: signedAtMs,
nonce: nonce)
else {
throw HTTPError(status: 0, detail: String(localized: "Could not sign watch identity"))
}
var client: [String: AnyCodable] = [
"id": AnyCodable("openclaw-watchos"),
"displayName": AnyCodable(InstanceIdentity.displayName),
"version": AnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": AnyCodable(InstanceIdentity.platformString),
"deviceFamily": AnyCodable(InstanceIdentity.deviceFamily),
"mode": AnyCodable("node"),
"instanceId": AnyCodable(InstanceIdentity.instanceId),
]
if let modelIdentifier = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = AnyCodable(modelIdentifier)
}
let auth: [String: AnyCodable] = switch credential {
case let .device(token):
["deviceToken": AnyCodable(token)]
case let .bootstrap(token):
["bootstrapToken": AnyCodable(token)]
}
return ConnectParams(
// Direct Watch HTTP transport was added after v3; only current gateways expose it.
minprotocol: GATEWAY_MIN_PROTOCOL_VERSION,
maxprotocol: GATEWAY_PROTOCOL_VERSION,
client: client,
caps: [],
commands: Self.commands,
permissions: ["notifications": AnyCodable(notificationsAuthorized)],
pathenv: nil,
role: "node",
scopes: [],
device: device,
auth: auth,
locale: Locale.preferredLanguages.first ?? Locale.current.identifier,
useragent: ProcessInfo.processInfo.operatingSystemVersionString)
}
private func request(
baseURL: URL,
path: String,
method: String,
token: String?) async throws -> Data
{
try await self.performRequest(
baseURL: baseURL,
path: path,
method: method,
token: token,
body: nil)
}
private func request(
baseURL: URL,
path: String,
method: String,
token: String?,
body: some Encodable) async throws -> Data
{
let encodedBody = try JSONEncoder().encode(body)
return try await self.performRequest(
baseURL: baseURL,
path: path,
method: method,
token: token,
body: encodedBody)
}
private func performRequest(
baseURL: URL,
path: String,
method: String,
token: String?,
body: Data?) async throws -> Data
{
let url = baseURL
.appendingPathComponent("api")
.appendingPathComponent("nodes")
.appendingPathComponent("watch")
.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = path == "poll" ? 25 : 8
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HTTPError(status: 0, detail: String(localized: "Invalid Gateway response"))
}
guard (200..<300).contains(http.statusCode) else {
let detail = String(data: data, encoding: .utf8) ?? ""
throw HTTPError(status: http.statusCode, detail: detail)
}
return data
}
private func finishCredentialHandoff(configuration: PersistedConfiguration) throws {
let link = configuration.link
let sanitized = PersistedConfiguration(
link: GatewayConnectDeepLink(
host: link.host,
port: link.port,
tls: link.tls,
bootstrapToken: nil,
token: nil,
password: nil,
fallbackEndpoints: link.fallbackEndpoints),
gatewayID: configuration.gatewayID,
setupSentAtMs: configuration.setupSentAtMs)
guard Self.saveConfiguration(sanitized) else {
throw HTTPError(
status: 0,
detail: String(localized: "Paired, but could not finish secure setup"))
}
self.configuration = sanitized
self.endpointText = Self.endpointText(sanitized)
}
private func handleInvoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
do {
switch request.command {
case OpenClawDeviceCommand.info.rawValue:
return try self.encodedResponse(id: request.id, payload: self.deviceInfo())
case OpenClawDeviceCommand.status.rawValue:
return try self.encodedResponse(id: request.id, payload: self.deviceStatus())
case OpenClawSystemCommand.notify.rawValue:
return try await self.handleNotification(request)
default:
return Self.errorResponse(
id: request.id,
code: .invalidRequest,
message: "INVALID_REQUEST: unsupported watchOS command")
}
} catch {
return Self.errorResponse(
id: request.id,
code: .unavailable,
message: error.localizedDescription)
}
}
private func handleNotification(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decode(OpenClawSystemNotifyParams.self, from: request.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty || !body.isEmpty else {
return Self.errorResponse(
id: request.id,
code: .invalidRequest,
message: "INVALID_REQUEST: empty notification")
}
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
return Self.errorResponse(
id: request.id,
code: .unavailable,
message: "NOT_AUTHORIZED: notifications")
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
switch params.priority ?? .active {
case .passive:
content.interruptionLevel = .passive
case .timeSensitive:
content.interruptionLevel = .timeSensitive
case .active:
content.interruptionLevel = .active
}
let sound = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
content.sound = sound.map { ["none", "silent", "off"].contains($0) } == true ? nil : .default
try await center.add(UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil))
return BridgeInvokeResponse(id: request.id, ok: true)
}
private func deviceInfo() -> OpenClawDeviceInfoPayload {
let device = WKInterfaceDevice.current()
let info = Bundle.main.infoDictionary ?? [:]
let appVersion = (info["CFBundleShortVersionString"] as? String) ?? "0"
let appBuild = (info["CFBundleVersion"] as? String) ?? "0"
return OpenClawDeviceInfoPayload(
deviceName: device.name,
modelIdentifier: InstanceIdentity.modelIdentifier ?? "Apple Watch",
systemName: "watchOS",
systemVersion: device.systemVersion,
appVersion: appVersion,
appBuild: appBuild,
locale: Locale.preferredLanguages.first ?? Locale.current.identifier)
}
private func deviceStatus() -> OpenClawDeviceStatusPayload {
let device = WKInterfaceDevice.current()
device.isBatteryMonitoringEnabled = true
let batteryState: OpenClawBatteryState = switch device.batteryState {
case .charging: .charging
case .full: .full
case .unplugged: .unplugged
case .unknown: .unknown
@unknown default: .unknown
}
let battery = OpenClawBatteryStatusPayload(
level: device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil,
state: batteryState,
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
let thermalState: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
case .nominal: .nominal
case .fair: .fair
case .serious: .serious
case .critical: .critical
@unknown default: .nominal
}
let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
let total = (attributes[.systemSize] as? NSNumber)?.int64Value ?? 0
let free = (attributes[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
let networkMetrics = self.networkMetrics.snapshot()
return OpenClawDeviceStatusPayload(
battery: battery,
thermal: OpenClawThermalStatusPayload(state: thermalState),
storage: OpenClawStorageStatusPayload(
totalBytes: total,
freeBytes: free,
usedBytes: max(0, total - free)),
network: OpenClawNetworkStatusPayload(
status: self.isConnected ? .satisfied : .requiresConnection,
isExpensive: networkMetrics?.isExpensive ?? false,
isConstrained: networkMetrics?.isConstrained ?? false,
interfaces: networkMetrics?.isCellular == true ? [.cellular] : [.other]),
uptimeSeconds: ProcessInfo.processInfo.systemUptime)
}
private func encodedResponse(id: String, payload: some Encodable) throws -> BridgeInvokeResponse {
let data = try JSONEncoder().encode(payload)
guard let json = String(data: data, encoding: .utf8) else {
throw CocoaError(.fileWriteInapplicableStringEncoding)
}
return BridgeInvokeResponse(id: id, ok: true, payloadJSON: json)
}
private static func decode<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
try JSONDecoder().decode(type, from: Data((json ?? "{}").utf8))
}
private static func errorResponse(
id: String,
code: OpenClawNodeErrorCode,
message: String) -> BridgeInvokeResponse
{
BridgeInvokeResponse(
id: id,
ok: false,
error: OpenClawNodeError(code: code, message: message))
}
private static func httpBaseURL(for link: GatewayConnectDeepLink) -> URL? {
guard link.tls else { return nil }
var components = URLComponents()
components.scheme = "https"
components.host = link.host
components.port = link.port
return components.url
}
private static func gatewayID(for link: GatewayConnectDeepLink) -> String {
"watch-direct:\(link.tls ? "https" : "http")://\(link.host.lowercased()):\(link.port)"
}
private static func endpointText(_ configuration: PersistedConfiguration) -> String {
"\(configuration.link.tls ? "https" : "http")://\(configuration.link.host):\(configuration.link.port)"
}
private static func loadConfiguration() -> PersistedConfiguration? {
guard let raw = GenericPasswordKeychainStore.loadString(
service: keychainService,
account: keychainAccount),
let data = raw.data(using: .utf8)
else { return nil }
return try? JSONDecoder().decode(PersistedConfiguration.self, from: data)
}
private static func saveConfiguration(_ configuration: PersistedConfiguration) -> Bool {
guard let data = try? JSONEncoder().encode(configuration),
let raw = String(data: data, encoding: .utf8)
else { return false }
return GenericPasswordKeychainStore.saveString(
raw,
service: self.keychainService,
account: self.keychainAccount)
}
private func disconnectActiveSession() {
self.isConnected = false
guard let session = activeSession else { return }
self.activeSession = nil
self.sendDisconnect(session)
}
private func releaseActiveSession(_ session: ActiveSession) {
guard self.activeSession == session else { return }
self.activeSession = nil
self.sendDisconnect(session)
}
private func sendDisconnect(_ session: ActiveSession) {
Task { [weak self] in
_ = try? await self?.request(
baseURL: session.baseURL,
path: "disconnect",
method: "POST",
token: session.token)
}
}
private static func lastAcceptedSetupSentAtMs() -> Int64 {
(UserDefaults.standard.object(forKey: self.lastSetupSentAtDefaultsKey) as? NSNumber)?.int64Value ?? 0
}
private static func saveLastAcceptedSetupSentAtMs(_ sentAtMs: Int64) {
UserDefaults.standard.set(NSNumber(value: sentAtMs), forKey: self.lastSetupSentAtDefaultsKey)
}
}
private struct WatchNetworkMetricsSnapshot {
let isCellular: Bool
let isExpensive: Bool
let isConstrained: Bool
}
private final class WatchURLSessionMetrics: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let lock = NSLock()
private var latest: WatchNetworkMetricsSnapshot?
func snapshot() -> WatchNetworkMetricsSnapshot? {
self.lock.lock()
defer { lock.unlock() }
return self.latest
}
func urlSession(
_: URLSession,
task _: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics)
{
guard let transaction = metrics.transactionMetrics.last else { return }
let snapshot = WatchNetworkMetricsSnapshot(
isCellular: transaction.isCellular,
isExpensive: transaction.isExpensive,
isConstrained: transaction.isConstrained)
self.lock.lock()
self.latest = snapshot
self.lock.unlock()
}
func urlSession(
_: URLSession,
task _: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse,
newRequest _: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void)
{
completionHandler(nil)
}
}