mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
refactor(shared): dedupe common OpenClawKit helpers
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T>(
|
||||
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<E: Error>(
|
||||
_ 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<Data, Error>) -> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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[..<zoneIndex])
|
||||
}
|
||||
if host.isEmpty {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" || host == "0.0.0.0" || host == "::" {
|
||||
return true
|
||||
}
|
||||
|
||||
if let ipv4 = IPv4Address(host) {
|
||||
return ipv4.rawValue.first == 127
|
||||
}
|
||||
if let ipv6 = IPv6Address(host) {
|
||||
let bytes = Array(ipv6.rawValue)
|
||||
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
|
||||
if isV6Loopback {
|
||||
return true
|
||||
}
|
||||
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
|
||||
return isMappedV4 && bytes[12] == 127
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public var websocketURL: URL? {
|
||||
let scheme = self.tls ? "wss" : "ws"
|
||||
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
||||
@@ -72,7 +37,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
let scheme = (parsed.scheme ?? "ws").lowercased()
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let tls = scheme == "wss"
|
||||
if !tls, !Self.isLoopbackHost(hostname) {
|
||||
if !tls, !LoopbackHost.isLoopbackHost(hostname) {
|
||||
return nil
|
||||
}
|
||||
let port = parsed.port ?? (tls ? 443 : 18789)
|
||||
@@ -167,7 +132,7 @@ public enum DeepLinkParser {
|
||||
}
|
||||
let port = query["port"].flatMap { Int($0) } ?? 18789
|
||||
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
||||
if !tls, !GatewayConnectDeepLink.isLoopbackHost(hostParam) {
|
||||
if !tls, !LoopbackHost.isLoopbackHost(hostParam) {
|
||||
return nil
|
||||
}
|
||||
return .gateway(
|
||||
|
||||
@@ -45,11 +45,7 @@ public struct WebSocketTaskBox: @unchecked Sendable {
|
||||
public func sendPing() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) 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
|
||||
}
|
||||
|
||||
@@ -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<E: Error>(
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<NWBrowser.Result>) -> 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate {
|
||||
var locationManager: CLLocationManager { get }
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { 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<CLLocation, Error>) -> Void) async throws -> CLLocation
|
||||
{
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
setContinuation(continuation)
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[..<zoneIndex])
|
||||
}
|
||||
if host.isEmpty {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" || host == "0.0.0.0" || host == "::" {
|
||||
return true
|
||||
}
|
||||
|
||||
if let ipv4 = IPv4Address(host) {
|
||||
return ipv4.rawValue.first == 127
|
||||
}
|
||||
if let ipv6 = IPv6Address(host) {
|
||||
let bytes = Array(ipv6.rawValue)
|
||||
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
|
||||
if isV6Loopback {
|
||||
return true
|
||||
}
|
||||
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
|
||||
return isMappedV4 && bytes[12] == 127
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public static func isLocalNetworkHost(_ rawHost: String) -> 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ifaddrs>?
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,17 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
public enum NetworkInterfaces {
|
||||
public static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public enum ThrowingContinuationSupport {
|
||||
public static func resumeVoid(_ continuation: CheckedContinuation<Void, Error>, error: Error?) {
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user