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