From cf67e374c0da9868e4f8e97428c2d050a13396cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 11:32:04 +0000 Subject: [PATCH] refactor(macos): dedupe UI, pairing, and runtime helpers --- .../OpenClaw/AgentWorkspaceConfig.swift | 30 ++ .../OpenClaw/AudioInputDeviceObserver.swift | 33 +-- .../OpenClaw/CameraCaptureService.swift | 133 +++------ .../CanvasA2UIActionMessageHandler.swift | 35 +-- .../Sources/OpenClaw/CanvasFileWatcher.swift | 19 +- .../CanvasWindowController+Testing.swift | 15 +- .../OpenClaw/CanvasWindowController.swift | 38 +-- .../ChannelsSettings+ChannelState.swift | 272 ++++++++++-------- .../OpenClaw/ChannelsSettings+View.swift | 9 +- .../Sources/OpenClaw/ColorHexSupport.swift | 14 + .../Sources/OpenClaw/ConfigFileWatcher.swift | 19 +- .../Sources/OpenClaw/ConfigSettings.swift | 9 +- .../OpenClaw/ContextMenuCardView.swift | 59 ++-- .../Sources/OpenClaw/ControlChannel.swift | 12 +- .../OpenClaw/CronJobEditor+Helpers.swift | 10 +- .../Sources/OpenClaw/CronJobsStore.swift | 18 +- .../OpenClaw/CronSettings+Helpers.swift | 10 +- .../DevicePairingApprovalPrompter.swift | 139 ++++----- .../OpenClaw/DurationFormattingSupport.swift | 15 + .../ExecApprovalsGatewayPrompter.swift | 6 +- .../OpenClaw/ExecApprovalsSocket.swift | 52 ++-- .../OpenClaw/ExecEnvInvocationUnwrapper.swift | 17 +- .../Sources/OpenClaw/ExecEnvOptions.swift | 29 ++ .../ExecSystemRunCommandValidator.swift | 93 +++--- .../GatewayDiscoverySelectionSupport.swift | 22 ++ .../OpenClaw/GatewayLaunchAgentManager.swift | 20 +- .../OpenClaw/GatewayPushSubscription.swift | 34 +++ .../OpenClaw/GatewayRemoteConfig.swift | 38 +-- .../Sources/OpenClaw/GeneralSettings.swift | 54 ++-- apps/macos/Sources/OpenClaw/HoverHUD.swift | 58 +--- .../Sources/OpenClaw/InstancesSettings.swift | 44 +-- .../Sources/OpenClaw/InstancesStore.swift | 27 +- .../JSONObjectExtractionSupport.swift | 16 ++ .../OpenClaw/Logging/OpenClawLogging.swift | 75 ++--- apps/macos/Sources/OpenClaw/MenuBar.swift | 12 +- .../Sources/OpenClaw/MenuContentView.swift | 48 +--- .../Sources/OpenClaw/MenuHeaderCard.swift | 52 ++++ .../OpenClaw/MenuHighlightedHostView.swift | 12 +- .../OpenClaw/MenuItemHighlightColors.swift | 22 ++ .../OpenClaw/MenuSessionsHeaderView.swift | 34 +-- .../OpenClaw/MenuUsageHeaderView.swift | 25 +- .../Sources/OpenClaw/MicRefreshSupport.swift | 46 +++ .../NodeMode/MacNodeLocationService.swift | 65 ++--- .../NodePairingApprovalPrompter.swift | 141 ++++----- .../Sources/OpenClaw/NodeServiceManager.swift | 20 +- apps/macos/Sources/OpenClaw/NodesMenu.swift | 72 ++--- apps/macos/Sources/OpenClaw/NodesStore.swift | 10 +- .../Sources/OpenClaw/NotifyOverlay.swift | 66 +---- .../OpenClaw/OnboardingView+Actions.swift | 14 +- .../OpenClaw/OnboardingView+Layout.swift | 36 +-- .../OpenClaw/OnboardingView+Monitoring.swift | 15 +- .../OpenClaw/OnboardingView+Workspace.swift | 23 +- .../Sources/OpenClaw/OpenClawConfigFile.swift | 29 +- .../OpenClaw/OverlayPanelFactory.swift | 126 ++++++++ .../OpenClaw/PairingAlertSupport.swift | 198 +++++++++++++ .../Sources/OpenClaw/PermissionManager.swift | 40 +-- .../PermissionMonitoringSupport.swift | 20 ++ .../OpenClaw/PlatformLabelFormatter.swift | 31 ++ .../Sources/OpenClaw/RemotePortTunnel.swift | 15 +- .../OpenClaw/ScreenRecordService.swift | 16 +- .../OpenClaw/SessionMenuLabelView.swift | 14 +- .../Sources/OpenClaw/SessionsSettings.swift | 12 +- .../OpenClaw/SettingsRefreshButton.swift | 18 ++ .../Sources/OpenClaw/SettingsRootView.swift | 13 +- .../OpenClaw/SettingsSidebarCard.swift | 12 + .../OpenClaw/SettingsSidebarScroll.swift | 14 + .../Sources/OpenClaw/SimpleFileWatcher.swift | 21 ++ .../OpenClaw/SimpleFileWatcherOwner.swift | 15 + .../Sources/OpenClaw/SimpleTaskSupport.swift | 31 ++ .../OpenClaw/SystemSettingsURLSupport.swift | 12 + apps/macos/Sources/OpenClaw/TalkOverlay.swift | 51 +--- .../Sources/OpenClaw/TalkOverlayView.swift | 13 +- .../Sources/OpenClaw/TextSummarySupport.swift | 16 ++ .../OpenClaw/TrackingAreaSupport.swift | 22 ++ .../Sources/OpenClaw/UsageCostData.swift | 93 +++++- .../Sources/OpenClaw/UsageMenuLabelView.swift | 14 +- .../OpenClaw/VoiceOverlayTextFormatting.swift | 27 ++ .../Sources/OpenClaw/VoicePushToTalk.swift | 37 +-- .../VoiceWakeGlobalSettingsSync.swift | 6 +- .../VoiceWakeOverlayController+Window.swift | 65 ++--- .../VoiceWakeRecognitionDebugSupport.swift | 62 ++++ .../Sources/OpenClaw/VoiceWakeRuntime.swift | 88 ++---- .../Sources/OpenClaw/VoiceWakeSettings.swift | 30 +- .../Sources/OpenClaw/VoiceWakeTester.swift | 56 ++-- .../Sources/OpenClaw/WebChatManager.swift | 8 +- .../Sources/OpenClaw/WebChatSwiftUI.swift | 14 +- .../Sources/OpenClaw/WorkActivityStore.swift | 12 +- .../GatewayDiscoveryModel.swift | 32 +-- .../OpenClawDiscovery/TailscaleNetwork.swift | 31 +- .../OpenClawMacCLI/CLIArgParsingSupport.swift | 9 + .../OpenClawMacCLI/ConnectCommand.swift | 42 ++- .../OpenClawMacCLI/WizardCommand.swift | 19 +- 92 files changed, 1769 insertions(+), 1802 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift create mode 100644 apps/macos/Sources/OpenClaw/ColorHexSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/ExecEnvOptions.swift create mode 100644 apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift create mode 100644 apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/MenuHeaderCard.swift create mode 100644 apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift create mode 100644 apps/macos/Sources/OpenClaw/MicRefreshSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift create mode 100644 apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift create mode 100644 apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift create mode 100644 apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift create mode 100644 apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift create mode 100644 apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift create mode 100644 apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift create mode 100644 apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/TextSummarySupport.swift create mode 100644 apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift create mode 100644 apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift create mode 100644 apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift create mode 100644 apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift b/apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift new file mode 100644 index 00000000000..a7a5ade51d6 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift @@ -0,0 +1,30 @@ +import Foundation + +enum AgentWorkspaceConfig { + static func workspace(from root: [String: Any]) -> String? { + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String + } + + static func setWorkspace(in root: inout [String: Any], workspace: String?) { + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + defaults.removeValue(forKey: "workspace") + } else { + defaults["workspace"] = trimmed + } + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents + } + } +} diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift index 6c01628144b..43d92a8dd1e 100644 --- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift +++ b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift @@ -9,21 +9,7 @@ final class AudioInputDeviceObserver { private var defaultInputListener: AudioObjectPropertyListenerBlock? static func defaultInputDeviceUID() -> String? { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var deviceID = AudioObjectID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - systemObject, - &address, - 0, - nil, - &size, - &deviceID) - guard status == noErr, deviceID != 0 else { return nil } + guard let deviceID = self.defaultInputDeviceID() else { return nil } return self.deviceUID(for: deviceID) } @@ -63,6 +49,15 @@ final class AudioInputDeviceObserver { } static func defaultInputDeviceSummary() -> String { + guard let deviceID = self.defaultInputDeviceID() else { + return "defaultInput=unknown" + } + let uid = self.deviceUID(for: deviceID) ?? "unknown" + let name = self.deviceName(for: deviceID) ?? "unknown" + return "defaultInput=\(name) (\(uid))" + } + + private static func defaultInputDeviceID() -> AudioObjectID? { let systemObject = AudioObjectID(kAudioObjectSystemObject) var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, @@ -77,12 +72,8 @@ final class AudioInputDeviceObserver { nil, &size, &deviceID) - guard status == noErr, deviceID != 0 else { - return "defaultInput=unknown" - } - let uid = self.deviceUID(for: deviceID) ?? "unknown" - let name = self.deviceName(for: deviceID) ?? "unknown" - return "defaultInput=\(name) (\(uid))" + guard status == noErr, deviceID != 0 else { return nil } + return deviceID } func start(onChange: @escaping @Sendable () -> Void) { diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift index 4e3749d6a68..83a091bb558 100644 --- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift +++ b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift @@ -64,45 +64,33 @@ actor CameraCaptureService { try await self.ensureAccess(for: .video) - let session = AVCaptureSession() - session.sessionPreset = .photo - - guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else { - throw CameraError.cameraUnavailable - } - - let input = try AVCaptureDeviceInput(device: device) - guard session.canAddInput(input) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(input) - - let output = AVCapturePhotoOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add photo output") - } - session.addOutput(output) - output.maxPhotoQualityPrioritization = .quality + let prepared = try CameraCapturePipelineSupport.preparePhotoSession( + preferFrontCamera: facing == .front, + deviceId: deviceId, + pickCamera: { preferFrontCamera, deviceId in + Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId) + }, + cameraUnavailableError: CameraError.cameraUnavailable, + mapSetupError: { setupError in + CameraError.captureFailed(setupError.localizedDescription) + }) + let session = prepared.session + let device = prepared.device + let output = prepared.output session.startRunning() defer { session.stopRunning() } - await Self.warmUpCaptureSession() + await CameraCapturePipelineSupport.warmUpCaptureSession() await self.waitForExposureAndWhiteBalance(device: device) await self.sleepDelayMs(delayMs) - let settings: AVCapturePhotoSettings = { - if output.availablePhotoCodecTypes.contains(.jpeg) { - return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) - } - return AVCapturePhotoSettings() - }() - settings.photoQualityPrioritization = .quality - var delegate: PhotoCaptureDelegate? - let rawData: Data = try await withCheckedThrowingContinuation { cont in - let d = PhotoCaptureDelegate(cont) - delegate = d - output.capturePhoto(with: settings, delegate: d) + let rawData: Data = try await withCheckedThrowingContinuation { continuation in + let captureDelegate = PhotoCaptureDelegate(continuation) + delegate = captureDelegate + output.capturePhoto( + with: CameraCapturePipelineSupport.makePhotoSettings(output: output), + delegate: captureDelegate) } withExtendedLifetime(delegate) {} @@ -135,39 +123,19 @@ actor CameraCaptureService { try await self.ensureAccess(for: .audio) } - let session = AVCaptureSession() - session.sessionPreset = .high - - guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else { - throw CameraError.cameraUnavailable - } - let cameraInput = try AVCaptureDeviceInput(device: camera) - guard session.canAddInput(cameraInput) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(cameraInput) - - if includeAudio { - guard let mic = AVCaptureDevice.default(for: .audio) else { - throw CameraError.microphoneUnavailable - } - let micInput = try AVCaptureDeviceInput(device: mic) - guard session.canAddInput(micInput) else { - throw CameraError.captureFailed("Failed to add microphone input") - } - session.addInput(micInput) - } - - let output = AVCaptureMovieFileOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add movie output") - } - session.addOutput(output) - output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) - - session.startRunning() + let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession( + preferFrontCamera: facing == .front, + deviceId: deviceId, + includeAudio: includeAudio, + durationMs: durationMs, + pickCamera: { preferFrontCamera, deviceId in + Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId) + }, + cameraUnavailableError: CameraError.cameraUnavailable, + mapSetupError: Self.mapMovieSetupError) + let session = prepared.session + let output = prepared.output defer { session.stopRunning() } - await Self.warmUpCaptureSession() let tmpMovURL = FileManager().temporaryDirectory .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov") @@ -180,7 +148,6 @@ actor CameraCaptureService { return FileManager().temporaryDirectory .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4") }() - // Ensure we don't fail exporting due to an existing file. try? FileManager().removeItem(at: outputURL) @@ -192,28 +159,12 @@ actor CameraCaptureService { output.startRecording(to: tmpMovURL, recordingDelegate: d) } withExtendedLifetime(delegate) {} - try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL) return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio) } private func ensureAccess(for mediaType: AVMediaType) async throws { - let status = AVCaptureDevice.authorizationStatus(for: mediaType) - switch status { - case .authorized: - return - case .notDetermined: - let ok = await withCheckedContinuation(isolation: nil) { cont in - AVCaptureDevice.requestAccess(for: mediaType) { granted in - cont.resume(returning: granted) - } - } - if !ok { - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - case .denied, .restricted: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - @unknown default: + if !(await CameraAuthorization.isAuthorized(for: mediaType)) { throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") } } @@ -278,6 +229,13 @@ actor CameraCaptureService { return min(60000, max(250, v)) } + private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError { + CameraCapturePipelineSupport.mapMovieSetupError( + setupError, + microphoneUnavailableError: .microphoneUnavailable, + captureFailed: { .captureFailed($0) }) + } + private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { let asset = AVURLAsset(url: inputURL) guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { @@ -315,11 +273,6 @@ actor CameraCaptureService { } } - private nonisolated 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 - } - private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async { let stepNs: UInt64 = 50_000_000 let maxSteps = 30 // ~1.5s @@ -338,11 +291,7 @@ actor CameraCaptureService { } private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { - switch position { - case .front: "front" - case .back: "back" - default: "unspecified" - } + CameraCapturePipelineSupport.positionLabel(position) } } diff --git a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift index 40f443c5c8b..4f47ea835df 100644 --- a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift +++ b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift @@ -109,40 +109,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { } static func isLocalNetworkCanvasURL(_ 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 - } - if host == "localhost" { return true } - if host.hasSuffix(".local") { return true } - if host.hasSuffix(".ts.net") { return true } - if host.hasSuffix(".tailscale.net") { return true } - if !host.contains("."), !host.contains(":") { return true } - if let ipv4 = Self.parseIPv4(host) { - return Self.isLocalNetworkIPv4(ipv4) - } - return false - } - - 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]) - } - - static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - let (a, b, _, _) = ip - if a == 10 { return true } - if a == 172, (16...31).contains(Int(b)) { return true } - if a == 192, b == 168 { return true } - if a == 127 { return true } - if a == 169, b == 254 { return true } - if a == 100, (64...127).contains(Int(b)) { return true } - return false + LocalNetworkURLSupport.isLocalNetworkHTTPURL(url) } // Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`). diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift index 3ed0d67ffbc..02f69bc91fa 100644 --- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift @@ -1,24 +1,13 @@ import Foundation -final class CanvasFileWatcher: @unchecked Sendable { - private let watcher: CoalescingFSEventsWatcher +final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner { + let watcher: SimpleFileWatcher init(url: URL, onChange: @escaping () -> Void) { - self.watcher = CoalescingFSEventsWatcher( + self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher( paths: [url.path], queueLabel: "ai.openclaw.canvaswatcher", - onChange: onChange) + onChange: onChange)) } - deinit { - self.stop() - } - - func start() { - self.watcher.start() - } - - func stop() { - self.watcher.stop() - } } diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift index 6c53fbc9971..c2442d7e17b 100644 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift +++ b/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift @@ -25,11 +25,22 @@ extension CanvasWindowController { } static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { - CanvasA2UIActionMessageHandler.parseIPv4(host) + 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]) } static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip) + let (a, b, _, _) = ip + if a == 10 { return true } + if a == 172, (16...31).contains(Int(b)) { return true } + if a == 192, b == 168 { return true } + if a == 127 { return true } + if a == 169, b == 254 { return true } + if a == 100, (64...127).contains(Int(b)) { return true } + return false } static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool { diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift index d30f54186ae..8017304087e 100644 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift +++ b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift @@ -274,25 +274,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS } func applyDebugStatusIfNeeded() { - let enabled = self.debugStatusEnabled - let title = Self.jsOptionalStringLiteral(self.debugStatusTitle) - let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle) - 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(\(title), \(subtitle)); - } - } catch (_) {} - })(); - """ - self.webView.evaluateJavaScript(js) { _, _ in } + WebViewJavaScriptSupport.applyDebugStatus( + webView: self.webView, + enabled: self.debugStatusEnabled, + title: self.debugStatusTitle, + subtitle: self.debugStatusSubtitle) } private func loadFile(_ url: URL) { @@ -302,19 +288,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS } func eval(javaScript: String) async throws -> String { - try await withCheckedThrowingContinuation { cont in - self.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: "") - } - } - } + try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript) } func snapshot(to outPath: String?) async throws -> String { diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift index 5be5818425b..10ca93f73e0 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift @@ -9,6 +9,90 @@ extension ChannelsSettings { self.store.snapshot?.decodeChannel(id, as: type) } + private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color { + if !configured { return .secondary } + if hasError { return .orange } + if probeOk == false { return .orange } + if running { return .green } + return .orange + } + + private func configuredChannelSummary(configured: Bool, running: Bool) -> String { + if !configured { return "Not configured" } + if running { return "Running" } + return "Configured" + } + + private func appendProbeDetails( + lines: inout [String], + probeOk: Bool?, + probeStatus: Int?, + probeElapsedMs: Double?, + probeVersion: String? = nil, + probeError: String? = nil, + lastProbeAtMs: Double?, + lastError: String?) + { + if let probeOk { + if probeOk { + if let version = probeVersion, !version.isEmpty { + lines.append("Version \(version)") + } + if let elapsed = probeElapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else if let probeError, !probeError.isEmpty { + lines.append("Probe error: \(probeError)") + } else { + let code = probeStatus.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: lastProbeAtMs) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let lastError, !lastError.isEmpty { + lines.append("Error: \(lastError)") + } + } + + private func finishDetails( + lines: inout [String], + probeOk: Bool?, + probeStatus: Int?, + probeElapsedMs: Double?, + probeVersion: String? = nil, + probeError: String? = nil, + lastProbeAtMs: Double?, + lastError: String?) -> String? + { + self.appendProbeDetails( + lines: &lines, + probeOk: probeOk, + probeStatus: probeStatus, + probeElapsedMs: probeElapsedMs, + probeVersion: probeVersion, + probeError: probeError, + lastProbeAtMs: lastProbeAtMs, + lastError: lastError) + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + private func finishProbeDetails( + lines: inout [String], + probe: (ok: Bool?, status: Int?, elapsedMs: Double?), + lastProbeAtMs: Double?, + lastError: String?) -> String? + { + self.finishDetails( + lines: &lines, + probeOk: probe.ok, + probeStatus: probe.status, + probeElapsedMs: probe.elapsedMs, + lastProbeAtMs: lastProbeAtMs, + lastError: lastError) + } + var whatsAppTint: Color { guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) else { return .secondary } @@ -23,51 +107,51 @@ extension ChannelsSettings { var telegramTint: Color { guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange + return self.configuredChannelTint( + configured: status.configured, + running: status.running, + hasError: status.lastError != nil, + probeOk: status.probe?.ok) } var discordTint: Color { guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange + return self.configuredChannelTint( + configured: status.configured, + running: status.running, + hasError: status.lastError != nil, + probeOk: status.probe?.ok) } var googlechatTint: Color { guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange + return self.configuredChannelTint( + configured: status.configured, + running: status.running, + hasError: status.lastError != nil, + probeOk: status.probe?.ok) } var signalTint: Color { guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange + return self.configuredChannelTint( + configured: status.configured, + running: status.running, + hasError: status.lastError != nil, + probeOk: status.probe?.ok) } var imessageTint: Color { guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange + return self.configuredChannelTint( + configured: status.configured, + running: status.running, + hasError: status.lastError != nil, + probeOk: status.probe?.ok) } var whatsAppSummary: String { @@ -82,41 +166,31 @@ extension ChannelsSettings { var telegramSummary: String { guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" + return self.configuredChannelSummary(configured: status.configured, running: status.running) } var discordSummary: String { guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" + return self.configuredChannelSummary(configured: status.configured, running: status.running) } var googlechatSummary: String { guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" + return self.configuredChannelSummary(configured: status.configured, running: status.running) } var signalSummary: String { guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" + return self.configuredChannelSummary(configured: status.configured, running: status.running) } var imessageSummary: String { guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" + return self.configuredChannelSummary(configured: status.configured, running: status.running) } var whatsAppDetails: String? { @@ -168,18 +242,15 @@ extension ChannelsSettings { if let url = probe.webhook?.url, !url.isEmpty { lines.append("Webhook: \(url)") } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") } } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") + return self.finishDetails( + lines: &lines, + probeOk: status.probe?.ok, + probeStatus: status.probe?.status, + probeElapsedMs: nil, + lastProbeAtMs: status.lastProbeAt, + lastError: status.lastError) } var discordDetails: String? { @@ -189,26 +260,17 @@ extension ChannelsSettings { if let source = status.tokenSource { lines.append("Token source: \(source)") } - if let probe = status.probe { - if probe.ok { - if let name = probe.bot?.username { - lines.append("Bot: @\(name)") - } - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } + if let name = status.probe?.bot?.username, !name.isEmpty { + lines.append("Bot: @\(name)") } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") + return self.finishProbeDetails( + lines: &lines, + probe: ( + ok: status.probe?.ok, + status: status.probe?.status, + elapsedMs: status.probe?.elapsedMs), + lastProbeAtMs: status.lastProbeAt, + lastError: status.lastError) } var googlechatDetails: String? { @@ -223,23 +285,14 @@ extension ChannelsSettings { let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)" lines.append("Audience: \(label)") } - if let probe = status.probe { - if probe.ok { - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") + return self.finishProbeDetails( + lines: &lines, + probe: ( + ok: status.probe?.ok, + status: status.probe?.status, + elapsedMs: status.probe?.elapsedMs), + lastProbeAtMs: status.lastProbeAt, + lastError: status.lastError) } var signalDetails: String? { @@ -247,26 +300,14 @@ extension ChannelsSettings { else { return nil } var lines: [String] = [] lines.append("Base URL: \(status.baseUrl)") - if let probe = status.probe { - if probe.ok { - if let version = probe.version, !version.isEmpty { - lines.append("Version \(version)") - } - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") + return self.finishDetails( + lines: &lines, + probeOk: status.probe?.ok, + probeStatus: status.probe?.status, + probeElapsedMs: status.probe?.elapsedMs, + probeVersion: status.probe?.version, + lastProbeAtMs: status.lastProbeAt, + lastError: status.lastError) } var imessageDetails: String? { @@ -279,17 +320,14 @@ extension ChannelsSettings { if let dbPath = status.dbPath, !dbPath.isEmpty { lines.append("DB: \(dbPath)") } - if let probe = status.probe, !probe.ok { - let err = probe.error ?? "probe failed" - lines.append("Probe error: \(err)") - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") + return self.finishDetails( + lines: &lines, + probeOk: status.probe?.ok, + probeStatus: nil, + probeElapsedMs: nil, + probeError: status.probe?.error, + lastProbeAtMs: status.lastProbeAt, + lastError: status.lastError) } var orderedChannels: [ChannelItem] { diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift index d1ed16bf6e8..9b3976f3bae 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift @@ -18,7 +18,7 @@ extension ChannelsSettings { } private var sidebar: some View { - ScrollView { + SettingsSidebarScroll { LazyVStack(alignment: .leading, spacing: 8) { if !self.enabledChannels.isEmpty { self.sidebarSectionHeader("Configured") @@ -34,14 +34,7 @@ extension ChannelsSettings { } } } - .padding(.vertical, 10) - .padding(.horizontal, 10) } - .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor))) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } private var detail: some View { diff --git a/apps/macos/Sources/OpenClaw/ColorHexSupport.swift b/apps/macos/Sources/OpenClaw/ColorHexSupport.swift new file mode 100644 index 00000000000..506f2f1fb4a --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ColorHexSupport.swift @@ -0,0 +1,14 @@ +import SwiftUI + +enum ColorHexSupport { + static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } +} diff --git a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift index 4434443497e..9a6d5aed4dd 100644 --- a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift @@ -1,11 +1,11 @@ import Foundation -final class ConfigFileWatcher: @unchecked Sendable { +final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner { private let url: URL private let watchedDir: URL private let targetPath: String private let targetName: String - private let watcher: CoalescingFSEventsWatcher + let watcher: SimpleFileWatcher init(url: URL, onChange: @escaping () -> Void) { self.url = url @@ -15,7 +15,7 @@ final class ConfigFileWatcher: @unchecked Sendable { let watchedDirPath = self.watchedDir.path let targetPath = self.targetPath let targetName = self.targetName - self.watcher = CoalescingFSEventsWatcher( + self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher( paths: [watchedDirPath], queueLabel: "ai.openclaw.configwatcher", shouldNotify: { _, eventPaths in @@ -28,18 +28,7 @@ final class ConfigFileWatcher: @unchecked Sendable { } return false }, - onChange: onChange) + onChange: onChange)) } - deinit { - self.stop() - } - - func start() { - self.watcher.start() - } - - func stop() { - self.watcher.stop() - } } diff --git a/apps/macos/Sources/OpenClaw/ConfigSettings.swift b/apps/macos/Sources/OpenClaw/ConfigSettings.swift index 096ae3f7149..d5f3ee7343a 100644 --- a/apps/macos/Sources/OpenClaw/ConfigSettings.swift +++ b/apps/macos/Sources/OpenClaw/ConfigSettings.swift @@ -72,7 +72,7 @@ extension ConfigSettings { } private var sidebar: some View { - ScrollView { + SettingsSidebarScroll { LazyVStack(alignment: .leading, spacing: 8) { if self.sections.isEmpty { Text("No config sections available.") @@ -86,14 +86,7 @@ extension ConfigSettings { } } } - .padding(.vertical, 10) - .padding(.horizontal, 10) } - .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor))) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } private var detail: some View { diff --git a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift index f9a11b9e512..7989afaeebc 100644 --- a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift +++ b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift @@ -6,10 +6,6 @@ struct ContextMenuCardView: View { private let rows: [SessionRow] private let statusText: String? private let isLoading: Bool - private let paddingTop: CGFloat = 8 - private let paddingBottom: CGFloat = 8 - private let paddingTrailing: CGFloat = 10 - private let paddingLeading: CGFloat = 20 private let barHeight: CGFloat = 3 init( @@ -23,45 +19,32 @@ struct ContextMenuCardView: View { } var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let statusText { - Text(statusText) - .font(.caption) - .foregroundStyle(.secondary) - } else if self.rows.isEmpty, !self.isLoading { - Text("No active sessions") - .font(.caption) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 12) { - if self.rows.isEmpty, self.isLoading { - ForEach(0..<2, id: \.self) { _ in - self.placeholderRow - } - } else { - ForEach(self.rows) { row in - self.sessionRow(row) + MenuHeaderCard( + title: "Context", + subtitle: self.subtitle, + statusText: self.statusText, + paddingBottom: 8) + { + if self.statusText == nil { + if self.rows.isEmpty, !self.isLoading { + Text("No active sessions") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 12) { + if self.rows.isEmpty, self.isLoading { + ForEach(0..<2, id: \.self) { _ in + self.placeholderRow + } + } else { + ForEach(self.rows) { row in + self.sessionRow(row) + } } } } } } - .padding(.top, self.paddingTop) - .padding(.bottom, self.paddingBottom) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .transaction { txn in txn.animation = nil } } private var subtitle: String { diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 16b4d6d3ad4..6fb81ce7941 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -336,16 +336,8 @@ final class ControlChannel { } private func startEventStream() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } + GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in + self?.handle(push: push) } } diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift index 6b3fc85a7c0..26b64ea7c65 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -258,14 +258,6 @@ extension CronJobEditor { } func formatDuration(ms: Int) -> String { - if ms < 1000 { return "\(ms)ms" } - let s = Double(ms) / 1000.0 - if s < 60 { return "\(Int(round(s)))s" } - let m = s / 60.0 - if m < 60 { return "\(Int(round(m)))m" } - let h = m / 60.0 - if h < 48 { return "\(Int(round(h)))h" } - let d = h / 24.0 - return "\(Int(round(d)))d" + DurationFormattingSupport.conciseDuration(ms: ms) } } diff --git a/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/apps/macos/Sources/OpenClaw/CronJobsStore.swift index 21c70ded584..1dd5668cc9f 100644 --- a/apps/macos/Sources/OpenClaw/CronJobsStore.swift +++ b/apps/macos/Sources/OpenClaw/CronJobsStore.swift @@ -38,7 +38,9 @@ final class CronJobsStore { func start() { guard !self.isPreview else { return } guard self.eventTask == nil else { return } - self.startGatewaySubscription() + GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in + self?.handle(push: push) + } self.pollTask = Task.detached { [weak self] in guard let self else { return } await self.refreshJobs() @@ -142,20 +144,6 @@ final class CronJobsStore { // MARK: - Gateway events - private func startGatewaySubscription() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - private func handle(push: GatewayPush) { switch push { case let .event(evt) where evt.event == "cron": diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift index c638e4c87b1..873b0741e34 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift @@ -31,15 +31,7 @@ extension CronSettings { } func formatDuration(ms: Int) -> String { - if ms < 1000 { return "\(ms)ms" } - let s = Double(ms) / 1000.0 - if s < 60 { return "\(Int(round(s)))s" } - let m = s / 60.0 - if m < 60 { return "\(Int(round(m)))m" } - let h = m / 60.0 - if h < 48 { return "\(Int(round(h)))h" } - let d = h / 24.0 - return "\(Int(round(d)))d" + DurationFormattingSupport.conciseDuration(ms: ms) } func nextRunLabel(_ date: Date, now: Date = .init()) -> String { diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift index f85e8d1a5df..ad4c38e8d24 100644 --- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift @@ -55,48 +55,37 @@ final class DevicePairingApprovalPrompter { } } - private struct PairingResolvedEvent: Codable { - let requestId: String - let deviceId: String - let decision: String - let ts: Double - } - - private enum PairingResolution: String { - case approved - case rejected - } + private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent func start() { - guard self.task == nil else { return } - self.isStopping = false - self.task = Task { [weak self] in - guard let self else { return } - _ = try? await GatewayConnection.shared.refresh() - await self.loadPendingRequestsFromGateway() - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in self?.handle(push: push) } - } - } + self.startPushTask() + } + + private func startPushTask() { + PairingAlertSupport.startPairingPushTask( + task: &self.task, + isStopping: &self.isStopping, + loadPending: self.loadPendingRequestsFromGateway, + handlePush: self.handle(push:)) } func stop() { - self.isStopping = true - self.endActiveAlert() - self.task?.cancel() - self.task = nil - self.queue.removeAll(keepingCapacity: false) + self.stopPushTask() self.updatePendingCounts() - self.isPresenting = false - self.activeRequestId = nil - self.alertHostWindow?.orderOut(nil) - self.alertHostWindow?.close() - self.alertHostWindow = nil self.resolvedByRequestId.removeAll(keepingCapacity: false) } + private func stopPushTask() { + PairingAlertSupport.stopPairingPrompter( + isStopping: &self.isStopping, + activeAlert: &self.activeAlert, + activeRequestId: &self.activeRequestId, + task: &self.task, + queue: &self.queue, + isPresenting: &self.isPresenting, + alertHostWindow: &self.alertHostWindow) + } + private func loadPendingRequestsFromGateway() async { do { let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) @@ -127,44 +116,23 @@ final class DevicePairingApprovalPrompter { private func presentAlert(for req: PendingRequest) { self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") - NSApp.activate(ignoringOtherApps: true) + PairingAlertSupport.presentPairingAlert( + request: req, + requestId: req.requestId, + messageText: "Allow device to connect?", + informativeText: Self.describe(req), + activeAlert: &self.activeAlert, + activeRequestId: &self.activeRequestId, + alertHostWindow: &self.alertHostWindow, + clearActive: self.clearActiveAlert(hostWindow:), + onResponse: self.handleAlertResponse) + } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow device to connect?" - alert.informativeText = Self.describe(req) - alert.addButton(withTitle: "Later") - alert.addButton(withTitle: "Approve") - alert.addButton(withTitle: "Reject") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - self.activeAlert = alert - self.activeRequestId = req.requestId - let hostWindow = self.requireAlertHostWindow() - - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow) { [weak self] response in - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRequestId = nil - self.activeAlert = nil - await self.handleAlertResponse(response, request: req) - hostWindow.orderOut(nil) - } - } + private func clearActiveAlert(hostWindow: NSWindow) { + PairingAlertSupport.clearActivePairingAlert( + activeAlert: &self.activeAlert, + activeRequestId: &self.activeRequestId, + hostWindow: hostWindow) } private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { @@ -206,24 +174,22 @@ final class DevicePairingApprovalPrompter { } private func approve(requestId: String) async -> Bool { - do { + await PairingAlertSupport.approveRequest( + requestId: requestId, + kind: "device", + logger: self.logger) + { try await GatewayConnection.shared.devicePairApprove(requestId: requestId) - self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - self.logger.error("approve failed requestId=\(requestId, privacy: .public)") - self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false } } private func reject(requestId: String) async { - do { + await PairingAlertSupport.rejectRequest( + requestId: requestId, + kind: "device", + logger: self.logger) + { try await GatewayConnection.shared.devicePairReject(requestId: requestId) - self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)") - } catch { - self.logger.error("reject failed requestId=\(requestId, privacy: .public)") - self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") } } @@ -231,10 +197,6 @@ final class DevicePairingApprovalPrompter { PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) } - private func requireAlertHostWindow() -> NSWindow { - PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) - } - private func handle(push: GatewayPush) { switch push { case let .event(evt) where evt.event == "device.pair.requested": @@ -269,8 +231,9 @@ final class DevicePairingApprovalPrompter { } private func handleResolved(_ resolved: PairingResolvedEvent) { - let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution - .approved : .rejected + let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue + ? PairingAlertSupport.PairingResolution.approved + : PairingAlertSupport.PairingResolution.rejected if let activeRequestId, activeRequestId == resolved.requestId { self.resolvedByRequestId.insert(resolved.requestId) self.endActiveAlert() diff --git a/apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift b/apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift new file mode 100644 index 00000000000..7ca706867c3 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift @@ -0,0 +1,15 @@ +import Foundation + +enum DurationFormattingSupport { + static func conciseDuration(ms: Int) -> String { + if ms < 1000 { return "\(ms)ms" } + let s = Double(ms) / 1000.0 + if s < 60 { return "\(Int(round(s)))s" } + let m = s / 60.0 + if m < 60 { return "\(Int(round(m)))m" } + let h = m / 60.0 + if h < 48 { return "\(Int(round(h)))h" } + let d = h / 24.0 + return "\(Int(round(d)))d" + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift index 670fa891c5b..0da8faadbc4 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift @@ -19,15 +19,13 @@ final class ExecApprovalsGatewayPrompter { } func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in + SimpleTaskSupport.start(task: &self.task) { [weak self] in await self?.run() } } func stop() { - self.task?.cancel() - self.task = nil + SimpleTaskSupport.stop(task: &self.task) } private func run() async { diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 390900eea72..bee77ce3e7d 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -73,6 +73,22 @@ private struct ExecHostResponse: Codable { var error: ExecHostError? } +private func readLineFromHandle(_ handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + if buffer.contains(0x0A) { break } + } + guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { + guard !buffer.isEmpty else { return nil } + return String(data: buffer, encoding: .utf8) + } + let lineData = buffer.subdata(in: 0.. String? { - var buffer = Data() - while buffer.count < maxBytes { - let chunk = try handle.read(upToCount: 4096) ?? Data() - if chunk.isEmpty { break } - buffer.append(chunk) - if buffer.contains(0x0A) { break } - } - guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { - guard !buffer.isEmpty else { return nil } - return String(data: buffer, encoding: .utf8) - } - let lineData = buffer.subdata(in: 0.. String? { - var buffer = Data() - while buffer.count < maxBytes { - let chunk = try handle.read(upToCount: 4096) ?? Data() - if chunk.isEmpty { break } - buffer.append(chunk) - if buffer.contains(0x0A) { break } - } - guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { - guard !buffer.isEmpty else { return nil } - return String(data: buffer, encoding: .utf8) - } - let lineData = buffer.subdata(in: 0.. Bool { let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"# return token.range(of: pattern, options: .regularExpression) != nil @@ -55,11 +42,11 @@ enum ExecEnvInvocationUnwrapper { if token.hasPrefix("-"), token != "-" { let lower = token.lowercased() let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower - if self.flagOptions.contains(flag) { + if ExecEnvOptions.flagOnly.contains(flag) { idx += 1 continue } - if self.optionsWithValue.contains(flag) { + if ExecEnvOptions.withValue.contains(flag) { if !lower.contains("=") { expectsOptionValue = true } diff --git a/apps/macos/Sources/OpenClaw/ExecEnvOptions.swift b/apps/macos/Sources/OpenClaw/ExecEnvOptions.swift new file mode 100644 index 00000000000..d8dae4f8ca4 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecEnvOptions.swift @@ -0,0 +1,29 @@ +import Foundation + +enum ExecEnvOptions { + static let withValue = Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + ]) + + static let flagOnly = Set(["-i", "--ignore-environment", "-0", "--null"]) + + static let inlineValuePrefixes = [ + "-u", + "-c", + "-s", + "--unset=", + "--chdir=", + "--split-string=", + "--default-signal=", + "--ignore-signal=", + "--block-signal=", + ] +} diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift index 707a46322d8..46ba6c4417a 100644 --- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -39,30 +39,6 @@ enum ExecSystemRunCommandValidator { private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"]) private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"]) - private static let envOptionsWithValue = Set([ - "-u", - "--unset", - "-c", - "--chdir", - "-s", - "--split-string", - "--default-signal", - "--ignore-signal", - "--block-signal", - ]) - private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"]) - private static let envInlineValuePrefixes = [ - "-u", - "-c", - "-s", - "--unset=", - "--chdir=", - "--split-string=", - "--default-signal=", - "--ignore-signal=", - "--block-signal=", - ] - private struct EnvUnwrapResult { let argv: [String] let usesModifiers: Bool @@ -113,7 +89,7 @@ enum ExecSystemRunCommandValidator { } private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool { - self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) } + ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) } } private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? { @@ -148,12 +124,12 @@ enum ExecSystemRunCommandValidator { let lower = token.lowercased() let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower - if self.envFlagOptions.contains(flag) { + if ExecEnvOptions.flagOnly.contains(flag) { usesModifiers = true idx += 1 continue } - if self.envOptionsWithValue.contains(flag) { + if ExecEnvOptions.withValue.contains(flag) { usesModifiers = true if !lower.contains("=") { expectsOptionValue = true @@ -301,10 +277,15 @@ enum ExecSystemRunCommandValidator { return current } - private static func resolveInlineCommandTokenIndex( + private struct InlineCommandTokenMatch { + var tokenIndex: Int + var inlineCommand: String? + } + + private static func findInlineCommandTokenMatch( _ argv: [String], flags: Set, - allowCombinedC: Bool) -> Int? + allowCombinedC: Bool) -> InlineCommandTokenMatch? { var idx = 1 while idx < argv.count { @@ -318,21 +299,35 @@ enum ExecSystemRunCommandValidator { break } if flags.contains(lower) { - return idx + 1 < argv.count ? idx + 1 : nil + return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil) } if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { let inline = String(token.dropFirst(inlineOffset)) .trimmingCharacters(in: .whitespacesAndNewlines) - if !inline.isEmpty { - return idx - } - return idx + 1 < argv.count ? idx + 1 : nil + return InlineCommandTokenMatch( + tokenIndex: idx, + inlineCommand: inline.isEmpty ? nil : inline) } idx += 1 } return nil } + private static func resolveInlineCommandTokenIndex( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> Int? + { + guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { + return nil + } + if match.inlineCommand != nil { + return match.tokenIndex + } + let nextIndex = match.tokenIndex + 1 + return nextIndex < argv.count ? nextIndex : nil + } + private static func combinedCommandInlineOffset(_ token: String) -> Int? { let chars = Array(token.lowercased()) guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { @@ -371,30 +366,14 @@ enum ExecSystemRunCommandValidator { flags: Set, allowCombinedC: Bool) -> String? { - var idx = 1 - while idx < argv.count { - let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { - idx += 1 - continue - } - let lower = token.lowercased() - if lower == "--" { - break - } - if flags.contains(lower) { - return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) - } - if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { - let inline = String(token.dropFirst(inlineOffset)) - if let inlineValue = self.trimmedNonEmpty(inline) { - return inlineValue - } - return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) - } - idx += 1 + guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { + return nil } - return nil + if let inlineCommand = match.inlineCommand { + return inlineCommand + } + let nextIndex = match.tokenIndex + 1 + return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil) } private static func extractCmdInlineCommand(_ argv: [String]) -> String? { diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift new file mode 100644 index 00000000000..ea7492b2c79 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift @@ -0,0 +1,22 @@ +import OpenClawDiscovery + +@MainActor +enum GatewayDiscoverySelectionSupport { + static func applyRemoteSelection( + gateway: GatewayDiscoveryModel.DiscoveredGateway, + state: AppState) + { + if state.remoteTransport == .direct { + state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { + OpenClawConfigFile.setRemoteGatewayUrl( + host: endpoint.host, + port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() + } + } +} diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 98743fec8b3..bc57055fb61 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -180,25 +180,11 @@ extension GatewayLaunchAgentManager { } private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let start = trimmed.firstIndex(of: "{"), - let end = trimmed.lastIndex(of: "}") - else { - return nil - } - let jsonText = String(trimmed[start...end]) - guard let data = jsonText.data(using: .utf8) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - return ParsedDaemonJson(text: jsonText, object: object) + guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil } + return ParsedDaemonJson(text: parsed.text, object: parsed.object) } private static func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + TextSummarySupport.summarizeLastLine(text) } } diff --git a/apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift b/apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift new file mode 100644 index 00000000000..3b3058e1729 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift @@ -0,0 +1,34 @@ +import OpenClawKit + +enum GatewayPushSubscription { + @MainActor + static func consume( + bufferingNewest: Int? = nil, + onPush: @escaping @MainActor (GatewayPush) -> Void) async + { + let stream: AsyncStream = if let bufferingNewest { + await GatewayConnection.shared.subscribe(bufferingNewest: bufferingNewest) + } else { + await GatewayConnection.shared.subscribe() + } + + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { + onPush(push) + } + } + } + + @MainActor + static func restartTask( + task: inout Task?, + bufferingNewest: Int? = nil, + onPush: @escaping @MainActor (GatewayPush) -> Void) + { + task?.cancel() + task = Task { + await self.consume(bufferingNewest: bufferingNewest, onPush: onPush) + } + } +} diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 64a6f92db8f..3d044bcda2f 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -1,41 +1,7 @@ import Foundation -import Network +import OpenClawKit enum GatewayRemoteConfig { - private 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[.. AppState.RemoteTransport { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], @@ -74,7 +40,7 @@ enum GatewayRemoteConfig { guard scheme == "ws" || scheme == "wss" else { return nil } let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !host.isEmpty else { return nil } - if scheme == "ws", !self.isLoopbackHost(host) { + if scheme == "ws", !LoopbackHost.isLoopbackHost(host) { return nil } if scheme == "ws", url.port == nil { diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 4dae858771c..bdf02d94992 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -260,17 +260,7 @@ struct GeneralSettings: View { TextField("user@host[:22]", text: self.$state.remoteTarget) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) - Button { - Task { await self.testRemote() } - } label: { - if self.remoteStatus == .checking { - ProgressView().controlSize(.small) - } else { - Text("Test remote") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.remoteStatus == .checking || !canTest) + self.remoteTestButton(disabled: !canTest) } if let validationMessage { Text(validationMessage) @@ -290,18 +280,8 @@ struct GeneralSettings: View { TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) - Button { - Task { await self.testRemote() } - } label: { - if self.remoteStatus == .checking { - ProgressView().controlSize(.small) - } else { - Text("Test remote") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.remoteStatus == .checking || self.state.remoteUrl - .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + self.remoteTestButton( + disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text( "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") @@ -311,6 +291,20 @@ struct GeneralSettings: View { } } + private func remoteTestButton(disabled: Bool) -> some View { + Button { + Task { await self.testRemote() } + } label: { + if self.remoteStatus == .checking { + ProgressView().controlSize(.small) + } else { + Text("Test remote") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.remoteStatus == .checking || disabled) + } + private var controlStatusLine: String { switch ControlChannel.shared.state { case .connected: "Connected" @@ -672,19 +666,7 @@ extension GeneralSettings { private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) - - if self.state.remoteTransport == .direct { - self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" - } else { - self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" - } - if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { - OpenClawConfigFile.setRemoteGatewayUrl( - host: endpoint.host, - port: endpoint.port) - } else { - OpenClawConfigFile.clearRemoteGatewayUrl() - } + GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state) } } diff --git a/apps/macos/Sources/OpenClaw/HoverHUD.swift b/apps/macos/Sources/OpenClaw/HoverHUD.swift index d3482362a0f..f9a8625ab2c 100644 --- a/apps/macos/Sources/OpenClaw/HoverHUD.swift +++ b/apps/macos/Sources/OpenClaw/HoverHUD.swift @@ -100,17 +100,8 @@ final class HoverHUDController { return } - let target = window.frame.offsetBy(dx: 0, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.14 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - window.orderOut(nil) - self.model.isVisible = false - } + OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) { + self.model.isVisible = false } } @@ -140,15 +131,7 @@ final class HoverHUDController { if !self.model.isVisible { self.model.isVisible = true let start = target.offsetBy(dx: 0, dy: 8) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } + OverlayPanelFactory.animatePresent(window: window, from: start, to: target) } else { window.orderFrontRegardless() self.updateWindowFrame(animate: true) @@ -157,22 +140,10 @@ final class HoverHUDController { private func ensureWindow() { if self.window != nil { return } - let panel = NSPanel( + let panel = OverlayPanelFactory.makePanel( contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = true - panel.level = .statusBar - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true + level: .statusBar, + hasShadow: true) let host = NSHostingView(rootView: HoverHUDView(controller: self)) host.translatesAutoresizingMaskIntoConstraints = false @@ -201,17 +172,7 @@ final class HoverHUDController { } private func updateWindowFrame(animate: Bool = false) { - guard let window else { return } - let frame = self.targetFrame() - if animate { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(frame, display: true) - } - } else { - window.setFrame(frame, display: true) - } + OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate) } private func installDismissMonitor() { @@ -231,10 +192,7 @@ final class HoverHUDController { } private func removeDismissMonitor() { - if let monitor = self.dismissMonitor { - NSEvent.removeMonitor(monitor) - self.dismissMonitor = nil - } + OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor) } } diff --git a/apps/macos/Sources/OpenClaw/InstancesSettings.swift b/apps/macos/Sources/OpenClaw/InstancesSettings.swift index 0c992c6970f..8949ae1b037 100644 --- a/apps/macos/Sources/OpenClaw/InstancesSettings.swift +++ b/apps/macos/Sources/OpenClaw/InstancesSettings.swift @@ -43,16 +43,8 @@ struct InstancesSettings: View { .foregroundStyle(.secondary) } Spacer() - if self.store.isLoading { - ProgressView() - } else { - Button { - Task { await self.store.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .help("Refresh") + SettingsRefreshButton(isLoading: self.store.isLoading) { + Task { await self.store.refresh() } } } } @@ -276,7 +268,7 @@ struct InstancesSettings: View { } private func platformIcon(_ raw: String) -> String { - let (prefix, _) = self.parsePlatform(raw) + let (prefix, _) = PlatformLabelFormatter.parse(raw) switch prefix { case "macos": return "laptopcomputer" @@ -294,31 +286,7 @@ struct InstancesSettings: View { } private func prettyPlatform(_ raw: String) -> String? { - let (prefix, version) = self.parsePlatform(raw) - if prefix.isEmpty { return nil } - let name: String = switch prefix { - case "macos": "macOS" - case "ios": "iOS" - case "ipados": "iPadOS" - case "tvos": "tvOS" - case "watchos": "watchOS" - default: prefix.prefix(1).uppercased() + prefix.dropFirst() - } - guard let version, !version.isEmpty else { return name } - let parts = version.split(separator: ".").map(String.init) - if parts.count >= 2 { - return "\(name) \(parts[0]).\(parts[1])" - } - return "\(name) \(version)" - } - - private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return ("", nil) } - let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) - let prefix = parts.first?.lowercased() ?? "" - let versionToken = parts.dropFirst().first - return (prefix, versionToken) + PlatformLabelFormatter.pretty(raw) } private func presenceUpdateSourceShortText(_ reason: String) -> String? { @@ -450,8 +418,8 @@ extension InstancesSettings { _ = view.prettyPlatform("ipados 17.1") _ = view.prettyPlatform("linux") _ = view.prettyPlatform(" ") - _ = view.parsePlatform("macOS 14.1") - _ = view.parsePlatform(" ") + _ = PlatformLabelFormatter.parse("macOS 14.1") + _ = PlatformLabelFormatter.parse(" ") _ = view.presenceUpdateSourceShortText("self") _ = view.presenceUpdateSourceShortText("instances-refresh") _ = view.presenceUpdateSourceShortText("seq gap") diff --git a/apps/macos/Sources/OpenClaw/InstancesStore.swift b/apps/macos/Sources/OpenClaw/InstancesStore.swift index 566340337db..073d129b944 100644 --- a/apps/macos/Sources/OpenClaw/InstancesStore.swift +++ b/apps/macos/Sources/OpenClaw/InstancesStore.swift @@ -62,14 +62,11 @@ final class InstancesStore { self.startCount += 1 guard self.startCount == 1 else { return } guard self.task == nil else { return } - self.startGatewaySubscription() - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.refresh() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh() - } + GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in + self?.handle(push: push) + } + SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in + await self?.refresh() } } @@ -84,20 +81,6 @@ final class InstancesStore { self.eventTask = nil } - private func startGatewaySubscription() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - private func handle(push: GatewayPush) { switch push { case let .event(evt) where evt.event == "presence": diff --git a/apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift b/apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift new file mode 100644 index 00000000000..f13570f6f71 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift @@ -0,0 +1,16 @@ +import Foundation + +enum JSONObjectExtractionSupport { + static func extract(from raw: String) -> (text: String, object: [String: Any])? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return (jsonText, object) + } +} diff --git a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift index 7692887e6c7..95cbe7fe84e 100644 --- a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift +++ b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift @@ -98,23 +98,42 @@ extension Logger.Message.StringInterpolation { } } -struct OpenClawOSLogHandler: LogHandler { - private let osLogger: os.Logger - var metadata: Logger.Metadata = [:] +private func stringifyLogMetadataValue(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { stringifyLogMetadataValue($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }.joined(separator: ",") + "}" + } +} +private protocol AppLogLevelBackedHandler: LogHandler { + var metadata: Logger.Metadata { get set } +} + +extension AppLogLevelBackedHandler { var logLevel: Logger.Level { get { AppLogSettings.logLevel() } set { AppLogSettings.setLogLevel(newValue) } } - init(subsystem: String, category: String) { - self.osLogger = os.Logger(subsystem: subsystem, category: category) - } - subscript(metadataKey key: String) -> Logger.Metadata.Value? { get { self.metadata[key] } set { self.metadata[key] = newValue } } +} + +struct OpenClawOSLogHandler: AppLogLevelBackedHandler { + private let osLogger: os.Logger + var metadata: Logger.Metadata = [:] + + init(subsystem: String, category: String) { + self.osLogger = os.Logger(subsystem: subsystem, category: category) + } func log( level: Logger.Level, @@ -157,39 +176,16 @@ struct OpenClawOSLogHandler: LogHandler { guard !metadata.isEmpty else { return message.description } let meta = metadata .sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\(self.stringify($0.value))" } + .map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" } .joined(separator: " ") return "\(message.description) [\(meta)]" } - - private static func stringify(_ value: Logger.Metadata.Value) -> String { - switch value { - case let .string(text): - text - case let .stringConvertible(value): - String(describing: value) - case let .array(values): - "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" - case let .dictionary(entries): - "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" - } - } } -struct OpenClawFileLogHandler: LogHandler { +struct OpenClawFileLogHandler: AppLogLevelBackedHandler { let label: String var metadata: Logger.Metadata = [:] - var logLevel: Logger.Level { - get { AppLogSettings.logLevel() } - set { AppLogSettings.setLogLevel(newValue) } - } - - subscript(metadataKey key: String) -> Logger.Metadata.Value? { - get { self.metadata[key] } - set { self.metadata[key] = newValue } - } - func log( level: Logger.Level, message: Logger.Message, @@ -212,21 +208,8 @@ struct OpenClawFileLogHandler: LogHandler { ] let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new }) for (key, value) in merged { - fields["meta.\(key)"] = Self.stringify(value) + fields["meta.\(key)"] = stringifyLogMetadataValue(value) } DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields) } - - private static func stringify(_ value: Logger.Metadata.Value) -> String { - switch value { - case let .string(text): - text - case let .stringConvertible(value): - String(describing: value) - case let .array(values): - "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" - case let .dictionary(entries): - "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" - } - } } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index d7ab72ce86f..0750da56a5e 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -228,17 +228,7 @@ private final class StatusItemMouseHandlerView: NSView { override func updateTrackingAreas() { super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let options: NSTrackingArea.Options = [ - .mouseEnteredAndExited, - .activeAlways, - .inVisibleRect, - ] - let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) - self.addTrackingArea(area) - self.tracking = area + TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self) } override func mouseEntered(with event: NSEvent) { diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index 3416d23f812..f4a250aabe4 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -170,7 +170,11 @@ struct MenuContent: View { await self.loadBrowserControlEnabled() } .onAppear { - self.startMicObserver() + MicRefreshSupport.startObserver(self.micObserver) { + MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) { + await self.loadMicrophones(force: true) + } + } } .onDisappear { self.micRefreshTask?.cancel() @@ -425,11 +429,7 @@ struct MenuContent: View { } private var voiceWakeBinding: Binding { - Binding( - get: { self.state.swabbleEnabled }, - set: { newValue in - Task { await self.state.setVoiceWakeEnabled(newValue) } - }) + MicRefreshSupport.voiceWakeBinding(for: self.state) } private var showVoiceWakeMicPicker: Bool { @@ -546,46 +546,20 @@ struct MenuContent: View { } .map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } self.availableMics = self.filterAliveInputs(self.availableMics) - self.updateSelectedMicName() + self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName( + selectedID: self.state.voiceWakeMicID, + in: self.availableMics, + uid: \.uid, + name: \.name) self.loadingMics = false } - private func startMicObserver() { - self.micObserver.start { - Task { @MainActor in - self.scheduleMicRefresh() - } - } - } - - @MainActor - private func scheduleMicRefresh() { - self.micRefreshTask?.cancel() - self.micRefreshTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 300_000_000) - guard !Task.isCancelled else { return } - await self.loadMicrophones(force: true) - } - } - private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] { let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs() guard !aliveUIDs.isEmpty else { return inputs } return inputs.filter { aliveUIDs.contains($0.uid) } } - @MainActor - private func updateSelectedMicName() { - let selected = self.state.voiceWakeMicID - if selected.isEmpty { - self.state.voiceWakeMicName = "" - return - } - if let match = self.availableMics.first(where: { $0.uid == selected }) { - self.state.voiceWakeMicName = match.name - } - } - private struct AudioInputDevice: Identifiable, Equatable { let uid: String let name: String diff --git a/apps/macos/Sources/OpenClaw/MenuHeaderCard.swift b/apps/macos/Sources/OpenClaw/MenuHeaderCard.swift new file mode 100644 index 00000000000..baf0d78c295 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/MenuHeaderCard.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct MenuHeaderCard: View { + let title: String + let subtitle: String + let statusText: String? + let paddingBottom: CGFloat + @ViewBuilder var content: Content + + init( + title: String, + subtitle: String, + statusText: String? = nil, + paddingBottom: CGFloat = 6, + @ViewBuilder content: () -> Content = { EmptyView() }) + { + self.title = title + self.subtitle = subtitle + self.statusText = statusText + self.paddingBottom = paddingBottom + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let statusText, !statusText.isEmpty { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + self.content + } + .padding(.top, 8) + .padding(.bottom, self.paddingBottom) + .padding(.leading, 20) + .padding(.trailing, 10) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + .transaction { txn in txn.animation = nil } + } +} diff --git a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift index 7107946989e..d6f0cfb981f 100644 --- a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift +++ b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift @@ -33,17 +33,7 @@ final class HighlightedMenuItemHostView: NSView { override func updateTrackingAreas() { super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let options: NSTrackingArea.Options = [ - .mouseEnteredAndExited, - .activeAlways, - .inVisibleRect, - ] - let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) - self.addTrackingArea(area) - self.tracking = area + TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self) } override func mouseEntered(with event: NSEvent) { diff --git a/apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift b/apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift new file mode 100644 index 00000000000..6d494828409 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift @@ -0,0 +1,22 @@ +import SwiftUI + +enum MenuItemHighlightColors { + struct Palette { + let primary: Color + let secondary: Color + } + + static func primary(_ highlighted: Bool) -> Color { + highlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + static func secondary(_ highlighted: Bool) -> Color { + highlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + static func palette(_ highlighted: Bool) -> Palette { + Palette( + primary: self.primary(highlighted), + secondary: self.secondary(highlighted)) + } +} diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift b/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift index e96cea53b84..2057ddc3aeb 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift @@ -4,37 +4,11 @@ struct MenuSessionsHeaderView: View { let count: Int let statusText: String? - private let paddingTop: CGFloat = 8 - private let paddingBottom: CGFloat = 6 - private let paddingTrailing: CGFloat = 10 - private let paddingLeading: CGFloat = 20 - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let statusText, !statusText.isEmpty { - Text(statusText) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - .padding(.top, self.paddingTop) - .padding(.bottom, self.paddingBottom) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .transaction { txn in txn.animation = nil } + MenuHeaderCard( + title: "Context", + subtitle: self.subtitle, + statusText: self.statusText) } private var subtitle: String { diff --git a/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift b/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift index dbb717d690a..cd7b4ede5ef 100644 --- a/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift +++ b/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift @@ -3,29 +3,10 @@ import SwiftUI struct MenuUsageHeaderView: View { let count: Int - private let paddingTop: CGFloat = 8 - private let paddingBottom: CGFloat = 6 - private let paddingTrailing: CGFloat = 10 - private let paddingLeading: CGFloat = 20 - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text("Usage") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.top, self.paddingTop) - .padding(.bottom, self.paddingBottom) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .transaction { txn in txn.animation = nil } + MenuHeaderCard( + title: "Usage", + subtitle: self.subtitle) } private var subtitle: String { diff --git a/apps/macos/Sources/OpenClaw/MicRefreshSupport.swift b/apps/macos/Sources/OpenClaw/MicRefreshSupport.swift new file mode 100644 index 00000000000..3bf983cd327 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/MicRefreshSupport.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftUI + +enum MicRefreshSupport { + private static let refreshDelayNs: UInt64 = 300_000_000 + + static func startObserver(_ observer: AudioInputDeviceObserver, triggerRefresh: @escaping @MainActor () -> Void) { + observer.start { + Task { @MainActor in + triggerRefresh() + } + } + } + + @MainActor + static func schedule( + refreshTask: inout Task?, + action: @escaping @MainActor () async -> Void) + { + refreshTask?.cancel() + refreshTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: self.refreshDelayNs) + guard !Task.isCancelled else { return } + await action() + } + } + + static func selectedMicName( + selectedID: String, + in devices: [T], + uid: KeyPath, + name: KeyPath) -> String + { + guard !selectedID.isEmpty else { return "" } + return devices.first(where: { $0[keyPath: uid] == selectedID })?[keyPath: name] ?? "" + } + + @MainActor + static func voiceWakeBinding(for state: AppState) -> Binding { + Binding( + get: { state.swabbleEnabled }, + set: { newValue in + Task { await state.setVoiceWakeEnabled(newValue) } + }) + } +} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift index bd4df512ca4..d9214bd77c8 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift @@ -3,7 +3,7 @@ import Foundation import OpenClawKit @MainActor -final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { +final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon { enum Error: Swift.Error { case timeout case unavailable @@ -12,21 +12,18 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private var locationContinuation: CheckedContinuation? + var locationManager: CLLocationManager { + self.manager + } + + var locationRequestContinuation: CheckedContinuation? { + get { self.locationContinuation } + set { self.locationContinuation = newValue } + } + override init() { super.init() - self.manager.delegate = self - self.manager.desiredAccuracy = kCLLocationAccuracyBest - } - - func authorizationStatus() -> CLAuthorizationStatus { - self.manager.authorizationStatus - } - - func accuracyAuthorization() -> CLAccuracyAuthorization { - if #available(macOS 11.0, *) { - return self.manager.accuracyAuthorization - } - return .fullAccuracy + self.configureLocationManager() } func currentLocation( @@ -37,26 +34,15 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { guard CLLocationManager.locationServicesEnabled() else { throw Error.unavailable } - - let now = Date() - if let maxAgeMs, - let cached = self.manager.location, - now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs) - { - return cached - } - - self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) - let timeout = max(0, timeoutMs ?? 10000) - return try await self.withTimeout(timeoutMs: timeout) { - try await self.requestLocation() - } - } - - private func requestLocation() async throws -> CLLocation { - try await withCheckedThrowingContinuation { cont in - self.locationContinuation = cont - self.manager.requestLocation() + return try await LocationCurrentRequest.resolve( + manager: self.manager, + desiredAccuracy: desiredAccuracy, + maxAgeMs: maxAgeMs, + timeoutMs: timeoutMs, + request: { try await self.requestLocationOnce() }) { timeoutMs, operation in + try await self.withTimeout(timeoutMs: timeoutMs) { + try await operation() + } } } @@ -103,17 +89,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { } } - private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy { - switch accuracy { - case .coarse: - kCLLocationAccuracyKilometer - case .balanced: - kCLLocationAccuracyHundredMeters - case .precise: - kCLLocationAccuracyBest - } - } - // MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility) nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index 10598d7f4be..e20aa8d53bb 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -68,55 +68,45 @@ final class NodePairingApprovalPrompter { } } - private struct PairingResolvedEvent: Codable { - let requestId: String - let nodeId: String - let decision: String - let ts: Double - } - - private enum PairingResolution: String { - case approved - case rejected - } + private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent + private typealias PairingResolution = PairingAlertSupport.PairingResolution func start() { - guard self.task == nil else { return } - self.isStopping = false self.reconcileTask?.cancel() self.reconcileTask = nil - self.task = Task { [weak self] in - guard let self else { return } - _ = try? await GatewayConnection.shared.refresh() - await self.loadPendingRequestsFromGateway() - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in self?.handle(push: push) } - } - } + self.startPushTask() + } + + private func startPushTask() { + PairingAlertSupport.startPairingPushTask( + task: &self.task, + isStopping: &self.isStopping, + loadPending: self.loadPendingRequestsFromGateway, + handlePush: self.handle(push:)) } func stop() { - self.isStopping = true - self.endActiveAlert() - self.task?.cancel() - self.task = nil + self.stopPushTask() self.reconcileTask?.cancel() self.reconcileTask = nil self.reconcileOnceTask?.cancel() self.reconcileOnceTask = nil - self.queue.removeAll(keepingCapacity: false) self.updatePendingCounts() - self.isPresenting = false - self.activeRequestId = nil - self.alertHostWindow?.orderOut(nil) - self.alertHostWindow?.close() - self.alertHostWindow = nil self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) self.autoApproveAttempts.removeAll(keepingCapacity: false) } + private func stopPushTask() { + PairingAlertSupport.stopPairingPrompter( + isStopping: &self.isStopping, + activeAlert: &self.activeAlert, + activeRequestId: &self.activeRequestId, + task: &self.task, + queue: &self.queue, + isPresenting: &self.isPresenting, + alertHostWindow: &self.alertHostWindow) + } + private func loadPendingRequestsFromGateway() async { // The gateway process may start slightly after the app. Retry a bit so // pending pairing prompts are still shown on launch. @@ -235,10 +225,6 @@ final class NodePairingApprovalPrompter { PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) } - private func requireAlertHostWindow() -> NSWindow { - PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) - } - private func handle(push: GatewayPush) { switch push { case let .event(evt) where evt.event == "node.pair.requested": @@ -293,47 +279,23 @@ final class NodePairingApprovalPrompter { private func presentAlert(for req: PendingRequest) { self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") - NSApp.activate(ignoringOtherApps: true) + PairingAlertSupport.presentPairingAlert( + request: req, + requestId: req.requestId, + messageText: "Allow node to connect?", + informativeText: Self.describe(req), + activeAlert: &self.activeAlert, + activeRequestId: &self.activeRequestId, + alertHostWindow: &self.alertHostWindow, + clearActive: self.clearActiveAlert(hostWindow:), + onResponse: self.handleAlertResponse) + } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow node to connect?" - alert.informativeText = Self.describe(req) - // Fail-safe ordering: if the dialog can't be presented, default to "Later". - alert.addButton(withTitle: "Later") - alert.addButton(withTitle: "Approve") - alert.addButton(withTitle: "Reject") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - self.activeAlert = alert - self.activeRequestId = req.requestId - let hostWindow = self.requireAlertHostWindow() - - // Position the hidden host window so the sheet appears centered on screen. - // (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".) - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow) { [weak self] response in - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRequestId = nil - self.activeAlert = nil - await self.handleAlertResponse(response, request: req) - hostWindow.orderOut(nil) - } - } + private func clearActiveAlert(hostWindow: NSWindow) { + PairingAlertSupport.clearActivePairingAlert( + activeAlert: &self.activeAlert, + activeRequestId: &self.activeRequestId, + hostWindow: hostWindow) } private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { @@ -373,24 +335,22 @@ final class NodePairingApprovalPrompter { } private func approve(requestId: String) async -> Bool { - do { + await PairingAlertSupport.approveRequest( + requestId: requestId, + kind: "node", + logger: self.logger) + { try await GatewayConnection.shared.nodePairApprove(requestId: requestId) - self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - self.logger.error("approve failed requestId=\(requestId, privacy: .public)") - self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false } } private func reject(requestId: String) async { - do { + await PairingAlertSupport.rejectRequest( + requestId: requestId, + kind: "node", + logger: self.logger) + { try await GatewayConnection.shared.nodePairReject(requestId: requestId) - self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") - } catch { - self.logger.error("reject failed requestId=\(requestId, privacy: .public)") - self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") } } @@ -419,8 +379,7 @@ final class NodePairingApprovalPrompter { private static func prettyPlatform(_ platform: String?) -> String? { let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) guard let raw, !raw.isEmpty else { return nil } - if raw.lowercased() == "ios" { return "iOS" } - if raw.lowercased() == "macos" { return "macOS" } + if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty } return raw } diff --git a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift index 38d0aa30241..7a9da5925f8 100644 --- a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift +++ b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift @@ -103,15 +103,9 @@ extension NodeServiceManager { } private static func parseServiceJson(from raw: String) -> ParsedServiceJson? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let start = trimmed.firstIndex(of: "{"), - let end = trimmed.lastIndex(of: "}") - else { - return nil - } - let jsonText = String(trimmed[start...end]) - guard let data = jsonText.data(using: .utf8) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil } + let jsonText = parsed.text + let object = parsed.object let ok = object["ok"] as? Bool let result = object["result"] as? String let message = object["message"] as? String @@ -139,12 +133,6 @@ extension NodeServiceManager { } private static func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + TextSummarySupport.summarizeLastLine(text) } } diff --git a/apps/macos/Sources/OpenClaw/NodesMenu.swift b/apps/macos/Sources/OpenClaw/NodesMenu.swift index f88177d8dd0..f96451fa20d 100644 --- a/apps/macos/Sources/OpenClaw/NodesMenu.swift +++ b/apps/macos/Sources/OpenClaw/NodesMenu.swift @@ -68,7 +68,7 @@ struct NodeMenuEntryFormatter { static func platformText(_ entry: NodeInfo) -> String? { if let raw = entry.platform?.nonEmpty { - return self.prettyPlatform(raw) ?? raw + return PlatformLabelFormatter.pretty(raw) ?? raw } if let family = entry.deviceFamily?.lowercased() { if family.contains("mac") { return "macOS" } @@ -79,34 +79,6 @@ struct NodeMenuEntryFormatter { return nil } - private static func prettyPlatform(_ raw: String) -> String? { - let (prefix, version) = self.parsePlatform(raw) - if prefix.isEmpty { return nil } - let name: String = switch prefix { - case "macos": "macOS" - case "ios": "iOS" - case "ipados": "iPadOS" - case "tvos": "tvOS" - case "watchos": "watchOS" - default: prefix.prefix(1).uppercased() + prefix.dropFirst() - } - guard let version, !version.isEmpty else { return name } - let parts = version.split(separator: ".").map(String.init) - if parts.count >= 2 { - return "\(name) \(parts[0]).\(parts[1])" - } - return "\(name) \(version)" - } - - private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return ("", nil) } - let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) - let prefix = parts.first?.lowercased() ?? "" - let versionToken = parts.dropFirst().first - return (prefix, versionToken) - } - private static func compactVersion(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } @@ -201,12 +173,8 @@ struct NodeMenuRowView: View { let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted - private var primaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + private var palette: MenuItemHighlightColors.Palette { + MenuItemHighlightColors.palette(self.isHighlighted) } var body: some View { @@ -216,9 +184,9 @@ struct NodeMenuRowView: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(NodeMenuEntryFormatter.primaryName(self.entry)) - .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) - .foregroundStyle(self.primaryColor) + Text(NodeMenuEntryFormatter.primaryName(self.entry)) + .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) + .foregroundStyle(self.palette.primary) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -227,9 +195,9 @@ struct NodeMenuRowView: View { HStack(alignment: .firstTextBaseline, spacing: 6) { if let right = NodeMenuEntryFormatter.headlineRight(self.entry) { - Text(right) - .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryColor) + Text(right) + .font(.caption.monospacedDigit()) + .foregroundStyle(self.palette.secondary) .lineLimit(1) .truncationMode(.middle) .layoutPriority(2) @@ -237,7 +205,7 @@ struct NodeMenuRowView: View { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryColor) + .foregroundStyle(self.palette.secondary) .padding(.leading, 2) } } @@ -245,7 +213,7 @@ struct NodeMenuRowView: View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(NodeMenuEntryFormatter.detailLeft(self.entry)) .font(.caption) - .foregroundStyle(self.secondaryColor) + .foregroundStyle(self.palette.secondary) .lineLimit(1) .truncationMode(.middle) @@ -254,7 +222,7 @@ struct NodeMenuRowView: View { if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) { Text(version) .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryColor) + .foregroundStyle(self.palette.secondary) .lineLimit(1) .truncationMode(.middle) } @@ -273,11 +241,11 @@ struct NodeMenuRowView: View { private var leadingIcon: some View { if NodeMenuEntryFormatter.isAndroid(self.entry) { AndroidMark() - .foregroundStyle(self.secondaryColor) + .foregroundStyle(self.palette.secondary) } else { Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry)) .font(.system(size: 18, weight: .regular)) - .foregroundStyle(self.secondaryColor) + .foregroundStyle(self.palette.secondary) } } } @@ -305,23 +273,19 @@ struct NodeMenuMultilineView: View { let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted - private var primaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + private var palette: MenuItemHighlightColors.Palette { + MenuItemHighlightColors.palette(self.isHighlighted) } var body: some View { VStack(alignment: .leading, spacing: 4) { Text("\(self.label):") .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryColor) + .foregroundStyle(self.palette.secondary) Text(self.value) .font(.caption) - .foregroundStyle(self.primaryColor) + .foregroundStyle(self.palette.primary) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } diff --git a/apps/macos/Sources/OpenClaw/NodesStore.swift b/apps/macos/Sources/OpenClaw/NodesStore.swift index 5cc94858645..830c6068934 100644 --- a/apps/macos/Sources/OpenClaw/NodesStore.swift +++ b/apps/macos/Sources/OpenClaw/NodesStore.swift @@ -54,14 +54,8 @@ final class NodesStore { func start() { self.startCount += 1 guard self.startCount == 1 else { return } - guard self.task == nil else { return } - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.refresh() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh() - } + SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in + await self?.refresh() } } diff --git a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift index 31157b0d831..92f2d05c88e 100644 --- a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift +++ b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift @@ -50,17 +50,8 @@ final class NotifyOverlayController { self.dismissTask = nil guard let window else { return } - let target = window.frame.offsetBy(dx: 8, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.16 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - window.orderOut(nil) - self.model.isVisible = false - } + OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 8, offsetY: 6) { + self.model.isVisible = false } } @@ -70,44 +61,21 @@ final class NotifyOverlayController { self.ensureWindow() self.hostingView?.rootView = NotifyOverlayView(controller: self) let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - self.updateWindowFrame(animate: true) - window.orderFrontRegardless() + OverlayPanelFactory.present( + window: self.window, + isVisible: &self.model.isVisible, + target: target) { window in + self.updateWindowFrame(animate: true) + window.orderFrontRegardless() } } private func ensureWindow() { if self.window != nil { return } - let panel = NSPanel( + let panel = OverlayPanelFactory.makePanel( contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = true - panel.level = .statusBar - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true + level: .statusBar, + hasShadow: true) let host = NSHostingView(rootView: NotifyOverlayView(controller: self)) host.translatesAutoresizingMaskIntoConstraints = false @@ -126,17 +94,7 @@ final class NotifyOverlayController { } private func updateWindowFrame(animate: Bool = false) { - guard let window else { return } - let frame = self.targetFrame() - if animate { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(frame, display: true) - } - } else { - window.setFrame(frame, display: true) - } + OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate) } private func measuredHeight() -> CGFloat { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index a521926ddb9..23b051cbc99 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -24,19 +24,7 @@ extension OnboardingView { Task { await self.onboardingWizard.cancelIfRunning() } self.preferredGatewayID = gateway.stableID GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) - - if self.state.remoteTransport == .direct { - self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" - } else { - self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" - } - if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { - OpenClawConfigFile.setRemoteGatewayUrl( - host: endpoint.host, - port: endpoint.port) - } else { - OpenClawConfigFile.clearRemoteGatewayUrl() - } + GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state) self.state.connectionMode = .remote MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index 9b0e45e205c..7ea549d9abb 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -189,19 +189,7 @@ extension OnboardingView { } func featureRow(title: String, subtitle: String, systemImage: String) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) + self.featureRowContent(title: title, subtitle: subtitle, systemImage: systemImage) } func featureActionRow( @@ -210,6 +198,22 @@ extension OnboardingView { systemImage: String, buttonTitle: String, action: @escaping () -> Void) -> some View + { + self.featureRowContent( + title: title, + subtitle: subtitle, + systemImage: systemImage, + action: AnyView( + Button(buttonTitle, action: action) + .buttonStyle(.link) + .padding(.top, 2))) + } + + private func featureRowContent( + title: String, + subtitle: String, + systemImage: String, + action: AnyView? = nil) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: systemImage) @@ -221,9 +225,9 @@ extension OnboardingView { Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) - Button(buttonTitle, action: action) - .buttonStyle(.link) - .padding(.top, 2) + if let action { + action + } } Spacer(minLength: 0) } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index efe37f31673..e7150edc55b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -17,14 +17,9 @@ extension OnboardingView { } func updatePermissionMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.permissionsPageIndex - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } + PermissionMonitoringSupport.setMonitoring( + pageIndex == self.permissionsPageIndex, + monitoring: &self.monitoringPermissions) } func updateDiscoveryMonitoring(for pageIndex: Int) { @@ -51,9 +46,7 @@ extension OnboardingView { } func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() + PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions) } func stopDiscovery() { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift index 7538f846b89..87a30e3285f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift @@ -69,9 +69,7 @@ extension OnboardingView { private func loadAgentWorkspace() async -> String? { let root = await ConfigStore.load() - let agents = root["agents"] as? [String: Any] - let defaults = agents?["defaults"] as? [String: Any] - return defaults?["workspace"] as? String + return AgentWorkspaceConfig.workspace(from: root) } @discardableResult @@ -87,24 +85,7 @@ extension OnboardingView { @MainActor private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { var root = await ConfigStore.load() - var agents = root["agents"] as? [String: Any] ?? [:] - var defaults = agents["defaults"] as? [String: Any] ?? [:] - let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - defaults.removeValue(forKey: "workspace") - } else { - defaults["workspace"] = trimmed - } - if defaults.isEmpty { - agents.removeValue(forKey: "defaults") - } else { - agents["defaults"] = defaults - } - if agents.isEmpty { - root.removeValue(forKey: "agents") - } else { - root["agents"] = agents - } + AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace) do { try await ConfigStore.save(root) return (true, nil) diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 35744baeda5..b112adc2850 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -127,34 +127,15 @@ enum OpenClawConfigFile { } static func agentWorkspace() -> String? { - let root = self.loadDict() - let agents = root["agents"] as? [String: Any] - let defaults = agents?["defaults"] as? [String: Any] - return defaults?["workspace"] as? String + AgentWorkspaceConfig.workspace(from: self.loadDict()) } static func setAgentWorkspace(_ workspace: String?) { var root = self.loadDict() - var agents = root["agents"] as? [String: Any] ?? [:] - var defaults = agents["defaults"] as? [String: Any] ?? [:] - let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - defaults.removeValue(forKey: "workspace") - } else { - defaults["workspace"] = trimmed - } - if defaults.isEmpty { - agents.removeValue(forKey: "defaults") - } else { - agents["defaults"] = defaults - } - if agents.isEmpty { - root.removeValue(forKey: "agents") - } else { - root["agents"] = agents - } + AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace) self.saveDict(root) - self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") + let hasWorkspace = !(workspace?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + self.logger.debug("agents.defaults.workspace updated set=\(hasWorkspace)") } static func gatewayPassword() -> String? { @@ -249,7 +230,7 @@ enum OpenClawConfigFile { return url } - private static func hostKey(_ host: String) -> String { + static func hostKey(_ host: String) -> String { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !trimmed.isEmpty else { return "" } if trimmed.contains(":") { return trimmed } diff --git a/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift new file mode 100644 index 00000000000..b1d6570d81f --- /dev/null +++ b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift @@ -0,0 +1,126 @@ +import AppKit +import QuartzCore + +enum OverlayPanelFactory { + @MainActor + static func makePanel( + contentRect: NSRect, + level: NSWindow.Level, + hasShadow: Bool, + acceptsMouseMovedEvents: Bool = false) -> NSPanel + { + let panel = NSPanel( + contentRect: contentRect, + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = hasShadow + panel.level = level + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.acceptsMouseMovedEvents = acceptsMouseMovedEvents + return panel + } + + @MainActor + static func animatePresent(window: NSWindow, from start: NSRect, to target: NSRect, duration: TimeInterval = 0.18) { + window.setFrame(start, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } + + @MainActor + static func animateFrame(window: NSWindow, to frame: NSRect, duration: TimeInterval = 0.12) { + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(frame, display: true) + } + } + + @MainActor + static func applyFrame(window: NSWindow?, target: NSRect, animate: Bool) { + guard let window else { return } + if animate { + self.animateFrame(window: window, to: target) + } else { + window.setFrame(target, display: true) + } + } + + @MainActor + static func present( + window: NSWindow?, + isVisible: inout Bool, + target: NSRect, + startOffsetY: CGFloat = -6, + onFirstPresent: (() -> Void)? = nil, + onAlreadyVisible: (NSWindow) -> Void) + { + guard let window else { return } + if !isVisible { + isVisible = true + onFirstPresent?() + let start = target.offsetBy(dx: 0, dy: startOffsetY) + self.animatePresent(window: window, from: start, to: target) + } else { + onAlreadyVisible(window) + } + } + + @MainActor + static func animateDismiss( + window: NSWindow, + offsetX: CGFloat = 6, + offsetY: CGFloat = 6, + duration: TimeInterval = 0.16, + completion: @escaping () -> Void) + { + let target = window.frame.offsetBy(dx: offsetX, dy: offsetY) + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 0 + } completionHandler: { + completion() + } + } + + @MainActor + static func animateDismissAndHide( + window: NSWindow, + offsetX: CGFloat = 6, + offsetY: CGFloat = 6, + duration: TimeInterval = 0.16, + onHidden: @escaping @MainActor () -> Void) + { + self.animateDismiss(window: window, offsetX: offsetX, offsetY: offsetY, duration: duration) { + Task { @MainActor in + window.orderOut(nil) + onHidden() + } + } + } + + @MainActor + static func clearGlobalEventMonitor(_ monitor: inout Any?) { + if let current = monitor { + NSEvent.removeMonitor(current) + monitor = nil + } + } +} diff --git a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift index e8e4428bf3f..024cec43d5b 100644 --- a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift +++ b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift @@ -1,4 +1,6 @@ import AppKit +import OpenClawKit +import OSLog final class PairingAlertHostWindow: NSWindow { override var canBecomeKey: Bool { @@ -12,6 +14,17 @@ final class PairingAlertHostWindow: NSWindow { @MainActor enum PairingAlertSupport { + enum PairingResolution: String { + case approved + case rejected + } + + struct PairingResolvedEvent: Codable { + let requestId: String + let decision: String + let ts: Double + } + static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) { guard let alert = activeAlert else { return } if let parent = alert.window.sheetParent { @@ -43,4 +56,189 @@ enum PairingAlertSupport { alertHostWindow = window return window } + + static func configureDefaultPairingAlert( + _ alert: NSAlert, + messageText: String, + informativeText: String) + { + alert.alertStyle = .warning + alert.messageText = messageText + alert.informativeText = informativeText + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + } + + static func beginCenteredSheet( + alert: NSAlert, + hostWindow: NSWindow, + completionHandler: @escaping (NSApplication.ModalResponse) -> Void) + { + let sheetSize = alert.window.frame.size + if let screen = hostWindow.screen ?? NSScreen.main { + let bounds = screen.visibleFrame + let x = bounds.midX - (sheetSize.width / 2) + let sheetOriginY = bounds.midY - (sheetSize.height / 2) + let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height + hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) + } else { + hostWindow.center() + } + hostWindow.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: hostWindow, completionHandler: completionHandler) + } + + static func runPairingPushTask( + bufferingNewest: Int = 200, + loadPending: @escaping @MainActor () async -> Void, + handlePush: @escaping @MainActor (GatewayPush) -> Void) async + { + _ = try? await GatewayConnection.shared.refresh() + await loadPending() + await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush) + } + + static func startPairingPushTask( + task: inout Task?, + isStopping: inout Bool, + bufferingNewest: Int = 200, + loadPending: @escaping @MainActor () async -> Void, + handlePush: @escaping @MainActor (GatewayPush) -> Void) + { + guard task == nil else { return } + isStopping = false + task = Task { + await self.runPairingPushTask( + bufferingNewest: bufferingNewest, + loadPending: loadPending, + handlePush: handlePush) + } + } + + static func beginPairingAlert( + messageText: String, + informativeText: String, + alertHostWindow: inout NSWindow?, + completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert { + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + self.configureDefaultPairingAlert(alert, messageText: messageText, informativeText: informativeText) + + let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow) + self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in + completion(response, hostWindow) + } + return alert + } + + static func presentPairingAlert( + requestId: String, + messageText: String, + informativeText: String, + activeAlert: inout NSAlert?, + activeRequestId: inout String?, + alertHostWindow: inout NSWindow?, + completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) + { + activeRequestId = requestId + activeAlert = self.beginPairingAlert( + messageText: messageText, + informativeText: informativeText, + alertHostWindow: &alertHostWindow, + completion: completion) + } + + static func presentPairingAlert( + request: Request, + requestId: String, + messageText: String, + informativeText: String, + activeAlert: inout NSAlert?, + activeRequestId: inout String?, + alertHostWindow: inout NSWindow?, + clearActive: @escaping @MainActor (NSWindow) -> Void, + onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void) + { + self.presentPairingAlert( + requestId: requestId, + messageText: messageText, + informativeText: informativeText, + activeAlert: &activeAlert, + activeRequestId: &activeRequestId, + alertHostWindow: &alertHostWindow) + { response, hostWindow in + Task { @MainActor in + clearActive(hostWindow) + await onResponse(response, request) + } + } + } + + static func clearActivePairingAlert( + activeAlert: inout NSAlert?, + activeRequestId: inout String?, + hostWindow: NSWindow) + { + activeRequestId = nil + activeAlert = nil + hostWindow.orderOut(nil) + } + + static func stopPairingPrompter( + isStopping: inout Bool, + activeAlert: inout NSAlert?, + activeRequestId: inout String?, + task: inout Task?, + queue: inout [Request], + isPresenting: inout Bool, + alertHostWindow: inout NSWindow?) + { + isStopping = true + self.endActiveAlert(activeAlert: &activeAlert, activeRequestId: &activeRequestId) + task?.cancel() + task = nil + queue.removeAll(keepingCapacity: false) + isPresenting = false + activeRequestId = nil + alertHostWindow?.orderOut(nil) + alertHostWindow?.close() + alertHostWindow = nil + } + + static func approveRequest( + requestId: String, + kind: String, + logger: Logger, + action: @escaping () async throws -> Void) async -> Bool + { + do { + try await action() + logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + logger.error("approve failed requestId=\(requestId, privacy: .public)") + logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + static func rejectRequest( + requestId: String, + kind: String, + logger: Logger, + action: @escaping () async throws -> Void) async + { + do { + try await action() + logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)") + } catch { + logger.error("reject failed requestId=\(requestId, privacy: .public)") + logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } } diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift index b5bcd167a46..1d490106376 100644 --- a/apps/macos/Sources/OpenClaw/PermissionManager.swift +++ b/apps/macos/Sources/OpenClaw/PermissionManager.swift @@ -229,61 +229,37 @@ enum PermissionManager { enum NotificationPermissionHelper { static func openSettings() { - let candidates = [ + SystemSettingsURLSupport.openFirst([ "x-apple.systempreferences:com.apple.Notifications-Settings.extension", "x-apple.systempreferences:com.apple.preference.notifications", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } + ]) } } enum MicrophonePermissionHelper { static func openSettings() { - let candidates = [ + SystemSettingsURLSupport.openFirst([ "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } + ]) } } enum CameraPermissionHelper { static func openSettings() { - let candidates = [ + SystemSettingsURLSupport.openFirst([ "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } + ]) } } enum LocationPermissionHelper { static func openSettings() { - let candidates = [ + SystemSettingsURLSupport.openFirst([ "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } + ]) } } diff --git a/apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift b/apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift new file mode 100644 index 00000000000..9d88ad5459d --- /dev/null +++ b/apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift @@ -0,0 +1,20 @@ +import Foundation + +@MainActor +enum PermissionMonitoringSupport { + static func setMonitoring(_ shouldMonitor: Bool, monitoring: inout Bool) { + if shouldMonitor, !monitoring { + monitoring = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, monitoring { + monitoring = false + PermissionMonitor.shared.unregister() + } + } + + static func stopMonitoring(_ monitoring: inout Bool) { + guard monitoring else { return } + monitoring = false + PermissionMonitor.shared.unregister() + } +} diff --git a/apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift b/apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift new file mode 100644 index 00000000000..9fe170b1ddd --- /dev/null +++ b/apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift @@ -0,0 +1,31 @@ +import Foundation + +enum PlatformLabelFormatter { + static func parse(_ raw: String) -> (prefix: String, version: String?) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return ("", nil) } + let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + let prefix = parts.first?.lowercased() ?? "" + let versionToken = parts.dropFirst().first + return (prefix, versionToken) + } + + static func pretty(_ raw: String) -> String? { + let (prefix, version) = self.parse(raw) + if prefix.isEmpty { return nil } + let name: String = switch prefix { + case "macos": "macOS" + case "ios": "iOS" + case "ipados": "iPadOS" + case "tvos": "tvOS" + case "watchos": "watchOS" + default: prefix.prefix(1).uppercased() + prefix.dropFirst() + } + guard let version, !version.isEmpty else { return name } + let parts = version.split(separator: ".").map(String.init) + if parts.count >= 2 { + return "\(name) \(parts[0]).\(parts[1])" + } + return "\(name) \(version)" + } +} diff --git a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift index 6502d2ad916..82adc209c16 100644 --- a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift +++ b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift @@ -152,8 +152,8 @@ final class RemotePortTunnel { else { return nil } - let sshKey = Self.hostKey(sshHost) - let urlKey = Self.hostKey(host) + let sshKey = OpenClawConfigFile.hostKey(sshHost) + let urlKey = OpenClawConfigFile.hostKey(host) guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } guard sshKey == urlKey else { Self.logger.debug( @@ -163,17 +163,6 @@ final class RemotePortTunnel { return port } - private static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed - } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed - } - private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 { if let preferred, self.portIsFree(preferred) { return preferred } if let preferred, !allowRandom { diff --git a/apps/macos/Sources/OpenClaw/ScreenRecordService.swift b/apps/macos/Sources/OpenClaw/ScreenRecordService.swift index 30d854b1147..a83eea9ebb3 100644 --- a/apps/macos/Sources/OpenClaw/ScreenRecordService.swift +++ b/apps/macos/Sources/OpenClaw/ScreenRecordService.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import OpenClawKit import OSLog @preconcurrency import ScreenCaptureKit @@ -34,8 +35,8 @@ final class ScreenRecordService { includeAudio: Bool?, outPath: String?) async throws -> (path: String, hasAudio: Bool) { - let durationMs = Self.clampDurationMs(durationMs) - let fps = Self.clampFps(fps) + let durationMs = CaptureRateLimits.clampDurationMs(durationMs) + let fps = CaptureRateLimits.clampFps(fps, maxFps: 60) let includeAudio = includeAudio ?? false let outURL: URL = { @@ -96,17 +97,6 @@ final class ScreenRecordService { try await recorder.finish() return (path: outURL.path, hasAudio: recorder.hasAudio) } - - private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 10000 - return min(60000, max(250, v)) - } - - private nonisolated static func clampFps(_ fps: Double?) -> Double { - let v = fps ?? 10 - if !v.isFinite { return 10 } - return min(60, max(1, v)) - } } private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { diff --git a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift index 51646e0a36a..a1a14dcce66 100644 --- a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift +++ b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift @@ -12,14 +12,6 @@ struct SessionMenuLabelView: View { private let paddingTrailing: CGFloat = 14 private let barHeight: CGFloat = 6 - private var primaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary - } - var body: some View { VStack(alignment: .leading, spacing: 8) { ContextUsageBar( @@ -31,7 +23,7 @@ struct SessionMenuLabelView: View { HStack(alignment: .firstTextBaseline, spacing: 2) { Text(self.row.label) .font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) - .foregroundStyle(self.primaryTextColor) + .foregroundStyle(MenuItemHighlightColors.primary(self.isHighlighted)) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -40,14 +32,14 @@ struct SessionMenuLabelView: View { Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)") .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryTextColor) + .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted)) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .layoutPriority(2) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryTextColor) + .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted)) .padding(.leading, 2) } } diff --git a/apps/macos/Sources/OpenClaw/SessionsSettings.swift b/apps/macos/Sources/OpenClaw/SessionsSettings.swift index 826f1128f54..766b2337804 100644 --- a/apps/macos/Sources/OpenClaw/SessionsSettings.swift +++ b/apps/macos/Sources/OpenClaw/SessionsSettings.swift @@ -44,16 +44,8 @@ struct SessionsSettings: View { .fixedSize(horizontal: false, vertical: true) } Spacer() - if self.loading { - ProgressView() - } else { - Button { - Task { await self.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .help("Refresh") + SettingsRefreshButton(isLoading: self.loading) { + Task { await self.refresh() } } } } diff --git a/apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift b/apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift new file mode 100644 index 00000000000..c918919486c --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct SettingsRefreshButton: View { + let isLoading: Bool + let action: () -> Void + + var body: some View { + if self.isLoading { + ProgressView() + } else { + Button(action: self.action) { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .help("Refresh") + } + } +} diff --git a/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/apps/macos/Sources/OpenClaw/SettingsRootView.swift index 016e2f3d1c7..1c021aaa2dc 100644 --- a/apps/macos/Sources/OpenClaw/SettingsRootView.swift +++ b/apps/macos/Sources/OpenClaw/SettingsRootView.swift @@ -158,20 +158,11 @@ struct SettingsRootView: View { private func updatePermissionMonitoring(for tab: SettingsTab) { guard !self.isPreview else { return } - let shouldMonitor = tab == .permissions - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } + PermissionMonitoringSupport.setMonitoring(tab == .permissions, monitoring: &self.monitoringPermissions) } private func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() + PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions) } } diff --git a/apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift b/apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift new file mode 100644 index 00000000000..b082d93b0ff --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + func settingsSidebarCardLayout() -> some View { + self + .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} diff --git a/apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift b/apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift new file mode 100644 index 00000000000..5ac4f9bfe41 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct SettingsSidebarScroll: View { + @ViewBuilder var content: Content + + var body: some View { + ScrollView { + self.content + .padding(.vertical, 10) + .padding(.horizontal, 10) + } + .settingsSidebarCardLayout() + } +} diff --git a/apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift b/apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift new file mode 100644 index 00000000000..6af7ea7de21 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift @@ -0,0 +1,21 @@ +import Foundation + +final class SimpleFileWatcher: @unchecked Sendable { + private let watcher: CoalescingFSEventsWatcher + + init(_ watcher: CoalescingFSEventsWatcher) { + self.watcher = watcher + } + + deinit { + self.stop() + } + + func start() { + self.watcher.start() + } + + func stop() { + self.watcher.stop() + } +} diff --git a/apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift b/apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift new file mode 100644 index 00000000000..acbf58f2b23 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift @@ -0,0 +1,15 @@ +import Foundation + +protocol SimpleFileWatcherOwner: AnyObject { + var watcher: SimpleFileWatcher { get } +} + +extension SimpleFileWatcherOwner { + func start() { + self.watcher.start() + } + + func stop() { + self.watcher.stop() + } +} diff --git a/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift b/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift new file mode 100644 index 00000000000..016b6ae7520 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift @@ -0,0 +1,31 @@ +import Foundation + +@MainActor +enum SimpleTaskSupport { + static func start(task: inout Task?, operation: @escaping @Sendable () async -> Void) { + guard task == nil else { return } + task = Task { + await operation() + } + } + + static func stop(task: inout Task?) { + task?.cancel() + task = nil + } + + static func startDetachedLoop( + task: inout Task?, + interval: TimeInterval, + operation: @escaping @Sendable () async -> Void) + { + guard task == nil else { return } + task = Task.detached { + await operation() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + await operation() + } + } + } +} diff --git a/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift b/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift new file mode 100644 index 00000000000..114b3cdd4c5 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift @@ -0,0 +1,12 @@ +import AppKit +import Foundation + +enum SystemSettingsURLSupport { + static func openFirst(_ candidates: [String]) { + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} diff --git a/apps/macos/Sources/OpenClaw/TalkOverlay.swift b/apps/macos/Sources/OpenClaw/TalkOverlay.swift index 27e5dedc110..055829dd0ea 100644 --- a/apps/macos/Sources/OpenClaw/TalkOverlay.swift +++ b/apps/macos/Sources/OpenClaw/TalkOverlay.swift @@ -30,23 +30,12 @@ final class TalkOverlayController { self.ensureWindow() self.hostingView?.rootView = TalkOverlayView(controller: self) let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.setFrame(target, display: true) - window.orderFrontRegardless() + OverlayPanelFactory.present( + window: self.window, + isVisible: &self.model.isVisible, + target: target) { window in + window.setFrame(target, display: true) + window.orderFrontRegardless() } } @@ -56,13 +45,7 @@ final class TalkOverlayController { return } - let target = window.frame.offsetBy(dx: 6, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.16 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { + OverlayPanelFactory.animateDismiss(window: window) { Task { @MainActor in window.orderOut(nil) self.model.isVisible = false @@ -100,23 +83,11 @@ final class TalkOverlayController { private func ensureWindow() { if self.window != nil { return } - let panel = NSPanel( + let panel = OverlayPanelFactory.makePanel( contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.acceptsMouseMovedEvents = true - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true + level: NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4), + hasShadow: false, + acceptsMouseMovedEvents: true) let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self)) host.translatesAutoresizingMaskIntoConstraints = false diff --git a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift index 80599d55ec3..25d3b78b75d 100644 --- a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift +++ b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift @@ -53,18 +53,7 @@ struct TalkOverlayView: View { private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private var seamColor: Color { - Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor - } - - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) + ColorHexSupport.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor } } diff --git a/apps/macos/Sources/OpenClaw/TextSummarySupport.swift b/apps/macos/Sources/OpenClaw/TextSummarySupport.swift new file mode 100644 index 00000000000..a58caf8800f --- /dev/null +++ b/apps/macos/Sources/OpenClaw/TextSummarySupport.swift @@ -0,0 +1,16 @@ +import Foundation + +enum TextSummarySupport { + static func summarizeLastLine(_ text: String, maxLength: Int = 200) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + if normalized.count > maxLength { + return String(normalized.prefix(maxLength - 1)) + "…" + } + return normalized + } +} diff --git a/apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift b/apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift new file mode 100644 index 00000000000..eda52a99432 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift @@ -0,0 +1,22 @@ +import AppKit + +enum TrackingAreaSupport { + @MainActor + static func resetMouseTracking( + on view: NSView, + tracking: inout NSTrackingArea?, + owner: AnyObject) + { + if let tracking { + view.removeTrackingArea(tracking) + } + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect, + ] + let area = NSTrackingArea(rect: view.bounds, options: options, owner: owner, userInfo: nil) + view.addTrackingArea(area) + tracking = area + } +} diff --git a/apps/macos/Sources/OpenClaw/UsageCostData.swift b/apps/macos/Sources/OpenClaw/UsageCostData.swift index ca1fb5cc3e2..87cef68169b 100644 --- a/apps/macos/Sources/OpenClaw/UsageCostData.swift +++ b/apps/macos/Sources/OpenClaw/UsageCostData.swift @@ -12,13 +12,92 @@ struct GatewayCostUsageTotals: Codable { struct GatewayCostUsageDay: Codable { let date: String - let input: Int - let output: Int - let cacheRead: Int - let cacheWrite: Int - let totalTokens: Int - let totalCost: Double - let missingCostEntries: Int + private let totals: GatewayCostUsageTotals + + var input: Int { + self.totals.input + } + + var output: Int { + self.totals.output + } + + var cacheRead: Int { + self.totals.cacheRead + } + + var cacheWrite: Int { + self.totals.cacheWrite + } + + var totalTokens: Int { + self.totals.totalTokens + } + + var totalCost: Double { + self.totals.totalCost + } + + var missingCostEntries: Int { + self.totals.missingCostEntries + } + + init( + date: String, + input: Int, + output: Int, + cacheRead: Int, + cacheWrite: Int, + totalTokens: Int, + totalCost: Double, + missingCostEntries: Int) + { + self.date = date + self.totals = GatewayCostUsageTotals( + input: input, + output: output, + cacheRead: cacheRead, + cacheWrite: cacheWrite, + totalTokens: totalTokens, + totalCost: totalCost, + missingCostEntries: missingCostEntries) + } + + private enum CodingKeys: String, CodingKey { + case date + case input + case output + case cacheRead + case cacheWrite + case totalTokens + case totalCost + case missingCostEntries + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.date = try c.decode(String.self, forKey: .date) + self.totals = GatewayCostUsageTotals( + input: try c.decode(Int.self, forKey: .input), + output: try c.decode(Int.self, forKey: .output), + cacheRead: try c.decode(Int.self, forKey: .cacheRead), + cacheWrite: try c.decode(Int.self, forKey: .cacheWrite), + totalTokens: try c.decode(Int.self, forKey: .totalTokens), + totalCost: try c.decode(Double.self, forKey: .totalCost), + missingCostEntries: try c.decode(Int.self, forKey: .missingCostEntries)) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(self.date, forKey: .date) + try c.encode(self.input, forKey: .input) + try c.encode(self.output, forKey: .output) + try c.encode(self.cacheRead, forKey: .cacheRead) + try c.encode(self.cacheWrite, forKey: .cacheWrite) + try c.encode(self.totalTokens, forKey: .totalTokens) + try c.encode(self.totalCost, forKey: .totalCost) + try c.encode(self.missingCostEntries, forKey: .missingCostEntries) + } } struct GatewayCostUsageSummary: Codable { diff --git a/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift b/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift index c7f95e47660..0119b527f99 100644 --- a/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift +++ b/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift @@ -9,14 +9,6 @@ struct UsageMenuLabelView: View { private let paddingTrailing: CGFloat = 14 private let barHeight: CGFloat = 6 - private var primaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary - } - var body: some View { VStack(alignment: .leading, spacing: 8) { if let used = row.usedPercent { @@ -30,7 +22,7 @@ struct UsageMenuLabelView: View { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(self.row.titleText) .font(.caption.weight(.semibold)) - .foregroundStyle(self.primaryTextColor) + .foregroundStyle(MenuItemHighlightColors.primary(self.isHighlighted)) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -39,7 +31,7 @@ struct UsageMenuLabelView: View { Text(self.row.detailText()) .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryTextColor) + .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted)) .lineLimit(1) .truncationMode(.tail) .layoutPriority(2) @@ -47,7 +39,7 @@ struct UsageMenuLabelView: View { if self.showsChevron { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryTextColor) + .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted)) .padding(.leading, 2) } } diff --git a/apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift b/apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift new file mode 100644 index 00000000000..722a522f867 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift @@ -0,0 +1,27 @@ +import AppKit + +enum VoiceOverlayTextFormatting { + static func delta(after committed: String, current: String) -> String { + if current.hasPrefix(committed) { + let start = current.index(current.startIndex, offsetBy: committed.count) + return String(current[start...]) + } + return current + } + + static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { + let full = NSMutableAttributedString() + let committedAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: committed, attributes: committedAttr)) + let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor + let volatileAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: volatileColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) + return full + } +} diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift index 6eaa45e0675..4b891d262b0 100644 --- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift +++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift @@ -170,7 +170,8 @@ actor VoicePushToTalk { // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. await VoiceWakeRuntime.shared.pauseForPushToTalk() let adoptedPrefix = self.adoptedPrefix - let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed( + let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : VoiceOverlayTextFormatting + .makeAttributed( committed: adoptedPrefix, volatile: "", isFinal: false) @@ -292,12 +293,15 @@ actor VoicePushToTalk { self.committed = transcript self.volatile = "" } else { - self.volatile = Self.delta(after: self.committed, current: transcript) + self.volatile = VoiceOverlayTextFormatting.delta(after: self.committed, current: transcript) } let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed) let snapshot = Self.join(committedWithPrefix, self.volatile) - let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal) + let attributed = VoiceOverlayTextFormatting.makeAttributed( + committed: committedWithPrefix, + volatile: self.volatile, + isFinal: isFinal) if let token = self.overlayToken { await MainActor.run { VoiceSessionCoordinator.shared.updatePartial( @@ -387,11 +391,11 @@ actor VoicePushToTalk { // MARK: - Test helpers static func _testDelta(committed: String, current: String) -> String { - self.delta(after: committed, current: current) + VoiceOverlayTextFormatting.delta(after: committed, current: current) } static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) { - let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal) + let sample = VoiceOverlayTextFormatting.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal) let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear return (committedColor, volatileColor) @@ -403,27 +407,4 @@ actor VoicePushToTalk { return "\(prefix) \(suffix)" } - private static func delta(after committed: String, current: String) -> String { - if current.hasPrefix(committed) { - let start = current.index(current.startIndex, offsetBy: committed.count) - return String(current[start...]) - } - return current - } - - private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { - let full = NSMutableAttributedString() - let committedAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: committed, attributes: committedAttr)) - let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor - let volatileAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: volatileColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) - return full - } } diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift index af4fae356ee..f8af69c066b 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift @@ -14,8 +14,7 @@ final class VoiceWakeGlobalSettingsSync { } func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in + SimpleTaskSupport.start(task: &self.task) { [weak self] in guard let self else { return } while !Task.isCancelled { do { @@ -39,8 +38,7 @@ final class VoiceWakeGlobalSettingsSync { } func stop() { - self.task?.cancel() - self.task = nil + SimpleTaskSupport.stop(task: &self.task) } private func refreshFromGateway() async { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift index fb5526a8d45..dd19647b761 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift @@ -13,50 +13,29 @@ extension VoiceWakeOverlayController { self.ensureWindow() self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - self.logger.log( - level: .info, - "overlay present windowShown textLen=\(self.model.text.count, privacy: .public)") - // Keep the status item in “listening” mode until we explicitly dismiss the overlay. - AppStateStore.shared.triggerVoiceEars(ttl: nil) - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - self.updateWindowFrame(animate: true) - window.orderFrontRegardless() + OverlayPanelFactory.present( + window: self.window, + isVisible: &self.model.isVisible, + target: target, + onFirstPresent: { + self.logger.log( + level: .info, + "overlay present windowShown textLen=\(self.model.text.count, privacy: .public)") + // Keep the status item in “listening” mode until we explicitly dismiss the overlay. + AppStateStore.shared.triggerVoiceEars(ttl: nil) + }) { window in + self.updateWindowFrame(animate: true) + window.orderFrontRegardless() } } private func ensureWindow() { if self.window != nil { return } let borderPad = self.closeOverflow - let panel = NSPanel( + let panel = OverlayPanelFactory.makePanel( contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.level = Self.preferredWindowLevel - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true + level: Self.preferredWindowLevel, + hasShadow: false) let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self)) host.translatesAutoresizingMaskIntoConstraints = false @@ -84,17 +63,7 @@ extension VoiceWakeOverlayController { } func updateWindowFrame(animate: Bool = false) { - guard let window else { return } - let frame = self.targetFrame() - if animate { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(frame, display: true) - } - } else { - window.setFrame(frame, display: true) - } + OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate) } func measuredHeight() -> CGFloat { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift new file mode 100644 index 00000000000..8dc29b93de8 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift @@ -0,0 +1,62 @@ +import Foundation +import SwabbleKit + +enum VoiceWakeRecognitionDebugSupport { + struct TranscriptSummary { + let textOnly: Bool + let timingCount: Int + } + + static func shouldLogTranscript( + transcript: String, + isFinal: Bool, + loggerLevel: Logger.Level, + lastLoggedText: inout String?, + lastLoggedAt: inout Date?, + minRepeatInterval: TimeInterval = 0.25) -> Bool + { + guard !transcript.isEmpty else { return false } + guard loggerLevel == .debug || loggerLevel == .trace else { return false } + if transcript == lastLoggedText, + !isFinal, + let last = lastLoggedAt, + Date().timeIntervalSince(last) < minRepeatInterval + { + return false + } + lastLoggedText = transcript + lastLoggedAt = Date() + return true + } + + static func textOnlyFallbackMatch( + transcript: String, + triggers: [String], + config: WakeWordGateConfig, + trimWake: (String, [String]) -> String) -> WakeWordGateMatch? + { + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: trimWake) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) + } + + static func transcriptSummary( + transcript: String, + triggers: [String], + segments: [WakeWordSegment]) -> TranscriptSummary + { + TranscriptSummary( + textOnly: WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers), + timingCount: segments.count(where: { $0.start > 0 || $0.duration > 0 })) + } + + static func matchSummary(_ match: WakeWordGateMatch?) -> String { + match.map { + "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" + } ?? "match=false" + } +} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift index b7e2d329b82..7b4d60afd7a 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift @@ -312,10 +312,12 @@ actor VoiceWakeRuntime { self.committedTranscript = trimmed self.volatileTranscript = "" } else { - self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed) + self.volatileTranscript = VoiceOverlayTextFormatting.delta( + after: self.committedTranscript, + current: trimmed) } - let attributed = Self.makeAttributed( + let attributed = VoiceOverlayTextFormatting.makeAttributed( committed: self.committedTranscript, volatile: self.volatileTranscript, isFinal: update.isFinal) @@ -337,10 +339,11 @@ actor VoiceWakeRuntime { var usedFallback = false var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) if match == nil, update.isFinal { - match = self.textOnlyFallbackMatch( + match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch( transcript: transcript, triggers: config.triggers, - config: gateConfig) + config: gateConfig, + trimWake: Self.trimmedAfterTrigger) usedFallback = match != nil } self.maybeLogRecognition( @@ -387,22 +390,19 @@ actor VoiceWakeRuntime { usedFallback: Bool, capturing: Bool) { - guard !transcript.isEmpty else { return } - let level = self.logger.logLevel - guard level == .debug || level == .trace else { return } - if transcript == self.lastLoggedText, !isFinal { - if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { - return - } - } - self.lastLoggedText = transcript - self.lastLoggedAt = Date() + guard VoiceWakeRecognitionDebugSupport.shouldLogTranscript( + transcript: transcript, + isFinal: isFinal, + loggerLevel: self.logger.logLevel, + lastLoggedText: &self.lastLoggedText, + lastLoggedAt: &self.lastLoggedAt) + else { return } - let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) - let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) - let matchSummary = match.map { - "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" - } ?? "match=false" + let summary = VoiceWakeRecognitionDebugSupport.transcriptSummary( + transcript: transcript, + triggers: triggers, + segments: segments) + let matchSummary = VoiceWakeRecognitionDebugSupport.matchSummary(match) let segmentSummary = segments.map { seg in let start = String(format: "%.2f", seg.start) let end = String(format: "%.2f", seg.end) @@ -410,8 +410,8 @@ actor VoiceWakeRuntime { }.joined(separator: ", ") self.logger.debug( - "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + - "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(summary.textOnly) " + + "isFinal=\(isFinal) timing=\(summary.timingCount)/\(segments.count) " + "capturing=\(capturing) fallback=\(usedFallback) " + "\(matchSummary) segments=[\(segmentSummary, privacy: .private)]") } @@ -495,20 +495,6 @@ actor VoiceWakeRuntime { await self.beginCapture(command: "", triggerEndTime: nil, config: config) } - private func textOnlyFallbackMatch( - transcript: String, - triggers: [String], - config: WakeWordGateConfig) -> WakeWordGateMatch? - { - guard let command = VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: config.minCommandLength, - trimWake: Self.trimmedAfterTrigger) - else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) - } - private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool { guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false } guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } @@ -526,10 +512,11 @@ actor VoiceWakeRuntime { guard !self.isCapturing else { return } guard let lastSeenAt, let lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard let match = self.textOnlyFallbackMatch( + guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch( transcript: lastText, triggers: triggers, - config: gateConfig) + config: gateConfig, + trimWake: Self.trimmedAfterTrigger) else { return } if let cooldown = self.cooldownUntil, Date() < cooldown { return @@ -564,7 +551,7 @@ actor VoiceWakeRuntime { } let snapshot = self.committedTranscript + self.volatileTranscript - let attributed = Self.makeAttributed( + let attributed = VoiceOverlayTextFormatting.makeAttributed( committed: self.committedTranscript, volatile: self.volatileTranscript, isFinal: false) @@ -781,33 +768,10 @@ actor VoiceWakeRuntime { } static func _testAttributedColor(isFinal: Bool) -> NSColor { - self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal) + VoiceOverlayTextFormatting.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal) .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear } #endif - private static func delta(after committed: String, current: String) -> String { - if current.hasPrefix(committed) { - let start = current.index(current.startIndex, offsetBy: committed.count) - return String(current[start...]) - } - return current - } - - private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { - let full = NSMutableAttributedString() - let committedAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: committed, attributes: committedAttr)) - let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor - let volatileAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: volatileColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) - return full - } } diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift index d4413618e11..a8db7037893 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift @@ -40,11 +40,7 @@ struct VoiceWakeSettings: View { } private var voiceWakeBinding: Binding { - Binding( - get: { self.state.swabbleEnabled }, - set: { newValue in - Task { await self.state.setVoiceWakeEnabled(newValue) } - }) + MicRefreshSupport.voiceWakeBinding(for: self.state) } var body: some View { @@ -534,30 +530,22 @@ struct VoiceWakeSettings: View { @MainActor private func updateSelectedMicName() { - let selected = self.state.voiceWakeMicID - if selected.isEmpty { - self.state.voiceWakeMicName = "" - return - } - if let match = self.availableMics.first(where: { $0.uid == selected }) { - self.state.voiceWakeMicName = match.name - } + self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName( + selectedID: self.state.voiceWakeMicID, + in: self.availableMics, + uid: \.uid, + name: \.name) } private func startMicObserver() { - self.micObserver.start { - Task { @MainActor in - self.scheduleMicRefresh() - } + MicRefreshSupport.startObserver(self.micObserver) { + self.scheduleMicRefresh() } } @MainActor private func scheduleMicRefresh() { - self.micRefreshTask?.cancel() - self.micRefreshTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 300_000_000) - guard !Task.isCancelled else { return } + MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) { await self.loadMicsIfNeeded(force: true) await self.restartMeter() } diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift index 063fea826ab..906f4a1c8b7 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift @@ -140,10 +140,11 @@ final class VoiceWakeTester { let gateConfig = WakeWordGateConfig(triggers: triggers) var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig) if match == nil, isFinal { - match = self.textOnlyFallbackMatch( + match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch( transcript: text, triggers: triggers, - config: gateConfig) + config: gateConfig, + trimWake: WakeWordGate.stripWake) } self.maybeLogDebug( transcript: text, @@ -273,28 +274,25 @@ final class VoiceWakeTester { match: WakeWordGateMatch?, isFinal: Bool) { - guard !transcript.isEmpty else { return } - let level = self.logger.logLevel - guard level == .debug || level == .trace else { return } - if transcript == self.lastLoggedText, !isFinal { - if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { - return - } - } - self.lastLoggedText = transcript - self.lastLoggedAt = Date() + guard VoiceWakeRecognitionDebugSupport.shouldLogTranscript( + transcript: transcript, + isFinal: isFinal, + loggerLevel: self.logger.logLevel, + lastLoggedText: &self.lastLoggedText, + lastLoggedAt: &self.lastLoggedAt) + else { return } - let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) + let summary = VoiceWakeRecognitionDebugSupport.transcriptSummary( + transcript: transcript, + triggers: triggers, + segments: segments) let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments) let segmentSummary = Self.debugSegments(segments) - let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) - let matchSummary = match.map { - "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" - } ?? "match=false" + let matchSummary = VoiceWakeRecognitionDebugSupport.matchSummary(match) self.logger.debug( - "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + - "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(summary.textOnly) " + + "isFinal=\(isFinal) timing=\(summary.timingCount)/\(segments.count) " + "\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]") } @@ -362,20 +360,6 @@ final class VoiceWakeTester { } } - private func textOnlyFallbackMatch( - transcript: String, - triggers: [String], - config: WakeWordGateConfig) -> WakeWordGateMatch? - { - guard let command = VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: config.minCommandLength, - trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) - else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) - } - private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) { Task { [weak self] in guard let self else { return } @@ -415,10 +399,12 @@ final class VoiceWakeTester { guard !self.isStopping, !self.holdingAfterDetect else { return } guard let lastSeenAt, let lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard let match = self.textOnlyFallbackMatch( + guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch( transcript: lastText, triggers: triggers, - config: WakeWordGateConfig(triggers: triggers)) else { return } + config: WakeWordGateConfig(triggers: triggers), + trimWake: WakeWordGate.stripWake) + else { return } self.holdingAfterDetect = true self.detectedText = match.command self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift index 61d1b4d39b7..47a8c781b8a 100644 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -111,13 +111,7 @@ final class WebChatManager { } func close() { - self.windowController?.close() - self.windowController = nil - self.windowSessionKey = nil - self.panelController?.close() - self.panelController = nil - self.panelSessionKey = nil - self.cachedPreferredSessionKey = nil + self.resetTunnels() } private func panelHidden() { diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 46e5d80a01e..61e19d91381 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -251,10 +251,7 @@ final class WebChatSwiftUIWindowController { } private func removeDismissMonitor() { - if let monitor = self.dismissMonitor { - NSEvent.removeMonitor(monitor) - self.dismissMonitor = nil - } + OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor) } private static func makeWindow( @@ -371,13 +368,6 @@ final class WebChatSwiftUIWindowController { } private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) + ColorHexSupport.color(fromHex: raw) } } diff --git a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift index 77d62963030..ac339a25317 100644 --- a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift +++ b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift @@ -113,17 +113,15 @@ final class WorkActivityStore { private func setJobActive(_ activity: Activity) { self.jobs[activity.sessionKey] = activity - // Main session preempts immediately. - if activity.role == .main { - self.currentSessionKey = activity.sessionKey - } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) { - self.currentSessionKey = activity.sessionKey - } - self.refreshDerivedState() + self.updateCurrentSession(with: activity) } private func setToolActive(_ activity: Activity) { self.tools[activity.sessionKey] = activity + self.updateCurrentSession(with: activity) + } + + private func updateCurrentSession(with activity: Activity) { // Main session preempts immediately. if activity.role == .main { self.currentSessionKey = activity.sessionKey diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift index abd18efaa9a..94361421a98 100644 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -92,31 +92,22 @@ public final class GatewayDiscoveryModel { if !self.browsers.isEmpty { return } for domain in OpenClawBonjour.gatewayServiceDomains { - let params = NWParameters.tcp - params.includePeerToPeer = true - let browser = NWBrowser( - for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain), - using: params) - - browser.stateUpdateHandler = { [weak self] state in - Task { @MainActor in + let browser = GatewayDiscoveryBrowserSupport.makeBrowser( + serviceType: OpenClawBonjour.gatewayServiceType, + domain: domain, + queueLabelPrefix: "ai.openclaw.macos.gateway-discovery", + onState: { [weak self] state in guard let self else { return } self.statesByDomain[domain] = state self.updateStatusText() - } - } - - browser.browseResultsChangedHandler = { [weak self] results, _ in - Task { @MainActor in + }, + onResults: { [weak self] results in guard let self else { return } self.resultsByDomain[domain] = results self.updateGateways(for: domain) self.recomputeGateways() - } - } - + }) self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "ai.openclaw.macos.gateway-discovery.\(domain)")) } self.scheduleWideAreaFallback() @@ -617,8 +608,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate { } func start(timeout: TimeInterval = 2.0) { - self.service.schedule(in: .main, forMode: .common) - self.service.resolve(withTimeout: timeout) + BonjourServiceResolverSupport.start(self.service, timeout: timeout) } func cancel() { @@ -664,9 +654,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate { } private static func normalizeHost(_ raw: String?) -> String? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { return nil } - return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed + BonjourServiceResolverSupport.normalizeHost(raw) } private func formatTXT(_ txt: [String: String]) -> String { diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift index ef78e6f400f..c1db2fdcf13 100644 --- a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift @@ -1,5 +1,5 @@ -import Darwin import Foundation +import OpenClawKit public enum TailscaleNetwork { public static func isTailnetIPv4(_ address: String) -> Bool { @@ -13,34 +13,9 @@ public enum TailscaleNetwork { } public static func detectTailnetIPv4() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - 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 } - if self.isTailnetIPv4(ip) { return ip } + for entry in NetworkInterfaceIPv4.addresses() { + if self.isTailnetIPv4(entry.ip) { return entry.ip } } - return nil } } diff --git a/apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift b/apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift new file mode 100644 index 00000000000..d23c8bcc177 --- /dev/null +++ b/apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift @@ -0,0 +1,9 @@ +import Foundation + +enum CLIArgParsingSupport { + static func nextValue(_ args: [String], index: inout Int) -> String? { + guard index + 1 < args.count else { return nil } + index += 1 + return args[index].trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift index 151b7fdda94..adf2d8599c3 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -53,7 +53,7 @@ struct ConnectOptions { i += 1 continue } - if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) { + if let handler = valueHandlers[arg], let value = CLIArgParsingSupport.nextValue(args, index: &i) { handler(&opts, value) i += 1 continue @@ -62,12 +62,6 @@ struct ConnectOptions { } return opts } - - private static func nextValue(_ args: [String], index: inout Int) -> String? { - guard index + 1 < args.count else { return nil } - index += 1 - return args[index].trimmingCharacters(in: .whitespacesAndNewlines) - } } struct ConnectOutput: Encodable { @@ -233,14 +227,7 @@ private func printConnectOutput(_ output: ConnectOutput, json: Bool) { private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint { let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased() if let raw = opts.url, !raw.isEmpty { - guard let url = URL(string: raw) else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) - } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, mode: resolvedMode, config: config), - password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), - mode: resolvedMode) + return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config) } if resolvedMode == "remote" { @@ -252,14 +239,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) } - guard let url = URL(string: raw) else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) - } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, mode: resolvedMode, config: config), - password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), - mode: resolvedMode) + return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config) } let port = config.port ?? 18789 @@ -281,6 +261,22 @@ private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> try? resolveGatewayEndpoint(opts: opts, config: config) } +private func gatewayEndpoint( + fromRawURL raw: String, + opts: ConnectOptions, + mode: String, + config: GatewayConfig) throws -> GatewayEndpoint +{ + guard let url = URL(string: raw) else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: mode, config: config), + password: resolvedPassword(opts: opts, mode: mode, config: config), + mode: mode) +} + private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { if let token = opts.token, !token.isEmpty { return token } if mode == "remote" { diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index f75ef05fdb2..ea9ff79ffa5 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -23,17 +23,17 @@ struct WizardCliOptions { case "--json": opts.json = true case "--url": - opts.url = self.nextValue(args, index: &i) + opts.url = CLIArgParsingSupport.nextValue(args, index: &i) case "--token": - opts.token = self.nextValue(args, index: &i) + opts.token = CLIArgParsingSupport.nextValue(args, index: &i) case "--password": - opts.password = self.nextValue(args, index: &i) + opts.password = CLIArgParsingSupport.nextValue(args, index: &i) case "--mode": - if let value = nextValue(args, index: &i) { + if let value = CLIArgParsingSupport.nextValue(args, index: &i) { opts.mode = value } case "--workspace": - opts.workspace = self.nextValue(args, index: &i) + opts.workspace = CLIArgParsingSupport.nextValue(args, index: &i) default: break } @@ -41,12 +41,6 @@ struct WizardCliOptions { } return opts } - - private static func nextValue(_ args: [String], index: inout Int) -> String? { - guard index + 1 < args.count else { return nil } - index += 1 - return args[index].trimmingCharacters(in: .whitespacesAndNewlines) - } } enum WizardCliError: Error, CustomStringConvertible { @@ -338,8 +332,7 @@ actor GatewayWizardClient { let frame = try await self.decodeFrame(message) 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 }