From 2ca57222210a328b055bf22d0e830c2fbcb0e620 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 11:31:57 +0000 Subject: [PATCH] refactor(shared): dedupe common OpenClawKit helpers --- .../OpenClawChatUI/ChatMessageViews.swift | 32 ++-- .../BonjourServiceResolverSupport.swift | 14 ++ .../OpenClawKit/CalendarCommands.swift | 12 +- .../OpenClawKit/CameraAuthorization.swift | 21 +++ .../CameraCapturePipelineSupport.swift | 151 ++++++++++++++++++ .../CameraSessionConfiguration.swift | 70 ++++++++ .../OpenClawKit/CaptureRateLimits.swift | 24 +++ .../Sources/OpenClawKit/DeepLinks.swift | 39 +---- .../Sources/OpenClawKit/GatewayChannel.swift | 9 +- .../GatewayConnectChallengeSupport.swift | 28 ++++ .../GatewayDiscoveryBrowserSupport.swift | 32 ++++ .../OpenClawKit/GatewayNodeSession.swift | 22 +-- .../OpenClawKit/LocalNetworkURLSupport.swift | 13 ++ .../OpenClawKit/LocationCurrentRequest.swift | 44 +++++ .../OpenClawKit/LocationServiceSupport.swift | 49 ++++++ .../Sources/OpenClawKit/LoopbackHost.swift | 80 ++++++++++ .../Sources/OpenClawKit/MotionCommands.swift | 12 +- .../OpenClawKit/NetworkInterfaceIPv4.swift | 43 +++++ .../OpenClawKit/NetworkInterfaces.swift | 38 +---- .../OpenClawDateRangeLimitParams.swift | 13 ++ .../ThrowingContinuationSupport.swift | 11 ++ .../WebViewJavaScriptSupport.swift | 57 +++++++ 22 files changed, 685 insertions(+), 129 deletions(-) create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 22f28517d..8bd230e7b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -488,6 +488,20 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable { } } +private extension View { + func assistantBubbleContainerStyle() -> some View { + self + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(OpenClawChatTheme.assistantBubble)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) + .focusable(false) + } +} + @MainActor struct ChatStreamingAssistantBubble: View { let text: String @@ -498,14 +512,7 @@ struct ChatStreamingAssistantBubble: View { ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant) } .padding(12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(OpenClawChatTheme.assistantBubble)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) - .focusable(false) + .assistantBubbleContainerStyle() } } @@ -542,14 +549,7 @@ struct ChatPendingToolsBubble: View { } } .padding(12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(OpenClawChatTheme.assistantBubble)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) - .focusable(false) + .assistantBubbleContainerStyle() } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift new file mode 100644 index 000000000..604b21ae4 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum BonjourServiceResolverSupport { + public static func start(_ service: NetService, timeout: TimeInterval = 2.0) { + service.schedule(in: .main, forMode: .common) + service.resolve(withTimeout: timeout) + } + + public static func normalizeHost(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift index 9935b81ba..c2b4202d5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift @@ -5,17 +5,7 @@ public enum OpenClawCalendarCommand: String, Codable, Sendable { case add = "calendar.add" } -public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable { - public var startISO: String? - public var endISO: String? - public var limit: Int? - - public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { - self.startISO = startISO - self.endISO = endISO - self.limit = limit - } -} +public typealias OpenClawCalendarEventsParams = OpenClawDateRangeLimitParams public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable { public var title: String diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift new file mode 100644 index 000000000..c7c1182ec --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift @@ -0,0 +1,21 @@ +import AVFoundation + +public enum CameraAuthorization { + public static func isAuthorized(for mediaType: AVMediaType) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: mediaType) + switch status { + case .authorized: + return true + case .notDetermined: + return await withCheckedContinuation(isolation: nil) { cont in + AVCaptureDevice.requestAccess(for: mediaType) { granted in + cont.resume(returning: granted) + } + } + case .denied, .restricted: + return false + @unknown default: + return false + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift new file mode 100644 index 000000000..075761a76 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift @@ -0,0 +1,151 @@ +import AVFoundation +import Foundation + +public enum CameraCapturePipelineSupport { + public static func preparePhotoSession( + preferFrontCamera: Bool, + deviceId: String?, + pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?, + cameraUnavailableError: @autoclosure () -> Error, + mapSetupError: (CameraSessionConfigurationError) -> Error) throws + -> (session: AVCaptureSession, device: AVCaptureDevice, output: AVCapturePhotoOutput) + { + let session = AVCaptureSession() + session.sessionPreset = .photo + + guard let device = pickCamera(preferFrontCamera, deviceId) else { + throw cameraUnavailableError() + } + + do { + try CameraSessionConfiguration.addCameraInput(session: session, camera: device) + let output = try CameraSessionConfiguration.addPhotoOutput(session: session) + return (session, device, output) + } catch let setupError as CameraSessionConfigurationError { + throw mapSetupError(setupError) + } + } + + public static func prepareMovieSession( + preferFrontCamera: Bool, + deviceId: String?, + includeAudio: Bool, + durationMs: Int, + pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?, + cameraUnavailableError: @autoclosure () -> Error, + mapSetupError: (CameraSessionConfigurationError) -> Error) throws + -> (session: AVCaptureSession, output: AVCaptureMovieFileOutput) + { + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let camera = pickCamera(preferFrontCamera, deviceId) else { + throw cameraUnavailableError() + } + + do { + try CameraSessionConfiguration.addCameraInput(session: session, camera: camera) + let output = try CameraSessionConfiguration.addMovieOutput( + session: session, + includeAudio: includeAudio, + durationMs: durationMs) + return (session, output) + } catch let setupError as CameraSessionConfigurationError { + throw mapSetupError(setupError) + } + } + + public static func prepareWarmMovieSession( + preferFrontCamera: Bool, + deviceId: String?, + includeAudio: Bool, + durationMs: Int, + pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?, + cameraUnavailableError: @autoclosure () -> Error, + mapSetupError: (CameraSessionConfigurationError) -> Error) async throws + -> (session: AVCaptureSession, output: AVCaptureMovieFileOutput) + { + let prepared = try self.prepareMovieSession( + preferFrontCamera: preferFrontCamera, + deviceId: deviceId, + includeAudio: includeAudio, + durationMs: durationMs, + pickCamera: pickCamera, + cameraUnavailableError: cameraUnavailableError(), + mapSetupError: mapSetupError) + prepared.session.startRunning() + await self.warmUpCaptureSession() + return prepared + } + + public static func withWarmMovieSession( + preferFrontCamera: Bool, + deviceId: String?, + includeAudio: Bool, + durationMs: Int, + pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?, + cameraUnavailableError: @autoclosure () -> Error, + mapSetupError: (CameraSessionConfigurationError) -> Error, + operation: (AVCaptureMovieFileOutput) async throws -> T) async throws -> T + { + let prepared = try await self.prepareWarmMovieSession( + preferFrontCamera: preferFrontCamera, + deviceId: deviceId, + includeAudio: includeAudio, + durationMs: durationMs, + pickCamera: pickCamera, + cameraUnavailableError: cameraUnavailableError(), + mapSetupError: mapSetupError) + defer { prepared.session.stopRunning() } + return try await operation(prepared.output) + } + + public static func mapMovieSetupError( + _ setupError: CameraSessionConfigurationError, + microphoneUnavailableError: @autoclosure () -> E, + captureFailed: (String) -> E) -> E + { + if case .microphoneUnavailable = setupError { + return microphoneUnavailableError() + } + return captureFailed(setupError.localizedDescription) + } + + public static func makePhotoSettings(output: AVCapturePhotoOutput) -> AVCapturePhotoSettings { + let settings: AVCapturePhotoSettings = { + if output.availablePhotoCodecTypes.contains(.jpeg) { + return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) + } + return AVCapturePhotoSettings() + }() + settings.photoQualityPrioritization = .quality + return settings + } + + public static func capturePhotoData( + output: AVCapturePhotoOutput, + makeDelegate: (CheckedContinuation) -> any AVCapturePhotoCaptureDelegate) async throws -> Data + { + var delegate: (any AVCapturePhotoCaptureDelegate)? + let rawData: Data = try await withCheckedThrowingContinuation { cont in + let captureDelegate = makeDelegate(cont) + delegate = captureDelegate + output.capturePhoto(with: self.makePhotoSettings(output: output), delegate: captureDelegate) + } + withExtendedLifetime(delegate) {} + return rawData + } + + public static func warmUpCaptureSession() async { + // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + } + + public static func positionLabel(_ position: AVCaptureDevice.Position) -> String { + switch position { + case .front: "front" + case .back: "back" + default: "unspecified" + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift new file mode 100644 index 000000000..748315ebc --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift @@ -0,0 +1,70 @@ +import AVFoundation +import CoreMedia + +public enum CameraSessionConfigurationError: LocalizedError { + case addCameraInputFailed + case addPhotoOutputFailed + case microphoneUnavailable + case addMicrophoneInputFailed + case addMovieOutputFailed + + public var errorDescription: String? { + switch self { + case .addCameraInputFailed: + "Failed to add camera input" + case .addPhotoOutputFailed: + "Failed to add photo output" + case .microphoneUnavailable: + "Microphone unavailable" + case .addMicrophoneInputFailed: + "Failed to add microphone input" + case .addMovieOutputFailed: + "Failed to add movie output" + } + } +} + +public enum CameraSessionConfiguration { + public static func addCameraInput(session: AVCaptureSession, camera: AVCaptureDevice) throws { + let input = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(input) else { + throw CameraSessionConfigurationError.addCameraInputFailed + } + session.addInput(input) + } + + public static func addPhotoOutput(session: AVCaptureSession) throws -> AVCapturePhotoOutput { + let output = AVCapturePhotoOutput() + guard session.canAddOutput(output) else { + throw CameraSessionConfigurationError.addPhotoOutputFailed + } + session.addOutput(output) + output.maxPhotoQualityPrioritization = .quality + return output + } + + public static func addMovieOutput( + session: AVCaptureSession, + includeAudio: Bool, + durationMs: Int) throws -> AVCaptureMovieFileOutput + { + if includeAudio { + guard let mic = AVCaptureDevice.default(for: .audio) else { + throw CameraSessionConfigurationError.microphoneUnavailable + } + let micInput = try AVCaptureDeviceInput(device: mic) + guard session.canAddInput(micInput) else { + throw CameraSessionConfigurationError.addMicrophoneInputFailed + } + session.addInput(micInput) + } + + let output = AVCaptureMovieFileOutput() + guard session.canAddOutput(output) else { + throw CameraSessionConfigurationError.addMovieOutputFailed + } + session.addOutput(output) + output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) + return output + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift new file mode 100644 index 000000000..5b95bf6bf --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum CaptureRateLimits { + public static func clampDurationMs( + _ ms: Int?, + defaultMs: Int = 10_000, + minMs: Int = 250, + maxMs: Int = 60_000) -> Int + { + let value = ms ?? defaultMs + return min(maxMs, max(minMs, value)) + } + + public static func clampFps( + _ fps: Double?, + defaultFps: Double = 10, + minFps: Double = 1, + maxFps: Double) -> Double + { + let value = fps ?? defaultFps + guard value.isFinite else { return defaultFps } + return min(maxFps, max(minFps, value)) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 507148846..20b376166 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -1,5 +1,4 @@ import Foundation -import Network public enum DeepLinkRoute: Sendable, Equatable { case agent(AgentDeepLink) @@ -21,40 +20,6 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { self.password = password } - fileprivate static func isLoopbackHost(_ raw: String) -> Bool { - var host = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) - if host.hasSuffix(".") { - host.removeLast() - } - if let zoneIndex = host.firstIndex(of: "%") { - host = String(host[..) in self.task.sendPing { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } + ThrowingContinuationSupport.resumeVoid(continuation, error: error) } } } @@ -560,8 +556,7 @@ public actor GatewayChannelActor { guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } if case let .event(evt) = frame, evt.event == "connect.challenge", let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String, - nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + let nonce = GatewayConnectChallengeSupport.nonce(from: payload) { return nonce } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift new file mode 100644 index 000000000..f2ad187bc --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift @@ -0,0 +1,28 @@ +import Foundation +import OpenClawProtocol + +public enum GatewayConnectChallengeSupport { + public static func nonce(from payload: [String: OpenClawProtocol.AnyCodable]?) -> String? { + guard let nonce = payload?["nonce"]?.value as? String else { return nil } + let trimmed = nonce.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + + public static func waitForNonce( + timeoutSeconds: Double, + onTimeout: @escaping @Sendable () -> E, + receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String + { + try await AsyncTimeout.withTimeout( + seconds: timeoutSeconds, + onTimeout: onTimeout, + operation: { + while true { + if let nonce = try await receiveNonce() { + return nonce + } + } + }) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift new file mode 100644 index 000000000..4f477b92a --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift @@ -0,0 +1,32 @@ +import Foundation +import Network + +public enum GatewayDiscoveryBrowserSupport { + @MainActor + public static func makeBrowser( + serviceType: String, + domain: String, + queueLabelPrefix: String, + onState: @escaping @MainActor (NWBrowser.State) -> Void, + onResults: @escaping @MainActor (Set) -> Void) -> NWBrowser + { + let params = NWParameters.tcp + params.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjour(type: serviceType, domain: domain), + using: params) + + browser.stateUpdateHandler = { state in + Task { @MainActor in + onState(state) + } + } + browser.browseResultsChangedHandler = { results, _ in + Task { @MainActor in + onResults(results) + } + } + browser.start(queue: DispatchQueue(label: "\(queueLabelPrefix).\(domain)")) + return browser + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 7dd2fe1ee..a3c09ff35 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -293,13 +293,7 @@ public actor GatewayNodeSession { private func resetConnectionState() { self.hasNotifiedConnected = false self.snapshotReceived = false - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: false) - } - } + self.drainSnapshotWaiters(returning: false) } private func handleChannelDisconnected(_ reason: String) async { @@ -311,13 +305,7 @@ public actor GatewayNodeSession { private func markSnapshotReceived() { self.snapshotReceived = true - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: true) - } - } + self.drainSnapshotWaiters(returning: true) } private func waitForSnapshot(timeoutMs: Int) async -> Bool { @@ -335,11 +323,15 @@ public actor GatewayNodeSession { private func timeoutSnapshotWaiters() { guard !self.snapshotReceived else { return } + self.drainSnapshotWaiters(returning: false) + } + + private func drainSnapshotWaiters(returning value: Bool) { if !self.snapshotWaiters.isEmpty { let waiters = self.snapshotWaiters self.snapshotWaiters.removeAll() for waiter in waiters { - waiter.resume(returning: false) + waiter.resume(returning: value) } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift new file mode 100644 index 000000000..86177b481 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum LocalNetworkURLSupport { + public static func isLocalNetworkHTTPURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return false + } + return LoopbackHost.isLocalNetworkHost(host) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift new file mode 100644 index 000000000..80038d601 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift @@ -0,0 +1,44 @@ +import CoreLocation +import Foundation + +public enum LocationCurrentRequest { + public typealias TimeoutRunner = @Sendable ( + _ timeoutMs: Int, + _ operation: @escaping @Sendable () async throws -> CLLocation + ) async throws -> CLLocation + + @MainActor + public static func resolve( + manager: CLLocationManager, + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?, + request: @escaping @Sendable () async throws -> CLLocation, + withTimeout: TimeoutRunner) async throws -> CLLocation + { + let now = Date() + if let maxAgeMs, + let cached = manager.location, + now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs) + { + return cached + } + + manager.desiredAccuracy = self.accuracyValue(desiredAccuracy) + let timeout = max(0, timeoutMs ?? 10000) + return try await withTimeout(timeout) { + try await request() + } + } + + public static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy { + switch accuracy { + case .coarse: + kCLLocationAccuracyKilometer + case .balanced: + kCLLocationAccuracyHundredMeters + case .precise: + kCLLocationAccuracyBest + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift new file mode 100644 index 000000000..1a818c6c2 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift @@ -0,0 +1,49 @@ +import CoreLocation +import Foundation + +@MainActor +public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate { + var locationManager: CLLocationManager { get } + var locationRequestContinuation: CheckedContinuation? { get set } +} + +public extension LocationServiceCommon { + func configureLocationManager() { + self.locationManager.delegate = self + self.locationManager.desiredAccuracy = kCLLocationAccuracyBest + } + + func authorizationStatus() -> CLAuthorizationStatus { + self.locationManager.authorizationStatus + } + + func accuracyAuthorization() -> CLAccuracyAuthorization { + LocationServiceSupport.accuracyAuthorization(manager: self.locationManager) + } + + func requestLocationOnce() async throws -> CLLocation { + try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in + self.locationRequestContinuation = continuation + } + } +} + +public enum LocationServiceSupport { + public static func accuracyAuthorization(manager: CLLocationManager) -> CLAccuracyAuthorization { + if #available(iOS 14.0, macOS 11.0, *) { + return manager.accuracyAuthorization + } + return .fullAccuracy + } + + @MainActor + public static func requestLocation( + manager: CLLocationManager, + setContinuation: @escaping (CheckedContinuation) -> Void) async throws -> CLLocation + { + try await withCheckedThrowingContinuation { continuation in + setContinuation(continuation) + manager.requestLocation() + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift new file mode 100644 index 000000000..265e31233 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift @@ -0,0 +1,80 @@ +import Foundation +import Network + +public enum LoopbackHost { + public static func isLoopback(_ rawHost: String) -> Bool { + self.isLoopbackHost(rawHost) + } + + public static func isLoopbackHost(_ rawHost: String) -> Bool { + var host = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. Bool { + let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !host.isEmpty else { return false } + if self.isLoopbackHost(host) { return true } + if host.hasSuffix(".local") { return true } + if host.hasSuffix(".ts.net") { return true } + if host.hasSuffix(".tailscale.net") { return true } + // Allow MagicDNS / LAN hostnames like "peters-mac-studio-1". + if !host.contains("."), !host.contains(":") { return true } + guard let ipv4 = self.parseIPv4(host) else { return false } + return self.isLocalNetworkIPv4(ipv4) + } + + private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes: [UInt8] = parts.compactMap { UInt8($0) } + guard bytes.count == 4 else { return nil } + return (bytes[0], bytes[1], bytes[2], bytes[3]) + } + + private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + let (a, b, _, _) = ip + // 10.0.0.0/8 + if a == 10 { return true } + // 172.16.0.0/12 + if a == 172, (16...31).contains(Int(b)) { return true } + // 192.168.0.0/16 + if a == 192, b == 168 { return true } + // 127.0.0.0/8 + if a == 127 { return true } + // 169.254.0.0/16 (link-local) + if a == 169, b == 254 { return true } + // Tailscale: 100.64.0.0/10 + if a == 100, (64...127).contains(Int(b)) { return true } + return false + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift index ab487bfd0..04d0ec4eb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift @@ -5,17 +5,7 @@ public enum OpenClawMotionCommand: String, Codable, Sendable { case pedometer = "motion.pedometer" } -public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable { - public var startISO: String? - public var endISO: String? - public var limit: Int? - - public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { - self.startISO = startISO - self.endISO = endISO - self.limit = limit - } -} +public typealias OpenClawMotionActivityParams = OpenClawDateRangeLimitParams public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable { public var startISO: String diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift new file mode 100644 index 000000000..57f2b08b9 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift @@ -0,0 +1,43 @@ +import Darwin +import Foundation + +public enum NetworkInterfaceIPv4 { + public struct AddressEntry: Sendable { + public let name: String + public let ip: String + } + + public static func addresses() -> [AddressEntry] { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return [] } + defer { freeifaddrs(addrList) } + + var entries: [AddressEntry] = [] + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + let name = String(cString: ptr.pointee.ifa_name) + entries.append(AddressEntry(name: name, ip: ip)) + } + return entries + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift index 3679ef542..ac554e833 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift @@ -1,43 +1,17 @@ -import Darwin import Foundation public enum NetworkInterfaces { public static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - var fallback: String? var en0: String? - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let name = String(cString: ptr.pointee.ifa_name) - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - - if name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } + for entry in NetworkInterfaceIPv4.addresses() { + if entry.name == "en0" { + en0 = entry.ip + break + } + if fallback == nil { fallback = entry.ip } } return en0 ?? fallback } } - diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift new file mode 100644 index 000000000..5ff0b1170 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct OpenClawDateRangeLimitParams: Codable, Sendable, Equatable { + public var startISO: String? + public var endISO: String? + public var limit: Int? + + public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { + self.startISO = startISO + self.endISO = endISO + self.limit = limit + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift new file mode 100644 index 000000000..42b22c95d --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum ThrowingContinuationSupport { + public static func resumeVoid(_ continuation: CheckedContinuation, error: Error?) { + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift new file mode 100644 index 000000000..2a9b37cb9 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift @@ -0,0 +1,57 @@ +import Foundation +import WebKit + +public enum WebViewJavaScriptSupport { + @MainActor + public static func applyDebugStatus( + webView: WKWebView, + enabled: Bool, + title: String?, + subtitle: String?) + { + let js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api) return; + if (typeof api.setDebugStatusEnabled === 'function') { + api.setDebugStatusEnabled(\(enabled ? "true" : "false")); + } + if (!\(enabled ? "true" : "false")) return; + if (typeof api.setStatus === 'function') { + api.setStatus(\(self.jsValue(title)), \(self.jsValue(subtitle))); + } + } catch (_) {} + })() + """ + webView.evaluateJavaScript(js) { _, _ in } + } + + @MainActor + public static func evaluateToString(webView: WKWebView, javaScript: String) async throws -> String { + try await withCheckedThrowingContinuation { cont in + webView.evaluateJavaScript(javaScript) { result, error in + if let error { + cont.resume(throwing: error) + return + } + if let result { + cont.resume(returning: String(describing: result)) + } else { + cont.resume(returning: "") + } + } + } + } + + public static func jsValue(_ value: String?) -> String { + guard let value else { return "null" } + if let data = try? JSONSerialization.data(withJSONObject: [value]), + let encoded = String(data: data, encoding: .utf8), + encoded.count >= 2 + { + return String(encoded.dropFirst().dropLast()) + } + return "null" + } +}