mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:58:38 +00:00
refactor(macos): dedupe UI, pairing, and runtime helpers
This commit is contained in:
30
apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift
Normal file
30
apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,21 +9,7 @@ final class AudioInputDeviceObserver {
|
|||||||
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
||||||
|
|
||||||
static func defaultInputDeviceUID() -> String? {
|
static func defaultInputDeviceUID() -> String? {
|
||||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
guard let deviceID = self.defaultInputDeviceID() else { return nil }
|
||||||
var address = AudioObjectPropertyAddress(
|
|
||||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
|
||||||
mElement: kAudioObjectPropertyElementMain)
|
|
||||||
var deviceID = AudioObjectID(0)
|
|
||||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
|
||||||
let status = AudioObjectGetPropertyData(
|
|
||||||
systemObject,
|
|
||||||
&address,
|
|
||||||
0,
|
|
||||||
nil,
|
|
||||||
&size,
|
|
||||||
&deviceID)
|
|
||||||
guard status == noErr, deviceID != 0 else { return nil }
|
|
||||||
return self.deviceUID(for: deviceID)
|
return self.deviceUID(for: deviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +49,15 @@ final class AudioInputDeviceObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func defaultInputDeviceSummary() -> String {
|
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)
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
@@ -77,12 +72,8 @@ final class AudioInputDeviceObserver {
|
|||||||
nil,
|
nil,
|
||||||
&size,
|
&size,
|
||||||
&deviceID)
|
&deviceID)
|
||||||
guard status == noErr, deviceID != 0 else {
|
guard status == noErr, deviceID != 0 else { return nil }
|
||||||
return "defaultInput=unknown"
|
return deviceID
|
||||||
}
|
|
||||||
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
|
||||||
let name = self.deviceName(for: deviceID) ?? "unknown"
|
|
||||||
return "defaultInput=\(name) (\(uid))"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start(onChange: @escaping @Sendable () -> Void) {
|
func start(onChange: @escaping @Sendable () -> Void) {
|
||||||
|
|||||||
@@ -64,45 +64,33 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
try await self.ensureAccess(for: .video)
|
try await self.ensureAccess(for: .video)
|
||||||
|
|
||||||
let session = AVCaptureSession()
|
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
|
||||||
session.sessionPreset = .photo
|
preferFrontCamera: facing == .front,
|
||||||
|
deviceId: deviceId,
|
||||||
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
pickCamera: { preferFrontCamera, deviceId in
|
||||||
throw CameraError.cameraUnavailable
|
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||||
}
|
},
|
||||||
|
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||||
let input = try AVCaptureDeviceInput(device: device)
|
mapSetupError: { setupError in
|
||||||
guard session.canAddInput(input) else {
|
CameraError.captureFailed(setupError.localizedDescription)
|
||||||
throw CameraError.captureFailed("Failed to add camera input")
|
})
|
||||||
}
|
let session = prepared.session
|
||||||
session.addInput(input)
|
let device = prepared.device
|
||||||
|
let output = prepared.output
|
||||||
let output = AVCapturePhotoOutput()
|
|
||||||
guard session.canAddOutput(output) else {
|
|
||||||
throw CameraError.captureFailed("Failed to add photo output")
|
|
||||||
}
|
|
||||||
session.addOutput(output)
|
|
||||||
output.maxPhotoQualityPrioritization = .quality
|
|
||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
await CameraCapturePipelineSupport.warmUpCaptureSession()
|
||||||
await self.waitForExposureAndWhiteBalance(device: device)
|
await self.waitForExposureAndWhiteBalance(device: device)
|
||||||
await self.sleepDelayMs(delayMs)
|
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?
|
var delegate: PhotoCaptureDelegate?
|
||||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
let rawData: Data = try await withCheckedThrowingContinuation { continuation in
|
||||||
let d = PhotoCaptureDelegate(cont)
|
let captureDelegate = PhotoCaptureDelegate(continuation)
|
||||||
delegate = d
|
delegate = captureDelegate
|
||||||
output.capturePhoto(with: settings, delegate: d)
|
output.capturePhoto(
|
||||||
|
with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
|
||||||
|
delegate: captureDelegate)
|
||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
withExtendedLifetime(delegate) {}
|
||||||
|
|
||||||
@@ -135,39 +123,19 @@ actor CameraCaptureService {
|
|||||||
try await self.ensureAccess(for: .audio)
|
try await self.ensureAccess(for: .audio)
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = AVCaptureSession()
|
let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
|
||||||
session.sessionPreset = .high
|
preferFrontCamera: facing == .front,
|
||||||
|
deviceId: deviceId,
|
||||||
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
includeAudio: includeAudio,
|
||||||
throw CameraError.cameraUnavailable
|
durationMs: durationMs,
|
||||||
}
|
pickCamera: { preferFrontCamera, deviceId in
|
||||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||||
guard session.canAddInput(cameraInput) else {
|
},
|
||||||
throw CameraError.captureFailed("Failed to add camera input")
|
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||||
}
|
mapSetupError: Self.mapMovieSetupError)
|
||||||
session.addInput(cameraInput)
|
let session = prepared.session
|
||||||
|
let output = prepared.output
|
||||||
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()
|
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
|
||||||
|
|
||||||
let tmpMovURL = FileManager().temporaryDirectory
|
let tmpMovURL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
||||||
@@ -180,7 +148,6 @@ actor CameraCaptureService {
|
|||||||
return FileManager().temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Ensure we don't fail exporting due to an existing file.
|
// Ensure we don't fail exporting due to an existing file.
|
||||||
try? FileManager().removeItem(at: outputURL)
|
try? FileManager().removeItem(at: outputURL)
|
||||||
|
|
||||||
@@ -192,28 +159,12 @@ actor CameraCaptureService {
|
|||||||
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
withExtendedLifetime(delegate) {}
|
||||||
|
|
||||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
||||||
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
if !(await CameraAuthorization.isAuthorized(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:
|
|
||||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,6 +229,13 @@ actor CameraCaptureService {
|
|||||||
return min(60000, max(250, v))
|
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 {
|
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||||
let asset = AVURLAsset(url: inputURL)
|
let asset = AVURLAsset(url: inputURL)
|
||||||
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
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 {
|
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
||||||
let stepNs: UInt64 = 50_000_000
|
let stepNs: UInt64 = 50_000_000
|
||||||
let maxSteps = 30 // ~1.5s
|
let maxSteps = 30 // ~1.5s
|
||||||
@@ -338,11 +291,7 @@ actor CameraCaptureService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||||
switch position {
|
CameraCapturePipelineSupport.positionLabel(position)
|
||||||
case .front: "front"
|
|
||||||
case .back: "back"
|
|
||||||
default: "unspecified"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,40 +109,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class CanvasFileWatcher: @unchecked Sendable {
|
final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||||
private let watcher: CoalescingFSEventsWatcher
|
let watcher: SimpleFileWatcher
|
||||||
|
|
||||||
init(url: URL, onChange: @escaping () -> Void) {
|
init(url: URL, onChange: @escaping () -> Void) {
|
||||||
self.watcher = CoalescingFSEventsWatcher(
|
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
|
||||||
paths: [url.path],
|
paths: [url.path],
|
||||||
queueLabel: "ai.openclaw.canvaswatcher",
|
queueLabel: "ai.openclaw.canvaswatcher",
|
||||||
onChange: onChange)
|
onChange: onChange))
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
self.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
self.watcher.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
self.watcher.stop()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,22 @@ extension CanvasWindowController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
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 {
|
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 {
|
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||||
|
|||||||
@@ -274,25 +274,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyDebugStatusIfNeeded() {
|
func applyDebugStatusIfNeeded() {
|
||||||
let enabled = self.debugStatusEnabled
|
WebViewJavaScriptSupport.applyDebugStatus(
|
||||||
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
webView: self.webView,
|
||||||
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
enabled: self.debugStatusEnabled,
|
||||||
let js = """
|
title: self.debugStatusTitle,
|
||||||
(() => {
|
subtitle: self.debugStatusSubtitle)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadFile(_ url: URL) {
|
private func loadFile(_ url: URL) {
|
||||||
@@ -302,19 +288,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
|
|
||||||
func eval(javaScript: String) async throws -> String {
|
func eval(javaScript: String) async throws -> String {
|
||||||
try await withCheckedThrowingContinuation { cont in
|
try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript)
|
||||||
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: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(to outPath: String?) async throws -> String {
|
func snapshot(to outPath: String?) async throws -> String {
|
||||||
|
|||||||
@@ -9,6 +9,90 @@ extension ChannelsSettings {
|
|||||||
self.store.snapshot?.decodeChannel(id, as: type)
|
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 {
|
var whatsAppTint: Color {
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
@@ -23,51 +107,51 @@ extension ChannelsSettings {
|
|||||||
var telegramTint: Color {
|
var telegramTint: Color {
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
return self.configuredChannelTint(
|
||||||
if status.lastError != nil { return .orange }
|
configured: status.configured,
|
||||||
if status.probe?.ok == false { return .orange }
|
running: status.running,
|
||||||
if status.running { return .green }
|
hasError: status.lastError != nil,
|
||||||
return .orange
|
probeOk: status.probe?.ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
var discordTint: Color {
|
var discordTint: Color {
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
return self.configuredChannelTint(
|
||||||
if status.lastError != nil { return .orange }
|
configured: status.configured,
|
||||||
if status.probe?.ok == false { return .orange }
|
running: status.running,
|
||||||
if status.running { return .green }
|
hasError: status.lastError != nil,
|
||||||
return .orange
|
probeOk: status.probe?.ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
var googlechatTint: Color {
|
var googlechatTint: Color {
|
||||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
return self.configuredChannelTint(
|
||||||
if status.lastError != nil { return .orange }
|
configured: status.configured,
|
||||||
if status.probe?.ok == false { return .orange }
|
running: status.running,
|
||||||
if status.running { return .green }
|
hasError: status.lastError != nil,
|
||||||
return .orange
|
probeOk: status.probe?.ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
var signalTint: Color {
|
var signalTint: Color {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
return self.configuredChannelTint(
|
||||||
if status.lastError != nil { return .orange }
|
configured: status.configured,
|
||||||
if status.probe?.ok == false { return .orange }
|
running: status.running,
|
||||||
if status.running { return .green }
|
hasError: status.lastError != nil,
|
||||||
return .orange
|
probeOk: status.probe?.ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
var imessageTint: Color {
|
var imessageTint: Color {
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
return self.configuredChannelTint(
|
||||||
if status.lastError != nil { return .orange }
|
configured: status.configured,
|
||||||
if status.probe?.ok == false { return .orange }
|
running: status.running,
|
||||||
if status.running { return .green }
|
hasError: status.lastError != nil,
|
||||||
return .orange
|
probeOk: status.probe?.ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
var whatsAppSummary: String {
|
var whatsAppSummary: String {
|
||||||
@@ -82,41 +166,31 @@ extension ChannelsSettings {
|
|||||||
var telegramSummary: String {
|
var telegramSummary: String {
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var discordSummary: String {
|
var discordSummary: String {
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var googlechatSummary: String {
|
var googlechatSummary: String {
|
||||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var signalSummary: String {
|
var signalSummary: String {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var imessageSummary: String {
|
var imessageSummary: String {
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var whatsAppDetails: String? {
|
var whatsAppDetails: String? {
|
||||||
@@ -168,18 +242,15 @@ extension ChannelsSettings {
|
|||||||
if let url = probe.webhook?.url, !url.isEmpty {
|
if let url = probe.webhook?.url, !url.isEmpty {
|
||||||
lines.append("Webhook: \(url)")
|
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) {
|
return self.finishDetails(
|
||||||
lines.append("Last probe \(relativeAge(from: last))")
|
lines: &lines,
|
||||||
}
|
probeOk: status.probe?.ok,
|
||||||
if let err = status.lastError, !err.isEmpty {
|
probeStatus: status.probe?.status,
|
||||||
lines.append("Error: \(err)")
|
probeElapsedMs: nil,
|
||||||
}
|
lastProbeAtMs: status.lastProbeAt,
|
||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
lastError: status.lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
var discordDetails: String? {
|
var discordDetails: String? {
|
||||||
@@ -189,26 +260,17 @@ extension ChannelsSettings {
|
|||||||
if let source = status.tokenSource {
|
if let source = status.tokenSource {
|
||||||
lines.append("Token source: \(source)")
|
lines.append("Token source: \(source)")
|
||||||
}
|
}
|
||||||
if let probe = status.probe {
|
if let name = status.probe?.bot?.username, !name.isEmpty {
|
||||||
if probe.ok {
|
lines.append("Bot: @\(name)")
|
||||||
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 last = self.date(fromMs: status.lastProbeAt) {
|
return self.finishProbeDetails(
|
||||||
lines.append("Last probe \(relativeAge(from: last))")
|
lines: &lines,
|
||||||
}
|
probe: (
|
||||||
if let err = status.lastError, !err.isEmpty {
|
ok: status.probe?.ok,
|
||||||
lines.append("Error: \(err)")
|
status: status.probe?.status,
|
||||||
}
|
elapsedMs: status.probe?.elapsedMs),
|
||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
lastProbeAtMs: status.lastProbeAt,
|
||||||
|
lastError: status.lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
var googlechatDetails: String? {
|
var googlechatDetails: String? {
|
||||||
@@ -223,23 +285,14 @@ extension ChannelsSettings {
|
|||||||
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
||||||
lines.append("Audience: \(label)")
|
lines.append("Audience: \(label)")
|
||||||
}
|
}
|
||||||
if let probe = status.probe {
|
return self.finishProbeDetails(
|
||||||
if probe.ok {
|
lines: &lines,
|
||||||
if let elapsed = probe.elapsedMs {
|
probe: (
|
||||||
lines.append("Probe \(Int(elapsed))ms")
|
ok: status.probe?.ok,
|
||||||
}
|
status: status.probe?.status,
|
||||||
} else {
|
elapsedMs: status.probe?.elapsedMs),
|
||||||
let code = probe.status.map { String($0) } ?? "unknown"
|
lastProbeAtMs: status.lastProbeAt,
|
||||||
lines.append("Probe failed (\(code))")
|
lastError: status.lastError)
|
||||||
}
|
|
||||||
}
|
|
||||||
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: " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var signalDetails: String? {
|
var signalDetails: String? {
|
||||||
@@ -247,26 +300,14 @@ extension ChannelsSettings {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
lines.append("Base URL: \(status.baseUrl)")
|
lines.append("Base URL: \(status.baseUrl)")
|
||||||
if let probe = status.probe {
|
return self.finishDetails(
|
||||||
if probe.ok {
|
lines: &lines,
|
||||||
if let version = probe.version, !version.isEmpty {
|
probeOk: status.probe?.ok,
|
||||||
lines.append("Version \(version)")
|
probeStatus: status.probe?.status,
|
||||||
}
|
probeElapsedMs: status.probe?.elapsedMs,
|
||||||
if let elapsed = probe.elapsedMs {
|
probeVersion: status.probe?.version,
|
||||||
lines.append("Probe \(Int(elapsed))ms")
|
lastProbeAtMs: status.lastProbeAt,
|
||||||
}
|
lastError: status.lastError)
|
||||||
} 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: " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var imessageDetails: String? {
|
var imessageDetails: String? {
|
||||||
@@ -279,17 +320,14 @@ extension ChannelsSettings {
|
|||||||
if let dbPath = status.dbPath, !dbPath.isEmpty {
|
if let dbPath = status.dbPath, !dbPath.isEmpty {
|
||||||
lines.append("DB: \(dbPath)")
|
lines.append("DB: \(dbPath)")
|
||||||
}
|
}
|
||||||
if let probe = status.probe, !probe.ok {
|
return self.finishDetails(
|
||||||
let err = probe.error ?? "probe failed"
|
lines: &lines,
|
||||||
lines.append("Probe error: \(err)")
|
probeOk: status.probe?.ok,
|
||||||
}
|
probeStatus: nil,
|
||||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
probeElapsedMs: nil,
|
||||||
lines.append("Last probe \(relativeAge(from: last))")
|
probeError: status.probe?.error,
|
||||||
}
|
lastProbeAtMs: status.lastProbeAt,
|
||||||
if let err = status.lastError, !err.isEmpty {
|
lastError: status.lastError)
|
||||||
lines.append("Error: \(err)")
|
|
||||||
}
|
|
||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var orderedChannels: [ChannelItem] {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var sidebar: some View {
|
private var sidebar: some View {
|
||||||
ScrollView {
|
SettingsSidebarScroll {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
if !self.enabledChannels.isEmpty {
|
if !self.enabledChannels.isEmpty {
|
||||||
self.sidebarSectionHeader("Configured")
|
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 {
|
private var detail: some View {
|
||||||
|
|||||||
14
apps/macos/Sources/OpenClaw/ColorHexSupport.swift
Normal file
14
apps/macos/Sources/OpenClaw/ColorHexSupport.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class ConfigFileWatcher: @unchecked Sendable {
|
final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||||
private let url: URL
|
private let url: URL
|
||||||
private let watchedDir: URL
|
private let watchedDir: URL
|
||||||
private let targetPath: String
|
private let targetPath: String
|
||||||
private let targetName: String
|
private let targetName: String
|
||||||
private let watcher: CoalescingFSEventsWatcher
|
let watcher: SimpleFileWatcher
|
||||||
|
|
||||||
init(url: URL, onChange: @escaping () -> Void) {
|
init(url: URL, onChange: @escaping () -> Void) {
|
||||||
self.url = url
|
self.url = url
|
||||||
@@ -15,7 +15,7 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
|||||||
let watchedDirPath = self.watchedDir.path
|
let watchedDirPath = self.watchedDir.path
|
||||||
let targetPath = self.targetPath
|
let targetPath = self.targetPath
|
||||||
let targetName = self.targetName
|
let targetName = self.targetName
|
||||||
self.watcher = CoalescingFSEventsWatcher(
|
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
|
||||||
paths: [watchedDirPath],
|
paths: [watchedDirPath],
|
||||||
queueLabel: "ai.openclaw.configwatcher",
|
queueLabel: "ai.openclaw.configwatcher",
|
||||||
shouldNotify: { _, eventPaths in
|
shouldNotify: { _, eventPaths in
|
||||||
@@ -28,18 +28,7 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
onChange: onChange)
|
onChange: onChange))
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
self.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
self.watcher.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
self.watcher.stop()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ extension ConfigSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var sidebar: some View {
|
private var sidebar: some View {
|
||||||
ScrollView {
|
SettingsSidebarScroll {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
if self.sections.isEmpty {
|
if self.sections.isEmpty {
|
||||||
Text("No config sections available.")
|
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 {
|
private var detail: some View {
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ struct ContextMenuCardView: View {
|
|||||||
private let rows: [SessionRow]
|
private let rows: [SessionRow]
|
||||||
private let statusText: String?
|
private let statusText: String?
|
||||||
private let isLoading: Bool
|
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
|
private let barHeight: CGFloat = 3
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -23,45 +19,32 @@ struct ContextMenuCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
MenuHeaderCard(
|
||||||
HStack(alignment: .firstTextBaseline) {
|
title: "Context",
|
||||||
Text("Context")
|
subtitle: self.subtitle,
|
||||||
.font(.caption.weight(.semibold))
|
statusText: self.statusText,
|
||||||
.foregroundStyle(.secondary)
|
paddingBottom: 8)
|
||||||
Spacer(minLength: 10)
|
{
|
||||||
Text(self.subtitle)
|
if self.statusText == nil {
|
||||||
.font(.caption)
|
if self.rows.isEmpty, !self.isLoading {
|
||||||
.foregroundStyle(.secondary)
|
Text("No active sessions")
|
||||||
}
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
if let statusText {
|
} else {
|
||||||
Text(statusText)
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.caption)
|
if self.rows.isEmpty, self.isLoading {
|
||||||
.foregroundStyle(.secondary)
|
ForEach(0..<2, id: \.self) { _ in
|
||||||
} else if self.rows.isEmpty, !self.isLoading {
|
self.placeholderRow
|
||||||
Text("No active sessions")
|
}
|
||||||
.font(.caption)
|
} else {
|
||||||
.foregroundStyle(.secondary)
|
ForEach(self.rows) { row in
|
||||||
} else {
|
self.sessionRow(row)
|
||||||
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 {
|
private var subtitle: String {
|
||||||
|
|||||||
@@ -336,16 +336,8 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startEventStream() {
|
private func startEventStream() {
|
||||||
self.eventTask?.cancel()
|
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||||
self.eventTask = Task { [weak self] in
|
self?.handle(push: push)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,14 +258,6 @@ extension CronJobEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatDuration(ms: Int) -> String {
|
func formatDuration(ms: Int) -> String {
|
||||||
if ms < 1000 { return "\(ms)ms" }
|
DurationFormattingSupport.conciseDuration(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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ final class CronJobsStore {
|
|||||||
func start() {
|
func start() {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
guard self.eventTask == nil 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
|
self.pollTask = Task.detached { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.refreshJobs()
|
await self.refreshJobs()
|
||||||
@@ -142,20 +144,6 @@ final class CronJobsStore {
|
|||||||
|
|
||||||
// MARK: - Gateway events
|
// 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) {
|
private func handle(push: GatewayPush) {
|
||||||
switch push {
|
switch push {
|
||||||
case let .event(evt) where evt.event == "cron":
|
case let .event(evt) where evt.event == "cron":
|
||||||
|
|||||||
@@ -31,15 +31,7 @@ extension CronSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatDuration(ms: Int) -> String {
|
func formatDuration(ms: Int) -> String {
|
||||||
if ms < 1000 { return "\(ms)ms" }
|
DurationFormattingSupport.conciseDuration(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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
||||||
|
|||||||
@@ -55,48 +55,37 @@ final class DevicePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PairingResolvedEvent: Codable {
|
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||||
let requestId: String
|
|
||||||
let deviceId: String
|
|
||||||
let decision: String
|
|
||||||
let ts: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum PairingResolution: String {
|
|
||||||
case approved
|
|
||||||
case rejected
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
self.startPushTask()
|
||||||
self.isStopping = false
|
}
|
||||||
self.task = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
private func startPushTask() {
|
||||||
_ = try? await GatewayConnection.shared.refresh()
|
PairingAlertSupport.startPairingPushTask(
|
||||||
await self.loadPendingRequestsFromGateway()
|
task: &self.task,
|
||||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
isStopping: &self.isStopping,
|
||||||
for await push in stream {
|
loadPending: self.loadPendingRequestsFromGateway,
|
||||||
if Task.isCancelled { return }
|
handlePush: self.handle(push:))
|
||||||
await MainActor.run { [weak self] in self?.handle(push: push) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.isStopping = true
|
self.stopPushTask()
|
||||||
self.endActiveAlert()
|
|
||||||
self.task?.cancel()
|
|
||||||
self.task = nil
|
|
||||||
self.queue.removeAll(keepingCapacity: false)
|
|
||||||
self.updatePendingCounts()
|
self.updatePendingCounts()
|
||||||
self.isPresenting = false
|
|
||||||
self.activeRequestId = nil
|
|
||||||
self.alertHostWindow?.orderOut(nil)
|
|
||||||
self.alertHostWindow?.close()
|
|
||||||
self.alertHostWindow = nil
|
|
||||||
self.resolvedByRequestId.removeAll(keepingCapacity: false)
|
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 {
|
private func loadPendingRequestsFromGateway() async {
|
||||||
do {
|
do {
|
||||||
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||||
@@ -127,44 +116,23 @@ final class DevicePairingApprovalPrompter {
|
|||||||
|
|
||||||
private func presentAlert(for req: PendingRequest) {
|
private func presentAlert(for req: PendingRequest) {
|
||||||
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
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()
|
private func clearActiveAlert(hostWindow: NSWindow) {
|
||||||
alert.alertStyle = .warning
|
PairingAlertSupport.clearActivePairingAlert(
|
||||||
alert.messageText = "Allow device to connect?"
|
activeAlert: &self.activeAlert,
|
||||||
alert.informativeText = Self.describe(req)
|
activeRequestId: &self.activeRequestId,
|
||||||
alert.addButton(withTitle: "Later")
|
hostWindow: hostWindow)
|
||||||
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 handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||||
@@ -206,24 +174,22 @@ final class DevicePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func approve(requestId: String) async -> Bool {
|
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)
|
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 {
|
private func reject(requestId: String) async {
|
||||||
do {
|
await PairingAlertSupport.rejectRequest(
|
||||||
|
requestId: requestId,
|
||||||
|
kind: "device",
|
||||||
|
logger: self.logger)
|
||||||
|
{
|
||||||
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
|
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)
|
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requireAlertHostWindow() -> NSWindow {
|
|
||||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handle(push: GatewayPush) {
|
private func handle(push: GatewayPush) {
|
||||||
switch push {
|
switch push {
|
||||||
case let .event(evt) where evt.event == "device.pair.requested":
|
case let .event(evt) where evt.event == "device.pair.requested":
|
||||||
@@ -269,8 +231,9 @@ final class DevicePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||||
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution
|
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
|
||||||
.approved : .rejected
|
? PairingAlertSupport.PairingResolution.approved
|
||||||
|
: PairingAlertSupport.PairingResolution.rejected
|
||||||
if let activeRequestId, activeRequestId == resolved.requestId {
|
if let activeRequestId, activeRequestId == resolved.requestId {
|
||||||
self.resolvedByRequestId.insert(resolved.requestId)
|
self.resolvedByRequestId.insert(resolved.requestId)
|
||||||
self.endActiveAlert()
|
self.endActiveAlert()
|
||||||
|
|||||||
15
apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift
Normal file
15
apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,13 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
SimpleTaskSupport.start(task: &self.task) { [weak self] in
|
||||||
self.task = Task { [weak self] in
|
|
||||||
await self?.run()
|
await self?.run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.task?.cancel()
|
SimpleTaskSupport.stop(task: &self.task)
|
||||||
self.task = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func run() async {
|
private func run() async {
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ private struct ExecHostResponse: Codable {
|
|||||||
var error: ExecHostError?
|
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..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
enum ExecApprovalsSocketClient {
|
enum ExecApprovalsSocketClient {
|
||||||
private struct TimeoutError: LocalizedError {
|
private struct TimeoutError: LocalizedError {
|
||||||
var message: String
|
var message: String
|
||||||
@@ -159,28 +175,12 @@ enum ExecApprovalsSocketClient {
|
|||||||
payload.append(0x0A)
|
payload.append(0x0A)
|
||||||
try handle.write(contentsOf: payload)
|
try handle.write(contentsOf: payload)
|
||||||
|
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
|
||||||
let lineData = line.data(using: .utf8)
|
let lineData = line.data(using: .utf8)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||||
return response.decision
|
return response.decision
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readLine(from 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..<newlineIndex)
|
|
||||||
return String(data: lineData, encoding: .utf8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -781,7 +781,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
|
||||||
let data = line.data(using: .utf8)
|
let data = line.data(using: .utf8)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
@@ -815,22 +815,6 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readLine(from 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..<newlineIndex)
|
|
||||||
return String(data: lineData, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendApprovalResponse(
|
private func sendApprovalResponse(
|
||||||
handle: FileHandle,
|
handle: FileHandle,
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -12,19 +12,6 @@ enum ExecCommandToken {
|
|||||||
enum ExecEnvInvocationUnwrapper {
|
enum ExecEnvInvocationUnwrapper {
|
||||||
static let maxWrapperDepth = 4
|
static let maxWrapperDepth = 4
|
||||||
|
|
||||||
private static let optionsWithValue = Set([
|
|
||||||
"-u",
|
|
||||||
"--unset",
|
|
||||||
"-c",
|
|
||||||
"--chdir",
|
|
||||||
"-s",
|
|
||||||
"--split-string",
|
|
||||||
"--default-signal",
|
|
||||||
"--ignore-signal",
|
|
||||||
"--block-signal",
|
|
||||||
])
|
|
||||||
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
|
||||||
|
|
||||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||||
return token.range(of: pattern, options: .regularExpression) != nil
|
return token.range(of: pattern, options: .regularExpression) != nil
|
||||||
@@ -55,11 +42,11 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
if token.hasPrefix("-"), token != "-" {
|
if token.hasPrefix("-"), token != "-" {
|
||||||
let lower = token.lowercased()
|
let lower = token.lowercased()
|
||||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||||
if self.flagOptions.contains(flag) {
|
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if self.optionsWithValue.contains(flag) {
|
if ExecEnvOptions.withValue.contains(flag) {
|
||||||
if !lower.contains("=") {
|
if !lower.contains("=") {
|
||||||
expectsOptionValue = true
|
expectsOptionValue = true
|
||||||
}
|
}
|
||||||
|
|||||||
29
apps/macos/Sources/OpenClaw/ExecEnvOptions.swift
Normal file
29
apps/macos/Sources/OpenClaw/ExecEnvOptions.swift
Normal file
@@ -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=",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -39,30 +39,6 @@ enum ExecSystemRunCommandValidator {
|
|||||||
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
|
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
|
||||||
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--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 {
|
private struct EnvUnwrapResult {
|
||||||
let argv: [String]
|
let argv: [String]
|
||||||
let usesModifiers: Bool
|
let usesModifiers: Bool
|
||||||
@@ -113,7 +89,7 @@ enum ExecSystemRunCommandValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
|
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? {
|
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
|
||||||
@@ -148,12 +124,12 @@ enum ExecSystemRunCommandValidator {
|
|||||||
|
|
||||||
let lower = token.lowercased()
|
let lower = token.lowercased()
|
||||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||||
if self.envFlagOptions.contains(flag) {
|
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||||
usesModifiers = true
|
usesModifiers = true
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if self.envOptionsWithValue.contains(flag) {
|
if ExecEnvOptions.withValue.contains(flag) {
|
||||||
usesModifiers = true
|
usesModifiers = true
|
||||||
if !lower.contains("=") {
|
if !lower.contains("=") {
|
||||||
expectsOptionValue = true
|
expectsOptionValue = true
|
||||||
@@ -301,10 +277,15 @@ enum ExecSystemRunCommandValidator {
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveInlineCommandTokenIndex(
|
private struct InlineCommandTokenMatch {
|
||||||
|
var tokenIndex: Int
|
||||||
|
var inlineCommand: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findInlineCommandTokenMatch(
|
||||||
_ argv: [String],
|
_ argv: [String],
|
||||||
flags: Set<String>,
|
flags: Set<String>,
|
||||||
allowCombinedC: Bool) -> Int?
|
allowCombinedC: Bool) -> InlineCommandTokenMatch?
|
||||||
{
|
{
|
||||||
var idx = 1
|
var idx = 1
|
||||||
while idx < argv.count {
|
while idx < argv.count {
|
||||||
@@ -318,21 +299,35 @@ enum ExecSystemRunCommandValidator {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if flags.contains(lower) {
|
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) {
|
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||||
let inline = String(token.dropFirst(inlineOffset))
|
let inline = String(token.dropFirst(inlineOffset))
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !inline.isEmpty {
|
return InlineCommandTokenMatch(
|
||||||
return idx
|
tokenIndex: idx,
|
||||||
}
|
inlineCommand: inline.isEmpty ? nil : inline)
|
||||||
return idx + 1 < argv.count ? idx + 1 : nil
|
|
||||||
}
|
}
|
||||||
idx += 1
|
idx += 1
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveInlineCommandTokenIndex(
|
||||||
|
_ argv: [String],
|
||||||
|
flags: Set<String>,
|
||||||
|
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? {
|
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
|
||||||
let chars = Array(token.lowercased())
|
let chars = Array(token.lowercased())
|
||||||
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
||||||
@@ -371,30 +366,14 @@ enum ExecSystemRunCommandValidator {
|
|||||||
flags: Set<String>,
|
flags: Set<String>,
|
||||||
allowCombinedC: Bool) -> String?
|
allowCombinedC: Bool) -> String?
|
||||||
{
|
{
|
||||||
var idx = 1
|
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||||
while idx < argv.count {
|
return nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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? {
|
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,25 +180,11 @@ extension GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
|
||||||
guard let start = trimmed.firstIndex(of: "{"),
|
return ParsedDaemonJson(text: parsed.text, object: parsed.object)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func summarize(_ text: String) -> String? {
|
private static func summarize(_ text: String) -> String? {
|
||||||
let lines = text
|
TextSummarySupport.summarizeLastLine(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift
Normal file
34
apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
enum GatewayPushSubscription {
|
||||||
|
@MainActor
|
||||||
|
static func consume(
|
||||||
|
bufferingNewest: Int? = nil,
|
||||||
|
onPush: @escaping @MainActor (GatewayPush) -> Void) async
|
||||||
|
{
|
||||||
|
let stream: AsyncStream<GatewayPush> = 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<Void, Never>?,
|
||||||
|
bufferingNewest: Int? = nil,
|
||||||
|
onPush: @escaping @MainActor (GatewayPush) -> Void)
|
||||||
|
{
|
||||||
|
task?.cancel()
|
||||||
|
task = Task {
|
||||||
|
await self.consume(bufferingNewest: bufferingNewest, onPush: onPush)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import OpenClawKit
|
||||||
|
|
||||||
enum GatewayRemoteConfig {
|
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[..<zoneIndex])
|
|
||||||
}
|
|
||||||
if host.isEmpty {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if host == "localhost" || host == "0.0.0.0" || host == "::" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let ipv4 = IPv4Address(host) {
|
|
||||||
return ipv4.rawValue.first == 127
|
|
||||||
}
|
|
||||||
if let ipv6 = IPv6Address(host) {
|
|
||||||
let bytes = Array(ipv6.rawValue)
|
|
||||||
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
|
|
||||||
if isV6Loopback {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
|
|
||||||
return isMappedV4 && bytes[12] == 127
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||||
guard let gateway = root["gateway"] as? [String: Any],
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
@@ -74,7 +40,7 @@ enum GatewayRemoteConfig {
|
|||||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !host.isEmpty else { return nil }
|
guard !host.isEmpty else { return nil }
|
||||||
if scheme == "ws", !self.isLoopbackHost(host) {
|
if scheme == "ws", !LoopbackHost.isLoopbackHost(host) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if scheme == "ws", url.port == nil {
|
if scheme == "ws", url.port == nil {
|
||||||
|
|||||||
@@ -260,17 +260,7 @@ struct GeneralSettings: View {
|
|||||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
Button {
|
self.remoteTestButton(disabled: !canTest)
|
||||||
Task { await self.testRemote() }
|
|
||||||
} label: {
|
|
||||||
if self.remoteStatus == .checking {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Test remote")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.remoteStatus == .checking || !canTest)
|
|
||||||
}
|
}
|
||||||
if let validationMessage {
|
if let validationMessage {
|
||||||
Text(validationMessage)
|
Text(validationMessage)
|
||||||
@@ -290,18 +280,8 @@ struct GeneralSettings: View {
|
|||||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
Button {
|
self.remoteTestButton(
|
||||||
Task { await self.testRemote() }
|
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
|
"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 {
|
private var controlStatusLine: String {
|
||||||
switch ControlChannel.shared.state {
|
switch ControlChannel.shared.state {
|
||||||
case .connected: "Connected"
|
case .connected: "Connected"
|
||||||
@@ -672,19 +666,7 @@ extension GeneralSettings {
|
|||||||
|
|
||||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||||
|
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,17 +100,8 @@ final class HoverHUDController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = window.frame.offsetBy(dx: 0, dy: 6)
|
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) {
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
self.model.isVisible = false
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,15 +131,7 @@ final class HoverHUDController {
|
|||||||
if !self.model.isVisible {
|
if !self.model.isVisible {
|
||||||
self.model.isVisible = true
|
self.model.isVisible = true
|
||||||
let start = target.offsetBy(dx: 0, dy: 8)
|
let start = target.offsetBy(dx: 0, dy: 8)
|
||||||
window.setFrame(start, display: true)
|
OverlayPanelFactory.animatePresent(window: window, from: start, to: target)
|
||||||
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 {
|
} else {
|
||||||
window.orderFrontRegardless()
|
window.orderFrontRegardless()
|
||||||
self.updateWindowFrame(animate: true)
|
self.updateWindowFrame(animate: true)
|
||||||
@@ -157,22 +140,10 @@ final class HoverHUDController {
|
|||||||
|
|
||||||
private func ensureWindow() {
|
private func ensureWindow() {
|
||||||
if self.window != nil { return }
|
if self.window != nil { return }
|
||||||
let panel = NSPanel(
|
let panel = OverlayPanelFactory.makePanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
level: .statusBar,
|
||||||
backing: .buffered,
|
hasShadow: true)
|
||||||
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
|
|
||||||
|
|
||||||
let host = NSHostingView(rootView: HoverHUDView(controller: self))
|
let host = NSHostingView(rootView: HoverHUDView(controller: self))
|
||||||
host.translatesAutoresizingMaskIntoConstraints = false
|
host.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -201,17 +172,7 @@ final class HoverHUDController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateWindowFrame(animate: Bool = false) {
|
private func updateWindowFrame(animate: Bool = false) {
|
||||||
guard let window else { return }
|
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func installDismissMonitor() {
|
private func installDismissMonitor() {
|
||||||
@@ -231,10 +192,7 @@ final class HoverHUDController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func removeDismissMonitor() {
|
private func removeDismissMonitor() {
|
||||||
if let monitor = self.dismissMonitor {
|
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
|
||||||
NSEvent.removeMonitor(monitor)
|
|
||||||
self.dismissMonitor = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,16 +43,8 @@ struct InstancesSettings: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if self.store.isLoading {
|
SettingsRefreshButton(isLoading: self.store.isLoading) {
|
||||||
ProgressView()
|
Task { await self.store.refresh() }
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
Task { await self.store.refresh() }
|
|
||||||
} label: {
|
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.help("Refresh")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,7 +268,7 @@ struct InstancesSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func platformIcon(_ raw: String) -> String {
|
private func platformIcon(_ raw: String) -> String {
|
||||||
let (prefix, _) = self.parsePlatform(raw)
|
let (prefix, _) = PlatformLabelFormatter.parse(raw)
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "macos":
|
case "macos":
|
||||||
return "laptopcomputer"
|
return "laptopcomputer"
|
||||||
@@ -294,31 +286,7 @@ struct InstancesSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func prettyPlatform(_ raw: String) -> String? {
|
private func prettyPlatform(_ raw: String) -> String? {
|
||||||
let (prefix, version) = self.parsePlatform(raw)
|
PlatformLabelFormatter.pretty(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
|
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
|
||||||
@@ -450,8 +418,8 @@ extension InstancesSettings {
|
|||||||
_ = view.prettyPlatform("ipados 17.1")
|
_ = view.prettyPlatform("ipados 17.1")
|
||||||
_ = view.prettyPlatform("linux")
|
_ = view.prettyPlatform("linux")
|
||||||
_ = view.prettyPlatform(" ")
|
_ = view.prettyPlatform(" ")
|
||||||
_ = view.parsePlatform("macOS 14.1")
|
_ = PlatformLabelFormatter.parse("macOS 14.1")
|
||||||
_ = view.parsePlatform(" ")
|
_ = PlatformLabelFormatter.parse(" ")
|
||||||
_ = view.presenceUpdateSourceShortText("self")
|
_ = view.presenceUpdateSourceShortText("self")
|
||||||
_ = view.presenceUpdateSourceShortText("instances-refresh")
|
_ = view.presenceUpdateSourceShortText("instances-refresh")
|
||||||
_ = view.presenceUpdateSourceShortText("seq gap")
|
_ = view.presenceUpdateSourceShortText("seq gap")
|
||||||
|
|||||||
@@ -62,14 +62,11 @@ final class InstancesStore {
|
|||||||
self.startCount += 1
|
self.startCount += 1
|
||||||
guard self.startCount == 1 else { return }
|
guard self.startCount == 1 else { return }
|
||||||
guard self.task == nil else { return }
|
guard self.task == nil else { return }
|
||||||
self.startGatewaySubscription()
|
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||||
self.task = Task.detached { [weak self] in
|
self?.handle(push: push)
|
||||||
guard let self else { return }
|
}
|
||||||
await self.refresh()
|
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
|
||||||
while !Task.isCancelled {
|
await self?.refresh()
|
||||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
|
||||||
await self.refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,20 +81,6 @@ final class InstancesStore {
|
|||||||
self.eventTask = nil
|
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) {
|
private func handle(push: GatewayPush) {
|
||||||
switch push {
|
switch push {
|
||||||
case let .event(evt) where evt.event == "presence":
|
case let .event(evt) where evt.event == "presence":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,23 +98,42 @@ extension Logger.Message.StringInterpolation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OpenClawOSLogHandler: LogHandler {
|
private func stringifyLogMetadataValue(_ value: Logger.Metadata.Value) -> String {
|
||||||
private let osLogger: os.Logger
|
switch value {
|
||||||
var metadata: Logger.Metadata = [:]
|
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 {
|
var logLevel: Logger.Level {
|
||||||
get { AppLogSettings.logLevel() }
|
get { AppLogSettings.logLevel() }
|
||||||
set { AppLogSettings.setLogLevel(newValue) }
|
set { AppLogSettings.setLogLevel(newValue) }
|
||||||
}
|
}
|
||||||
|
|
||||||
init(subsystem: String, category: String) {
|
|
||||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||||
get { self.metadata[key] }
|
get { self.metadata[key] }
|
||||||
set { self.metadata[key] = newValue }
|
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(
|
func log(
|
||||||
level: Logger.Level,
|
level: Logger.Level,
|
||||||
@@ -157,39 +176,16 @@ struct OpenClawOSLogHandler: LogHandler {
|
|||||||
guard !metadata.isEmpty else { return message.description }
|
guard !metadata.isEmpty else { return message.description }
|
||||||
let meta = metadata
|
let meta = metadata
|
||||||
.sorted(by: { $0.key < $1.key })
|
.sorted(by: { $0.key < $1.key })
|
||||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
return "\(message.description) [\(meta)]"
|
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
|
let label: String
|
||||||
var metadata: Logger.Metadata = [:]
|
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(
|
func log(
|
||||||
level: Logger.Level,
|
level: Logger.Level,
|
||||||
message: Logger.Message,
|
message: Logger.Message,
|
||||||
@@ -212,21 +208,8 @@ struct OpenClawFileLogHandler: LogHandler {
|
|||||||
]
|
]
|
||||||
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||||
for (key, value) in merged {
|
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)
|
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: ",") + "}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,17 +228,7 @@ private final class StatusItemMouseHandlerView: NSView {
|
|||||||
|
|
||||||
override func updateTrackingAreas() {
|
override func updateTrackingAreas() {
|
||||||
super.updateTrackingAreas()
|
super.updateTrackingAreas()
|
||||||
if let tracking {
|
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mouseEntered(with event: NSEvent) {
|
override func mouseEntered(with event: NSEvent) {
|
||||||
|
|||||||
@@ -170,7 +170,11 @@ struct MenuContent: View {
|
|||||||
await self.loadBrowserControlEnabled()
|
await self.loadBrowserControlEnabled()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.startMicObserver()
|
MicRefreshSupport.startObserver(self.micObserver) {
|
||||||
|
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||||
|
await self.loadMicrophones(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
self.micRefreshTask?.cancel()
|
self.micRefreshTask?.cancel()
|
||||||
@@ -425,11 +429,7 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var voiceWakeBinding: Binding<Bool> {
|
private var voiceWakeBinding: Binding<Bool> {
|
||||||
Binding(
|
MicRefreshSupport.voiceWakeBinding(for: self.state)
|
||||||
get: { self.state.swabbleEnabled },
|
|
||||||
set: { newValue in
|
|
||||||
Task { await self.state.setVoiceWakeEnabled(newValue) }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showVoiceWakeMicPicker: Bool {
|
private var showVoiceWakeMicPicker: Bool {
|
||||||
@@ -546,46 +546,20 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||||
self.availableMics = self.filterAliveInputs(self.availableMics)
|
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
|
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] {
|
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
|
||||||
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||||
guard !aliveUIDs.isEmpty else { return inputs }
|
guard !aliveUIDs.isEmpty else { return inputs }
|
||||||
return inputs.filter { aliveUIDs.contains($0.uid) }
|
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 {
|
private struct AudioInputDevice: Identifiable, Equatable {
|
||||||
let uid: String
|
let uid: String
|
||||||
let name: String
|
let name: String
|
||||||
|
|||||||
52
apps/macos/Sources/OpenClaw/MenuHeaderCard.swift
Normal file
52
apps/macos/Sources/OpenClaw/MenuHeaderCard.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MenuHeaderCard<Content: View>: 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,17 +33,7 @@ final class HighlightedMenuItemHostView: NSView {
|
|||||||
|
|
||||||
override func updateTrackingAreas() {
|
override func updateTrackingAreas() {
|
||||||
super.updateTrackingAreas()
|
super.updateTrackingAreas()
|
||||||
if let tracking {
|
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mouseEntered(with event: NSEvent) {
|
override func mouseEntered(with event: NSEvent) {
|
||||||
|
|||||||
22
apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift
Normal file
22
apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,37 +4,11 @@ struct MenuSessionsHeaderView: View {
|
|||||||
let count: Int
|
let count: Int
|
||||||
let statusText: String?
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
MenuHeaderCard(
|
||||||
HStack(alignment: .firstTextBaseline) {
|
title: "Context",
|
||||||
Text("Context")
|
subtitle: self.subtitle,
|
||||||
.font(.caption.weight(.semibold))
|
statusText: self.statusText)
|
||||||
.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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
|
|||||||
@@ -3,29 +3,10 @@ import SwiftUI
|
|||||||
struct MenuUsageHeaderView: View {
|
struct MenuUsageHeaderView: View {
|
||||||
let count: Int
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
MenuHeaderCard(
|
||||||
HStack(alignment: .firstTextBaseline) {
|
title: "Usage",
|
||||||
Text("Usage")
|
subtitle: self.subtitle)
|
||||||
.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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
|
|||||||
46
apps/macos/Sources/OpenClaw/MicRefreshSupport.swift
Normal file
46
apps/macos/Sources/OpenClaw/MicRefreshSupport.swift
Normal file
@@ -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<Void, Never>?,
|
||||||
|
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<T>(
|
||||||
|
selectedID: String,
|
||||||
|
in devices: [T],
|
||||||
|
uid: KeyPath<T, String>,
|
||||||
|
name: KeyPath<T, String>) -> String
|
||||||
|
{
|
||||||
|
guard !selectedID.isEmpty else { return "" }
|
||||||
|
return devices.first(where: { $0[keyPath: uid] == selectedID })?[keyPath: name] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func voiceWakeBinding(for state: AppState) -> Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { state.swabbleEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
Task { await state.setVoiceWakeEnabled(newValue) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||||
enum Error: Swift.Error {
|
enum Error: Swift.Error {
|
||||||
case timeout
|
case timeout
|
||||||
case unavailable
|
case unavailable
|
||||||
@@ -12,21 +12,18 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
private let manager = CLLocationManager()
|
private let manager = CLLocationManager()
|
||||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||||
|
|
||||||
|
var locationManager: CLLocationManager {
|
||||||
|
self.manager
|
||||||
|
}
|
||||||
|
|
||||||
|
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
|
||||||
|
get { self.locationContinuation }
|
||||||
|
set { self.locationContinuation = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
self.manager.delegate = self
|
self.configureLocationManager()
|
||||||
self.manager.desiredAccuracy = kCLLocationAccuracyBest
|
|
||||||
}
|
|
||||||
|
|
||||||
func authorizationStatus() -> CLAuthorizationStatus {
|
|
||||||
self.manager.authorizationStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
|
||||||
if #available(macOS 11.0, *) {
|
|
||||||
return self.manager.accuracyAuthorization
|
|
||||||
}
|
|
||||||
return .fullAccuracy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentLocation(
|
func currentLocation(
|
||||||
@@ -37,26 +34,15 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
guard CLLocationManager.locationServicesEnabled() else {
|
guard CLLocationManager.locationServicesEnabled() else {
|
||||||
throw Error.unavailable
|
throw Error.unavailable
|
||||||
}
|
}
|
||||||
|
return try await LocationCurrentRequest.resolve(
|
||||||
let now = Date()
|
manager: self.manager,
|
||||||
if let maxAgeMs,
|
desiredAccuracy: desiredAccuracy,
|
||||||
let cached = self.manager.location,
|
maxAgeMs: maxAgeMs,
|
||||||
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
|
timeoutMs: timeoutMs,
|
||||||
{
|
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
|
||||||
return cached
|
try await self.withTimeout(timeoutMs: timeoutMs) {
|
||||||
}
|
try await operation()
|
||||||
|
}
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
|||||||
@@ -68,55 +68,45 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PairingResolvedEvent: Codable {
|
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||||
let requestId: String
|
private typealias PairingResolution = PairingAlertSupport.PairingResolution
|
||||||
let nodeId: String
|
|
||||||
let decision: String
|
|
||||||
let ts: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum PairingResolution: String {
|
|
||||||
case approved
|
|
||||||
case rejected
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
|
||||||
self.isStopping = false
|
|
||||||
self.reconcileTask?.cancel()
|
self.reconcileTask?.cancel()
|
||||||
self.reconcileTask = nil
|
self.reconcileTask = nil
|
||||||
self.task = Task { [weak self] in
|
self.startPushTask()
|
||||||
guard let self else { return }
|
}
|
||||||
_ = try? await GatewayConnection.shared.refresh()
|
|
||||||
await self.loadPendingRequestsFromGateway()
|
private func startPushTask() {
|
||||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
PairingAlertSupport.startPairingPushTask(
|
||||||
for await push in stream {
|
task: &self.task,
|
||||||
if Task.isCancelled { return }
|
isStopping: &self.isStopping,
|
||||||
await MainActor.run { [weak self] in self?.handle(push: push) }
|
loadPending: self.loadPendingRequestsFromGateway,
|
||||||
}
|
handlePush: self.handle(push:))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.isStopping = true
|
self.stopPushTask()
|
||||||
self.endActiveAlert()
|
|
||||||
self.task?.cancel()
|
|
||||||
self.task = nil
|
|
||||||
self.reconcileTask?.cancel()
|
self.reconcileTask?.cancel()
|
||||||
self.reconcileTask = nil
|
self.reconcileTask = nil
|
||||||
self.reconcileOnceTask?.cancel()
|
self.reconcileOnceTask?.cancel()
|
||||||
self.reconcileOnceTask = nil
|
self.reconcileOnceTask = nil
|
||||||
self.queue.removeAll(keepingCapacity: false)
|
|
||||||
self.updatePendingCounts()
|
self.updatePendingCounts()
|
||||||
self.isPresenting = false
|
|
||||||
self.activeRequestId = nil
|
|
||||||
self.alertHostWindow?.orderOut(nil)
|
|
||||||
self.alertHostWindow?.close()
|
|
||||||
self.alertHostWindow = nil
|
|
||||||
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||||
self.autoApproveAttempts.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 {
|
private func loadPendingRequestsFromGateway() async {
|
||||||
// The gateway process may start slightly after the app. Retry a bit so
|
// The gateway process may start slightly after the app. Retry a bit so
|
||||||
// pending pairing prompts are still shown on launch.
|
// pending pairing prompts are still shown on launch.
|
||||||
@@ -235,10 +225,6 @@ final class NodePairingApprovalPrompter {
|
|||||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requireAlertHostWindow() -> NSWindow {
|
|
||||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handle(push: GatewayPush) {
|
private func handle(push: GatewayPush) {
|
||||||
switch push {
|
switch push {
|
||||||
case let .event(evt) where evt.event == "node.pair.requested":
|
case let .event(evt) where evt.event == "node.pair.requested":
|
||||||
@@ -293,47 +279,23 @@ final class NodePairingApprovalPrompter {
|
|||||||
|
|
||||||
private func presentAlert(for req: PendingRequest) {
|
private func presentAlert(for req: PendingRequest) {
|
||||||
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
|
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()
|
private func clearActiveAlert(hostWindow: NSWindow) {
|
||||||
alert.alertStyle = .warning
|
PairingAlertSupport.clearActivePairingAlert(
|
||||||
alert.messageText = "Allow node to connect?"
|
activeAlert: &self.activeAlert,
|
||||||
alert.informativeText = Self.describe(req)
|
activeRequestId: &self.activeRequestId,
|
||||||
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
|
hostWindow: hostWindow)
|
||||||
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 handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||||
@@ -373,24 +335,22 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func approve(requestId: String) async -> Bool {
|
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)
|
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 {
|
private func reject(requestId: String) async {
|
||||||
do {
|
await PairingAlertSupport.rejectRequest(
|
||||||
|
requestId: requestId,
|
||||||
|
kind: "node",
|
||||||
|
logger: self.logger)
|
||||||
|
{
|
||||||
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
|
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? {
|
private static func prettyPlatform(_ platform: String?) -> String? {
|
||||||
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard let raw, !raw.isEmpty else { return nil }
|
guard let raw, !raw.isEmpty else { return nil }
|
||||||
if raw.lowercased() == "ios" { return "iOS" }
|
if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty }
|
||||||
if raw.lowercased() == "macos" { return "macOS" }
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,15 +103,9 @@ extension NodeServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
|
||||||
guard let start = trimmed.firstIndex(of: "{"),
|
let jsonText = parsed.text
|
||||||
let end = trimmed.lastIndex(of: "}")
|
let object = parsed.object
|
||||||
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 }
|
|
||||||
let ok = object["ok"] as? Bool
|
let ok = object["ok"] as? Bool
|
||||||
let result = object["result"] as? String
|
let result = object["result"] as? String
|
||||||
let message = object["message"] as? String
|
let message = object["message"] as? String
|
||||||
@@ -139,12 +133,6 @@ extension NodeServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func summarize(_ text: String) -> String? {
|
private static func summarize(_ text: String) -> String? {
|
||||||
let lines = text
|
TextSummarySupport.summarizeLastLine(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ struct NodeMenuEntryFormatter {
|
|||||||
|
|
||||||
static func platformText(_ entry: NodeInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
if let raw = entry.platform?.nonEmpty {
|
if let raw = entry.platform?.nonEmpty {
|
||||||
return self.prettyPlatform(raw) ?? raw
|
return PlatformLabelFormatter.pretty(raw) ?? raw
|
||||||
}
|
}
|
||||||
if let family = entry.deviceFamily?.lowercased() {
|
if let family = entry.deviceFamily?.lowercased() {
|
||||||
if family.contains("mac") { return "macOS" }
|
if family.contains("mac") { return "macOS" }
|
||||||
@@ -79,34 +79,6 @@ struct NodeMenuEntryFormatter {
|
|||||||
return nil
|
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 {
|
private static func compactVersion(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return trimmed }
|
guard !trimmed.isEmpty else { return trimmed }
|
||||||
@@ -201,12 +173,8 @@ struct NodeMenuRowView: View {
|
|||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
private var primaryColor: Color {
|
private var palette: MenuItemHighlightColors.Palette {
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
MenuItemHighlightColors.palette(self.isHighlighted)
|
||||||
}
|
|
||||||
|
|
||||||
private var secondaryColor: Color {
|
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -216,9 +184,9 @@ struct NodeMenuRowView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||||
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
|
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
|
||||||
.foregroundStyle(self.primaryColor)
|
.foregroundStyle(self.palette.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
@@ -227,9 +195,9 @@ struct NodeMenuRowView: View {
|
|||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
|
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
|
||||||
Text(right)
|
Text(right)
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.layoutPriority(2)
|
.layoutPriority(2)
|
||||||
@@ -237,7 +205,7 @@ struct NodeMenuRowView: View {
|
|||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +213,7 @@ struct NodeMenuRowView: View {
|
|||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
|
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|
||||||
@@ -254,7 +222,7 @@ struct NodeMenuRowView: View {
|
|||||||
if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) {
|
if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) {
|
||||||
Text(version)
|
Text(version)
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
}
|
}
|
||||||
@@ -273,11 +241,11 @@ struct NodeMenuRowView: View {
|
|||||||
private var leadingIcon: some View {
|
private var leadingIcon: some View {
|
||||||
if NodeMenuEntryFormatter.isAndroid(self.entry) {
|
if NodeMenuEntryFormatter.isAndroid(self.entry) {
|
||||||
AndroidMark()
|
AndroidMark()
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
|
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
|
||||||
.font(.system(size: 18, weight: .regular))
|
.font(.system(size: 18, weight: .regular))
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,23 +273,19 @@ struct NodeMenuMultilineView: View {
|
|||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
private var primaryColor: Color {
|
private var palette: MenuItemHighlightColors.Palette {
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
MenuItemHighlightColors.palette(self.isHighlighted)
|
||||||
}
|
|
||||||
|
|
||||||
private var secondaryColor: Color {
|
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("\(self.label):")
|
Text("\(self.label):")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(self.secondaryColor)
|
.foregroundStyle(self.palette.secondary)
|
||||||
|
|
||||||
Text(self.value)
|
Text(self.value)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(self.primaryColor)
|
.foregroundStyle(self.palette.primary)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,14 +54,8 @@ final class NodesStore {
|
|||||||
func start() {
|
func start() {
|
||||||
self.startCount += 1
|
self.startCount += 1
|
||||||
guard self.startCount == 1 else { return }
|
guard self.startCount == 1 else { return }
|
||||||
guard self.task == nil else { return }
|
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
|
||||||
self.task = Task.detached { [weak self] in
|
await self?.refresh()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,17 +50,8 @@ final class NotifyOverlayController {
|
|||||||
self.dismissTask = nil
|
self.dismissTask = nil
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
|
|
||||||
let target = window.frame.offsetBy(dx: 8, dy: 6)
|
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 8, offsetY: 6) {
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
self.model.isVisible = false
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,44 +61,21 @@ final class NotifyOverlayController {
|
|||||||
self.ensureWindow()
|
self.ensureWindow()
|
||||||
self.hostingView?.rootView = NotifyOverlayView(controller: self)
|
self.hostingView?.rootView = NotifyOverlayView(controller: self)
|
||||||
let target = self.targetFrame()
|
let target = self.targetFrame()
|
||||||
|
OverlayPanelFactory.present(
|
||||||
guard let window else { return }
|
window: self.window,
|
||||||
if !self.model.isVisible {
|
isVisible: &self.model.isVisible,
|
||||||
self.model.isVisible = true
|
target: target) { window in
|
||||||
let start = target.offsetBy(dx: 0, dy: -6)
|
self.updateWindowFrame(animate: true)
|
||||||
window.setFrame(start, display: true)
|
window.orderFrontRegardless()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureWindow() {
|
private func ensureWindow() {
|
||||||
if self.window != nil { return }
|
if self.window != nil { return }
|
||||||
let panel = NSPanel(
|
let panel = OverlayPanelFactory.makePanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight),
|
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
level: .statusBar,
|
||||||
backing: .buffered,
|
hasShadow: true)
|
||||||
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
|
|
||||||
|
|
||||||
let host = NSHostingView(rootView: NotifyOverlayView(controller: self))
|
let host = NSHostingView(rootView: NotifyOverlayView(controller: self))
|
||||||
host.translatesAutoresizingMaskIntoConstraints = false
|
host.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -126,17 +94,7 @@ final class NotifyOverlayController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateWindowFrame(animate: Bool = false) {
|
private func updateWindowFrame(animate: Bool = false) {
|
||||||
guard let window else { return }
|
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func measuredHeight() -> CGFloat {
|
private func measuredHeight() -> CGFloat {
|
||||||
|
|||||||
@@ -24,19 +24,7 @@ extension OnboardingView {
|
|||||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state.connectionMode = .remote
|
self.state.connectionMode = .remote
|
||||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||||
|
|||||||
@@ -189,19 +189,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
|
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
self.featureRowContent(title: title, subtitle: subtitle, systemImage: systemImage)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func featureActionRow(
|
func featureActionRow(
|
||||||
@@ -210,6 +198,22 @@ extension OnboardingView {
|
|||||||
systemImage: String,
|
systemImage: String,
|
||||||
buttonTitle: String,
|
buttonTitle: String,
|
||||||
action: @escaping () -> Void) -> some View
|
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) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: systemImage)
|
Image(systemName: systemImage)
|
||||||
@@ -221,9 +225,9 @@ extension OnboardingView {
|
|||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Button(buttonTitle, action: action)
|
if let action {
|
||||||
.buttonStyle(.link)
|
action
|
||||||
.padding(.top, 2)
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,9 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updatePermissionMonitoring(for pageIndex: Int) {
|
func updatePermissionMonitoring(for pageIndex: Int) {
|
||||||
let shouldMonitor = pageIndex == self.permissionsPageIndex
|
PermissionMonitoringSupport.setMonitoring(
|
||||||
if shouldMonitor, !self.monitoringPermissions {
|
pageIndex == self.permissionsPageIndex,
|
||||||
self.monitoringPermissions = true
|
monitoring: &self.monitoringPermissions)
|
||||||
PermissionMonitor.shared.register()
|
|
||||||
} else if !shouldMonitor, self.monitoringPermissions {
|
|
||||||
self.monitoringPermissions = false
|
|
||||||
PermissionMonitor.shared.unregister()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDiscoveryMonitoring(for pageIndex: Int) {
|
func updateDiscoveryMonitoring(for pageIndex: Int) {
|
||||||
@@ -51,9 +46,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopPermissionMonitoring() {
|
func stopPermissionMonitoring() {
|
||||||
guard self.monitoringPermissions else { return }
|
PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions)
|
||||||
self.monitoringPermissions = false
|
|
||||||
PermissionMonitor.shared.unregister()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopDiscovery() {
|
func stopDiscovery() {
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ extension OnboardingView {
|
|||||||
|
|
||||||
private func loadAgentWorkspace() async -> String? {
|
private func loadAgentWorkspace() async -> String? {
|
||||||
let root = await ConfigStore.load()
|
let root = await ConfigStore.load()
|
||||||
let agents = root["agents"] as? [String: Any]
|
return AgentWorkspaceConfig.workspace(from: root)
|
||||||
let defaults = agents?["defaults"] as? [String: Any]
|
|
||||||
return defaults?["workspace"] as? String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -87,24 +85,7 @@ extension OnboardingView {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
|
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
|
||||||
var root = await ConfigStore.load()
|
var root = await ConfigStore.load()
|
||||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
|
||||||
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
|
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
try await ConfigStore.save(root)
|
try await ConfigStore.save(root)
|
||||||
return (true, nil)
|
return (true, nil)
|
||||||
|
|||||||
@@ -127,34 +127,15 @@ enum OpenClawConfigFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func agentWorkspace() -> String? {
|
static func agentWorkspace() -> String? {
|
||||||
let root = self.loadDict()
|
AgentWorkspaceConfig.workspace(from: self.loadDict())
|
||||||
let agents = root["agents"] as? [String: Any]
|
|
||||||
let defaults = agents?["defaults"] as? [String: Any]
|
|
||||||
return defaults?["workspace"] as? String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func setAgentWorkspace(_ workspace: String?) {
|
static func setAgentWorkspace(_ workspace: String?) {
|
||||||
var root = self.loadDict()
|
var root = self.loadDict()
|
||||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
|
||||||
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
|
|
||||||
}
|
|
||||||
self.saveDict(root)
|
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? {
|
static func gatewayPassword() -> String? {
|
||||||
@@ -249,7 +230,7 @@ enum OpenClawConfigFile {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hostKey(_ host: String) -> String {
|
static func hostKey(_ host: String) -> String {
|
||||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
guard !trimmed.isEmpty else { return "" }
|
guard !trimmed.isEmpty else { return "" }
|
||||||
if trimmed.contains(":") { return trimmed }
|
if trimmed.contains(":") { return trimmed }
|
||||||
|
|||||||
126
apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift
Normal file
126
apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import OpenClawKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
final class PairingAlertHostWindow: NSWindow {
|
final class PairingAlertHostWindow: NSWindow {
|
||||||
override var canBecomeKey: Bool {
|
override var canBecomeKey: Bool {
|
||||||
@@ -12,6 +14,17 @@ final class PairingAlertHostWindow: NSWindow {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum PairingAlertSupport {
|
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?) {
|
static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
|
||||||
guard let alert = activeAlert else { return }
|
guard let alert = activeAlert else { return }
|
||||||
if let parent = alert.window.sheetParent {
|
if let parent = alert.window.sheetParent {
|
||||||
@@ -43,4 +56,189 @@ enum PairingAlertSupport {
|
|||||||
alertHostWindow = window
|
alertHostWindow = window
|
||||||
return 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<Void, Never>?,
|
||||||
|
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: 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<Request>(
|
||||||
|
isStopping: inout Bool,
|
||||||
|
activeAlert: inout NSAlert?,
|
||||||
|
activeRequestId: inout String?,
|
||||||
|
task: inout Task<Void, Never>?,
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,61 +229,37 @@ enum PermissionManager {
|
|||||||
|
|
||||||
enum NotificationPermissionHelper {
|
enum NotificationPermissionHelper {
|
||||||
static func openSettings() {
|
static func openSettings() {
|
||||||
let candidates = [
|
SystemSettingsURLSupport.openFirst([
|
||||||
"x-apple.systempreferences:com.apple.Notifications-Settings.extension",
|
"x-apple.systempreferences:com.apple.Notifications-Settings.extension",
|
||||||
"x-apple.systempreferences:com.apple.preference.notifications",
|
"x-apple.systempreferences:com.apple.preference.notifications",
|
||||||
]
|
])
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MicrophonePermissionHelper {
|
enum MicrophonePermissionHelper {
|
||||||
static func openSettings() {
|
static func openSettings() {
|
||||||
let candidates = [
|
SystemSettingsURLSupport.openFirst([
|
||||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
|
||||||
"x-apple.systempreferences:com.apple.preference.security",
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
]
|
])
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CameraPermissionHelper {
|
enum CameraPermissionHelper {
|
||||||
static func openSettings() {
|
static func openSettings() {
|
||||||
let candidates = [
|
SystemSettingsURLSupport.openFirst([
|
||||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
||||||
"x-apple.systempreferences:com.apple.preference.security",
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
]
|
])
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LocationPermissionHelper {
|
enum LocationPermissionHelper {
|
||||||
static func openSettings() {
|
static func openSettings() {
|
||||||
let candidates = [
|
SystemSettingsURLSupport.openFirst([
|
||||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
|
||||||
"x-apple.systempreferences:com.apple.preference.security",
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
]
|
])
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift
Normal file
31
apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift
Normal file
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,8 +152,8 @@ final class RemotePortTunnel {
|
|||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let sshKey = Self.hostKey(sshHost)
|
let sshKey = OpenClawConfigFile.hostKey(sshHost)
|
||||||
let urlKey = Self.hostKey(host)
|
let urlKey = OpenClawConfigFile.hostKey(host)
|
||||||
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
|
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
|
||||||
guard sshKey == urlKey else {
|
guard sshKey == urlKey else {
|
||||||
Self.logger.debug(
|
Self.logger.debug(
|
||||||
@@ -163,17 +163,6 @@ final class RemotePortTunnel {
|
|||||||
return port
|
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 {
|
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
|
||||||
if let preferred, self.portIsFree(preferred) { return preferred }
|
if let preferred, self.portIsFree(preferred) { return preferred }
|
||||||
if let preferred, !allowRandom {
|
if let preferred, !allowRandom {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
import OSLog
|
import OSLog
|
||||||
@preconcurrency import ScreenCaptureKit
|
@preconcurrency import ScreenCaptureKit
|
||||||
|
|
||||||
@@ -34,8 +35,8 @@ final class ScreenRecordService {
|
|||||||
includeAudio: Bool?,
|
includeAudio: Bool?,
|
||||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||||
{
|
{
|
||||||
let durationMs = Self.clampDurationMs(durationMs)
|
let durationMs = CaptureRateLimits.clampDurationMs(durationMs)
|
||||||
let fps = Self.clampFps(fps)
|
let fps = CaptureRateLimits.clampFps(fps, maxFps: 60)
|
||||||
let includeAudio = includeAudio ?? false
|
let includeAudio = includeAudio ?? false
|
||||||
|
|
||||||
let outURL: URL = {
|
let outURL: URL = {
|
||||||
@@ -96,17 +97,6 @@ final class ScreenRecordService {
|
|||||||
try await recorder.finish()
|
try await recorder.finish()
|
||||||
return (path: outURL.path, hasAudio: recorder.hasAudio)
|
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 {
|
private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ struct SessionMenuLabelView: View {
|
|||||||
private let paddingTrailing: CGFloat = 14
|
private let paddingTrailing: CGFloat = 14
|
||||||
private let barHeight: CGFloat = 6
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ContextUsageBar(
|
ContextUsageBar(
|
||||||
@@ -31,7 +23,7 @@ struct SessionMenuLabelView: View {
|
|||||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||||
Text(self.row.label)
|
Text(self.row.label)
|
||||||
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
||||||
.foregroundStyle(self.primaryTextColor)
|
.foregroundStyle(MenuItemHighlightColors.primary(self.isHighlighted))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
@@ -40,14 +32,14 @@ struct SessionMenuLabelView: View {
|
|||||||
|
|
||||||
Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)")
|
Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)")
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
.foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.layoutPriority(2)
|
.layoutPriority(2)
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
.foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,8 @@ struct SessionsSettings: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if self.loading {
|
SettingsRefreshButton(isLoading: self.loading) {
|
||||||
ProgressView()
|
Task { await self.refresh() }
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
Task { await self.refresh() }
|
|
||||||
} label: {
|
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.help("Refresh")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift
Normal file
18
apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,20 +158,11 @@ struct SettingsRootView: View {
|
|||||||
|
|
||||||
private func updatePermissionMonitoring(for tab: SettingsTab) {
|
private func updatePermissionMonitoring(for tab: SettingsTab) {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
let shouldMonitor = tab == .permissions
|
PermissionMonitoringSupport.setMonitoring(tab == .permissions, monitoring: &self.monitoringPermissions)
|
||||||
if shouldMonitor, !self.monitoringPermissions {
|
|
||||||
self.monitoringPermissions = true
|
|
||||||
PermissionMonitor.shared.register()
|
|
||||||
} else if !shouldMonitor, self.monitoringPermissions {
|
|
||||||
self.monitoringPermissions = false
|
|
||||||
PermissionMonitor.shared.unregister()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopPermissionMonitoring() {
|
private func stopPermissionMonitoring() {
|
||||||
guard self.monitoringPermissions else { return }
|
PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions)
|
||||||
self.monitoringPermissions = false
|
|
||||||
PermissionMonitor.shared.unregister()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift
Normal file
12
apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift
Normal file
14
apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsSidebarScroll<Content: View>: View {
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
self.content
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
.settingsSidebarCardLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift
Normal file
21
apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift
Normal file
15
apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift
Normal file
31
apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum SimpleTaskSupport {
|
||||||
|
static func start(task: inout Task<Void, Never>?, operation: @escaping @Sendable () async -> Void) {
|
||||||
|
guard task == nil else { return }
|
||||||
|
task = Task {
|
||||||
|
await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stop(task: inout Task<Void, Never>?) {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func startDetachedLoop(
|
||||||
|
task: inout Task<Void, Never>?,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift
Normal file
12
apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,23 +30,12 @@ final class TalkOverlayController {
|
|||||||
self.ensureWindow()
|
self.ensureWindow()
|
||||||
self.hostingView?.rootView = TalkOverlayView(controller: self)
|
self.hostingView?.rootView = TalkOverlayView(controller: self)
|
||||||
let target = self.targetFrame()
|
let target = self.targetFrame()
|
||||||
|
OverlayPanelFactory.present(
|
||||||
guard let window else { return }
|
window: self.window,
|
||||||
if !self.model.isVisible {
|
isVisible: &self.model.isVisible,
|
||||||
self.model.isVisible = true
|
target: target) { window in
|
||||||
let start = target.offsetBy(dx: 0, dy: -6)
|
window.setFrame(target, display: true)
|
||||||
window.setFrame(start, display: true)
|
window.orderFrontRegardless()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,13 +45,7 @@ final class TalkOverlayController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = window.frame.offsetBy(dx: 6, dy: 6)
|
OverlayPanelFactory.animateDismiss(window: window) {
|
||||||
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
|
Task { @MainActor in
|
||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
self.model.isVisible = false
|
self.model.isVisible = false
|
||||||
@@ -100,23 +83,11 @@ final class TalkOverlayController {
|
|||||||
|
|
||||||
private func ensureWindow() {
|
private func ensureWindow() {
|
||||||
if self.window != nil { return }
|
if self.window != nil { return }
|
||||||
let panel = NSPanel(
|
let panel = OverlayPanelFactory.makePanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize),
|
contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
level: NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4),
|
||||||
backing: .buffered,
|
hasShadow: false,
|
||||||
defer: false)
|
acceptsMouseMovedEvents: true)
|
||||||
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
|
|
||||||
|
|
||||||
let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self))
|
let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self))
|
||||||
host.translatesAutoresizingMaskIntoConstraints = false
|
host.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|||||||
@@ -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 static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
|
||||||
|
|
||||||
private var seamColor: Color {
|
private var seamColor: Color {
|
||||||
Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
|
ColorHexSupport.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
apps/macos/Sources/OpenClaw/TextSummarySupport.swift
Normal file
16
apps/macos/Sources/OpenClaw/TextSummarySupport.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift
Normal file
22
apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,92 @@ struct GatewayCostUsageTotals: Codable {
|
|||||||
|
|
||||||
struct GatewayCostUsageDay: Codable {
|
struct GatewayCostUsageDay: Codable {
|
||||||
let date: String
|
let date: String
|
||||||
let input: Int
|
private let totals: GatewayCostUsageTotals
|
||||||
let output: Int
|
|
||||||
let cacheRead: Int
|
var input: Int {
|
||||||
let cacheWrite: Int
|
self.totals.input
|
||||||
let totalTokens: Int
|
}
|
||||||
let totalCost: Double
|
|
||||||
let missingCostEntries: Int
|
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 {
|
struct GatewayCostUsageSummary: Codable {
|
||||||
|
|||||||
@@ -9,14 +9,6 @@ struct UsageMenuLabelView: View {
|
|||||||
private let paddingTrailing: CGFloat = 14
|
private let paddingTrailing: CGFloat = 14
|
||||||
private let barHeight: CGFloat = 6
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if let used = row.usedPercent {
|
if let used = row.usedPercent {
|
||||||
@@ -30,7 +22,7 @@ struct UsageMenuLabelView: View {
|
|||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(self.row.titleText)
|
Text(self.row.titleText)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(self.primaryTextColor)
|
.foregroundStyle(MenuItemHighlightColors.primary(self.isHighlighted))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
@@ -39,7 +31,7 @@ struct UsageMenuLabelView: View {
|
|||||||
|
|
||||||
Text(self.row.detailText())
|
Text(self.row.detailText())
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
.foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
.layoutPriority(2)
|
.layoutPriority(2)
|
||||||
@@ -47,7 +39,7 @@ struct UsageMenuLabelView: View {
|
|||||||
if self.showsChevron {
|
if self.showsChevron {
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
.foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift
Normal file
27
apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,7 +170,8 @@ actor VoicePushToTalk {
|
|||||||
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
|
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
|
||||||
await VoiceWakeRuntime.shared.pauseForPushToTalk()
|
await VoiceWakeRuntime.shared.pauseForPushToTalk()
|
||||||
let adoptedPrefix = self.adoptedPrefix
|
let adoptedPrefix = self.adoptedPrefix
|
||||||
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(
|
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : VoiceOverlayTextFormatting
|
||||||
|
.makeAttributed(
|
||||||
committed: adoptedPrefix,
|
committed: adoptedPrefix,
|
||||||
volatile: "",
|
volatile: "",
|
||||||
isFinal: false)
|
isFinal: false)
|
||||||
@@ -292,12 +293,15 @@ actor VoicePushToTalk {
|
|||||||
self.committed = transcript
|
self.committed = transcript
|
||||||
self.volatile = ""
|
self.volatile = ""
|
||||||
} else {
|
} 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 committedWithPrefix = Self.join(self.adoptedPrefix, self.committed)
|
||||||
let snapshot = Self.join(committedWithPrefix, self.volatile)
|
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 {
|
if let token = self.overlayToken {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
VoiceSessionCoordinator.shared.updatePartial(
|
VoiceSessionCoordinator.shared.updatePartial(
|
||||||
@@ -387,11 +391,11 @@ actor VoicePushToTalk {
|
|||||||
// MARK: - Test helpers
|
// MARK: - Test helpers
|
||||||
|
|
||||||
static func _testDelta(committed: String, current: String) -> String {
|
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) {
|
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 committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear
|
let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
return (committedColor, volatileColor)
|
return (committedColor, volatileColor)
|
||||||
@@ -403,27 +407,4 @@ actor VoicePushToTalk {
|
|||||||
return "\(prefix) \(suffix)"
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ final class VoiceWakeGlobalSettingsSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
SimpleTaskSupport.start(task: &self.task) { [weak self] in
|
||||||
self.task = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
do {
|
do {
|
||||||
@@ -39,8 +38,7 @@ final class VoiceWakeGlobalSettingsSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.task?.cancel()
|
SimpleTaskSupport.stop(task: &self.task)
|
||||||
self.task = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshFromGateway() async {
|
private func refreshFromGateway() async {
|
||||||
|
|||||||
@@ -13,50 +13,29 @@ extension VoiceWakeOverlayController {
|
|||||||
self.ensureWindow()
|
self.ensureWindow()
|
||||||
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
||||||
let target = self.targetFrame()
|
let target = self.targetFrame()
|
||||||
|
OverlayPanelFactory.present(
|
||||||
guard let window else { return }
|
window: self.window,
|
||||||
if !self.model.isVisible {
|
isVisible: &self.model.isVisible,
|
||||||
self.model.isVisible = true
|
target: target,
|
||||||
self.logger.log(
|
onFirstPresent: {
|
||||||
level: .info,
|
self.logger.log(
|
||||||
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
level: .info,
|
||||||
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
||||||
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
||||||
let start = target.offsetBy(dx: 0, dy: -6)
|
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
||||||
window.setFrame(start, display: true)
|
}) { window in
|
||||||
window.alphaValue = 0
|
self.updateWindowFrame(animate: true)
|
||||||
window.orderFrontRegardless()
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureWindow() {
|
private func ensureWindow() {
|
||||||
if self.window != nil { return }
|
if self.window != nil { return }
|
||||||
let borderPad = self.closeOverflow
|
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),
|
contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
level: Self.preferredWindowLevel,
|
||||||
backing: .buffered,
|
hasShadow: false)
|
||||||
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
|
|
||||||
|
|
||||||
let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self))
|
let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self))
|
||||||
host.translatesAutoresizingMaskIntoConstraints = false
|
host.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -84,17 +63,7 @@ extension VoiceWakeOverlayController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateWindowFrame(animate: Bool = false) {
|
func updateWindowFrame(animate: Bool = false) {
|
||||||
guard let window else { return }
|
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func measuredHeight() -> CGFloat {
|
func measuredHeight() -> CGFloat {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,10 +312,12 @@ actor VoiceWakeRuntime {
|
|||||||
self.committedTranscript = trimmed
|
self.committedTranscript = trimmed
|
||||||
self.volatileTranscript = ""
|
self.volatileTranscript = ""
|
||||||
} else {
|
} 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,
|
committed: self.committedTranscript,
|
||||||
volatile: self.volatileTranscript,
|
volatile: self.volatileTranscript,
|
||||||
isFinal: update.isFinal)
|
isFinal: update.isFinal)
|
||||||
@@ -337,10 +339,11 @@ actor VoiceWakeRuntime {
|
|||||||
var usedFallback = false
|
var usedFallback = false
|
||||||
var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig)
|
var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig)
|
||||||
if match == nil, update.isFinal {
|
if match == nil, update.isFinal {
|
||||||
match = self.textOnlyFallbackMatch(
|
match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
|
||||||
transcript: transcript,
|
transcript: transcript,
|
||||||
triggers: config.triggers,
|
triggers: config.triggers,
|
||||||
config: gateConfig)
|
config: gateConfig,
|
||||||
|
trimWake: Self.trimmedAfterTrigger)
|
||||||
usedFallback = match != nil
|
usedFallback = match != nil
|
||||||
}
|
}
|
||||||
self.maybeLogRecognition(
|
self.maybeLogRecognition(
|
||||||
@@ -387,22 +390,19 @@ actor VoiceWakeRuntime {
|
|||||||
usedFallback: Bool,
|
usedFallback: Bool,
|
||||||
capturing: Bool)
|
capturing: Bool)
|
||||||
{
|
{
|
||||||
guard !transcript.isEmpty else { return }
|
guard VoiceWakeRecognitionDebugSupport.shouldLogTranscript(
|
||||||
let level = self.logger.logLevel
|
transcript: transcript,
|
||||||
guard level == .debug || level == .trace else { return }
|
isFinal: isFinal,
|
||||||
if transcript == self.lastLoggedText, !isFinal {
|
loggerLevel: self.logger.logLevel,
|
||||||
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
lastLoggedText: &self.lastLoggedText,
|
||||||
return
|
lastLoggedAt: &self.lastLoggedAt)
|
||||||
}
|
else { return }
|
||||||
}
|
|
||||||
self.lastLoggedText = transcript
|
|
||||||
self.lastLoggedAt = Date()
|
|
||||||
|
|
||||||
let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers)
|
let summary = VoiceWakeRecognitionDebugSupport.transcriptSummary(
|
||||||
let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
|
transcript: transcript,
|
||||||
let matchSummary = match.map {
|
triggers: triggers,
|
||||||
"match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
|
segments: segments)
|
||||||
} ?? "match=false"
|
let matchSummary = VoiceWakeRecognitionDebugSupport.matchSummary(match)
|
||||||
let segmentSummary = segments.map { seg in
|
let segmentSummary = segments.map { seg in
|
||||||
let start = String(format: "%.2f", seg.start)
|
let start = String(format: "%.2f", seg.start)
|
||||||
let end = String(format: "%.2f", seg.end)
|
let end = String(format: "%.2f", seg.end)
|
||||||
@@ -410,8 +410,8 @@ actor VoiceWakeRuntime {
|
|||||||
}.joined(separator: ", ")
|
}.joined(separator: ", ")
|
||||||
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
|
"voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(summary.textOnly) " +
|
||||||
"isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
|
"isFinal=\(isFinal) timing=\(summary.timingCount)/\(segments.count) " +
|
||||||
"capturing=\(capturing) fallback=\(usedFallback) " +
|
"capturing=\(capturing) fallback=\(usedFallback) " +
|
||||||
"\(matchSummary) segments=[\(segmentSummary, privacy: .private)]")
|
"\(matchSummary) segments=[\(segmentSummary, privacy: .private)]")
|
||||||
}
|
}
|
||||||
@@ -495,20 +495,6 @@ actor VoiceWakeRuntime {
|
|||||||
await self.beginCapture(command: "", triggerEndTime: nil, config: config)
|
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 {
|
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
|
||||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
|
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
|
||||||
guard VoiceWakeTextUtils.startsWithTrigger(transcript: 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 !self.isCapturing else { return }
|
||||||
guard let lastSeenAt, let lastText else { return }
|
guard let lastSeenAt, let lastText else { return }
|
||||||
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
guard let match = self.textOnlyFallbackMatch(
|
guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
|
||||||
transcript: lastText,
|
transcript: lastText,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
config: gateConfig)
|
config: gateConfig,
|
||||||
|
trimWake: Self.trimmedAfterTrigger)
|
||||||
else { return }
|
else { return }
|
||||||
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
||||||
return
|
return
|
||||||
@@ -564,7 +551,7 @@ actor VoiceWakeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = self.committedTranscript + self.volatileTranscript
|
let snapshot = self.committedTranscript + self.volatileTranscript
|
||||||
let attributed = Self.makeAttributed(
|
let attributed = VoiceOverlayTextFormatting.makeAttributed(
|
||||||
committed: self.committedTranscript,
|
committed: self.committedTranscript,
|
||||||
volatile: self.volatileTranscript,
|
volatile: self.volatileTranscript,
|
||||||
isFinal: false)
|
isFinal: false)
|
||||||
@@ -781,33 +768,10 @@ actor VoiceWakeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func _testAttributedColor(isFinal: Bool) -> NSColor {
|
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
|
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ struct VoiceWakeSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var voiceWakeBinding: Binding<Bool> {
|
private var voiceWakeBinding: Binding<Bool> {
|
||||||
Binding(
|
MicRefreshSupport.voiceWakeBinding(for: self.state)
|
||||||
get: { self.state.swabbleEnabled },
|
|
||||||
set: { newValue in
|
|
||||||
Task { await self.state.setVoiceWakeEnabled(newValue) }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -534,30 +530,22 @@ struct VoiceWakeSettings: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func updateSelectedMicName() {
|
private func updateSelectedMicName() {
|
||||||
let selected = self.state.voiceWakeMicID
|
self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
|
||||||
if selected.isEmpty {
|
selectedID: self.state.voiceWakeMicID,
|
||||||
self.state.voiceWakeMicName = ""
|
in: self.availableMics,
|
||||||
return
|
uid: \.uid,
|
||||||
}
|
name: \.name)
|
||||||
if let match = self.availableMics.first(where: { $0.uid == selected }) {
|
|
||||||
self.state.voiceWakeMicName = match.name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startMicObserver() {
|
private func startMicObserver() {
|
||||||
self.micObserver.start {
|
MicRefreshSupport.startObserver(self.micObserver) {
|
||||||
Task { @MainActor in
|
self.scheduleMicRefresh()
|
||||||
self.scheduleMicRefresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func scheduleMicRefresh() {
|
private func scheduleMicRefresh() {
|
||||||
self.micRefreshTask?.cancel()
|
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||||
self.micRefreshTask = Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self.loadMicsIfNeeded(force: true)
|
await self.loadMicsIfNeeded(force: true)
|
||||||
await self.restartMeter()
|
await self.restartMeter()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,10 +140,11 @@ final class VoiceWakeTester {
|
|||||||
let gateConfig = WakeWordGateConfig(triggers: triggers)
|
let gateConfig = WakeWordGateConfig(triggers: triggers)
|
||||||
var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
||||||
if match == nil, isFinal {
|
if match == nil, isFinal {
|
||||||
match = self.textOnlyFallbackMatch(
|
match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
|
||||||
transcript: text,
|
transcript: text,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
config: gateConfig)
|
config: gateConfig,
|
||||||
|
trimWake: WakeWordGate.stripWake)
|
||||||
}
|
}
|
||||||
self.maybeLogDebug(
|
self.maybeLogDebug(
|
||||||
transcript: text,
|
transcript: text,
|
||||||
@@ -273,28 +274,25 @@ final class VoiceWakeTester {
|
|||||||
match: WakeWordGateMatch?,
|
match: WakeWordGateMatch?,
|
||||||
isFinal: Bool)
|
isFinal: Bool)
|
||||||
{
|
{
|
||||||
guard !transcript.isEmpty else { return }
|
guard VoiceWakeRecognitionDebugSupport.shouldLogTranscript(
|
||||||
let level = self.logger.logLevel
|
transcript: transcript,
|
||||||
guard level == .debug || level == .trace else { return }
|
isFinal: isFinal,
|
||||||
if transcript == self.lastLoggedText, !isFinal {
|
loggerLevel: self.logger.logLevel,
|
||||||
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
lastLoggedText: &self.lastLoggedText,
|
||||||
return
|
lastLoggedAt: &self.lastLoggedAt)
|
||||||
}
|
else { return }
|
||||||
}
|
|
||||||
self.lastLoggedText = transcript
|
|
||||||
self.lastLoggedAt = Date()
|
|
||||||
|
|
||||||
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 gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments)
|
||||||
let segmentSummary = Self.debugSegments(segments)
|
let segmentSummary = Self.debugSegments(segments)
|
||||||
let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
|
let matchSummary = VoiceWakeRecognitionDebugSupport.matchSummary(match)
|
||||||
let matchSummary = match.map {
|
|
||||||
"match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
|
|
||||||
} ?? "match=false"
|
|
||||||
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
|
"voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(summary.textOnly) " +
|
||||||
"isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
|
"isFinal=\(isFinal) timing=\(summary.timingCount)/\(segments.count) " +
|
||||||
"\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]")
|
"\(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) {
|
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -415,10 +399,12 @@ final class VoiceWakeTester {
|
|||||||
guard !self.isStopping, !self.holdingAfterDetect else { return }
|
guard !self.isStopping, !self.holdingAfterDetect else { return }
|
||||||
guard let lastSeenAt, let lastText else { return }
|
guard let lastSeenAt, let lastText else { return }
|
||||||
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
guard let match = self.textOnlyFallbackMatch(
|
guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
|
||||||
transcript: lastText,
|
transcript: lastText,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
config: WakeWordGateConfig(triggers: triggers)) else { return }
|
config: WakeWordGateConfig(triggers: triggers),
|
||||||
|
trimWake: WakeWordGate.stripWake)
|
||||||
|
else { return }
|
||||||
self.holdingAfterDetect = true
|
self.holdingAfterDetect = true
|
||||||
self.detectedText = match.command
|
self.detectedText = match.command
|
||||||
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
||||||
|
|||||||
@@ -111,13 +111,7 @@ final class WebChatManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
self.windowController?.close()
|
self.resetTunnels()
|
||||||
self.windowController = nil
|
|
||||||
self.windowSessionKey = nil
|
|
||||||
self.panelController?.close()
|
|
||||||
self.panelController = nil
|
|
||||||
self.panelSessionKey = nil
|
|
||||||
self.cachedPreferredSessionKey = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func panelHidden() {
|
private func panelHidden() {
|
||||||
|
|||||||
@@ -251,10 +251,7 @@ final class WebChatSwiftUIWindowController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func removeDismissMonitor() {
|
private func removeDismissMonitor() {
|
||||||
if let monitor = self.dismissMonitor {
|
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
|
||||||
NSEvent.removeMonitor(monitor)
|
|
||||||
self.dismissMonitor = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeWindow(
|
private static func makeWindow(
|
||||||
@@ -371,13 +368,6 @@ final class WebChatSwiftUIWindowController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func color(fromHex raw: String?) -> Color? {
|
private static func color(fromHex raw: String?) -> Color? {
|
||||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
ColorHexSupport.color(fromHex: raw)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,17 +113,15 @@ final class WorkActivityStore {
|
|||||||
|
|
||||||
private func setJobActive(_ activity: Activity) {
|
private func setJobActive(_ activity: Activity) {
|
||||||
self.jobs[activity.sessionKey] = activity
|
self.jobs[activity.sessionKey] = activity
|
||||||
// Main session preempts immediately.
|
self.updateCurrentSession(with: activity)
|
||||||
if activity.role == .main {
|
|
||||||
self.currentSessionKey = activity.sessionKey
|
|
||||||
} else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) {
|
|
||||||
self.currentSessionKey = activity.sessionKey
|
|
||||||
}
|
|
||||||
self.refreshDerivedState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setToolActive(_ activity: Activity) {
|
private func setToolActive(_ activity: Activity) {
|
||||||
self.tools[activity.sessionKey] = activity
|
self.tools[activity.sessionKey] = activity
|
||||||
|
self.updateCurrentSession(with: activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCurrentSession(with activity: Activity) {
|
||||||
// Main session preempts immediately.
|
// Main session preempts immediately.
|
||||||
if activity.role == .main {
|
if activity.role == .main {
|
||||||
self.currentSessionKey = activity.sessionKey
|
self.currentSessionKey = activity.sessionKey
|
||||||
|
|||||||
@@ -92,31 +92,22 @@ public final class GatewayDiscoveryModel {
|
|||||||
if !self.browsers.isEmpty { return }
|
if !self.browsers.isEmpty { return }
|
||||||
|
|
||||||
for domain in OpenClawBonjour.gatewayServiceDomains {
|
for domain in OpenClawBonjour.gatewayServiceDomains {
|
||||||
let params = NWParameters.tcp
|
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||||
params.includePeerToPeer = true
|
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||||
let browser = NWBrowser(
|
domain: domain,
|
||||||
for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain),
|
queueLabelPrefix: "ai.openclaw.macos.gateway-discovery",
|
||||||
using: params)
|
onState: { [weak self] state in
|
||||||
|
|
||||||
browser.stateUpdateHandler = { [weak self] state in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.statesByDomain[domain] = state
|
self.statesByDomain[domain] = state
|
||||||
self.updateStatusText()
|
self.updateStatusText()
|
||||||
}
|
},
|
||||||
}
|
onResults: { [weak self] results in
|
||||||
|
|
||||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.resultsByDomain[domain] = results
|
self.resultsByDomain[domain] = results
|
||||||
self.updateGateways(for: domain)
|
self.updateGateways(for: domain)
|
||||||
self.recomputeGateways()
|
self.recomputeGateways()
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
self.browsers[domain] = browser
|
self.browsers[domain] = browser
|
||||||
browser.start(queue: DispatchQueue(label: "ai.openclaw.macos.gateway-discovery.\(domain)"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.scheduleWideAreaFallback()
|
self.scheduleWideAreaFallback()
|
||||||
@@ -617,8 +608,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start(timeout: TimeInterval = 2.0) {
|
func start(timeout: TimeInterval = 2.0) {
|
||||||
self.service.schedule(in: .main, forMode: .common)
|
BonjourServiceResolverSupport.start(self.service, timeout: timeout)
|
||||||
self.service.resolve(withTimeout: timeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
@@ -664,9 +654,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeHost(_ raw: String?) -> String? {
|
private static func normalizeHost(_ raw: String?) -> String? {
|
||||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
BonjourServiceResolverSupport.normalizeHost(raw)
|
||||||
if trimmed.isEmpty { return nil }
|
|
||||||
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTXT(_ txt: [String: String]) -> String {
|
private func formatTXT(_ txt: [String: String]) -> String {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Darwin
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
public enum TailscaleNetwork {
|
public enum TailscaleNetwork {
|
||||||
public static func isTailnetIPv4(_ address: String) -> Bool {
|
public static func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
@@ -13,34 +13,9 @@ public enum TailscaleNetwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func detectTailnetIPv4() -> String? {
|
public static func detectTailnetIPv4() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
for entry in NetworkInterfaceIPv4.addresses() {
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
if self.isTailnetIPv4(entry.ip) { return entry.ip }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ struct ConnectOptions {
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
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)
|
handler(&opts, value)
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
@@ -62,12 +62,6 @@ struct ConnectOptions {
|
|||||||
}
|
}
|
||||||
return opts
|
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 {
|
struct ConnectOutput: Encodable {
|
||||||
@@ -233,14 +227,7 @@ private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
|
|||||||
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||||
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||||
if let raw = opts.url, !raw.isEmpty {
|
if let raw = opts.url, !raw.isEmpty {
|
||||||
guard let url = URL(string: raw) else {
|
return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if resolvedMode == "remote" {
|
if resolvedMode == "remote" {
|
||||||
@@ -252,14 +239,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||||
}
|
}
|
||||||
guard let url = URL(string: raw) else {
|
return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
@@ -281,6 +261,22 @@ private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) ->
|
|||||||
try? resolveGatewayEndpoint(opts: opts, config: config)
|
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? {
|
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
if let token = opts.token, !token.isEmpty { return token }
|
if let token = opts.token, !token.isEmpty { return token }
|
||||||
if mode == "remote" {
|
if mode == "remote" {
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ struct WizardCliOptions {
|
|||||||
case "--json":
|
case "--json":
|
||||||
opts.json = true
|
opts.json = true
|
||||||
case "--url":
|
case "--url":
|
||||||
opts.url = self.nextValue(args, index: &i)
|
opts.url = CLIArgParsingSupport.nextValue(args, index: &i)
|
||||||
case "--token":
|
case "--token":
|
||||||
opts.token = self.nextValue(args, index: &i)
|
opts.token = CLIArgParsingSupport.nextValue(args, index: &i)
|
||||||
case "--password":
|
case "--password":
|
||||||
opts.password = self.nextValue(args, index: &i)
|
opts.password = CLIArgParsingSupport.nextValue(args, index: &i)
|
||||||
case "--mode":
|
case "--mode":
|
||||||
if let value = nextValue(args, index: &i) {
|
if let value = CLIArgParsingSupport.nextValue(args, index: &i) {
|
||||||
opts.mode = value
|
opts.mode = value
|
||||||
}
|
}
|
||||||
case "--workspace":
|
case "--workspace":
|
||||||
opts.workspace = self.nextValue(args, index: &i)
|
opts.workspace = CLIArgParsingSupport.nextValue(args, index: &i)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -41,12 +41,6 @@ struct WizardCliOptions {
|
|||||||
}
|
}
|
||||||
return opts
|
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 {
|
enum WizardCliError: Error, CustomStringConvertible {
|
||||||
@@ -338,8 +332,7 @@ actor GatewayWizardClient {
|
|||||||
let frame = try await self.decodeFrame(message)
|
let frame = try await self.decodeFrame(message)
|
||||||
if case let .event(evt) = frame, evt.event == "connect.challenge",
|
if case let .event(evt) = frame, evt.event == "connect.challenge",
|
||||||
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||||
let nonce = payload["nonce"]?.value as? String,
|
let nonce = GatewayConnectChallengeSupport.nonce(from: payload)
|
||||||
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
||||||
{
|
{
|
||||||
return nonce
|
return nonce
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user