feat: unify device auth + pairing
This commit is contained in:
84
apps/macos/Sources/Clawdbot/DeviceIdentity.swift
Normal file
84
apps/macos/Sources/Clawdbot/DeviceIdentity.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct DeviceIdentity: Codable, Sendable {
|
||||
var deviceId: String
|
||||
var publicKey: String
|
||||
var privateKey: String
|
||||
var createdAtMs: Int
|
||||
}
|
||||
|
||||
enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
|
||||
static func loadOrCreate() -> DeviceIdentity {
|
||||
let url = self.fileURL()
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||
!decoded.deviceId.isEmpty,
|
||||
!decoded.publicKey.isEmpty,
|
||||
!decoded.privateKey.isEmpty {
|
||||
return decoded
|
||||
}
|
||||
let identity = self.generate()
|
||||
self.save(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||
do {
|
||||
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
||||
let signature = try privateKey.signature(for: Data(payload.utf8))
|
||||
return self.base64UrlEncode(signature)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func generate() -> DeviceIdentity {
|
||||
let privateKey = Curve25519.Signing.PrivateKey()
|
||||
let publicKey = privateKey.publicKey
|
||||
let publicKeyData = publicKey.rawRepresentation
|
||||
let privateKeyData = privateKey.rawRepresentation
|
||||
let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||
return DeviceIdentity(
|
||||
deviceId: deviceId,
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
}
|
||||
|
||||
private static func base64UrlEncode(_ data: Data) -> String {
|
||||
let base64 = data.base64EncodedString()
|
||||
return base64
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
|
||||
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
|
||||
return self.base64UrlEncode(data)
|
||||
}
|
||||
|
||||
private static func save(_ identity: DeviceIdentity) {
|
||||
let url = self.fileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(identity)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
let base = ClawdbotPaths.stateDirURL
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
}
|
||||
318
apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift
Normal file
318
apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
import AppKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DevicePairingApprovalPrompter {
|
||||
static let shared = DevicePairingApprovalPrompter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "device-pairing")
|
||||
private var task: Task<Void, Never>?
|
||||
private var isStopping = false
|
||||
private var isPresenting = false
|
||||
private var queue: [PendingRequest] = []
|
||||
var pendingCount: Int = 0
|
||||
var pendingRepairCount: Int = 0
|
||||
private var activeAlert: NSAlert?
|
||||
private var activeRequestId: String?
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var resolvedByRequestId: Set<String> = []
|
||||
|
||||
private final class AlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
}
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
let paired: [PairedDevice]?
|
||||
}
|
||||
|
||||
private struct PairedDevice: Codable, Equatable {
|
||||
let deviceId: String
|
||||
let approvedAtMs: Double?
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let remoteIp: String?
|
||||
}
|
||||
|
||||
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
let requestId: String
|
||||
let deviceId: String
|
||||
let publicKey: String
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let clientId: String?
|
||||
let clientMode: String?
|
||||
let role: String?
|
||||
let scopes: [String]?
|
||||
let remoteIp: String?
|
||||
let silent: Bool?
|
||||
let isRepair: Bool?
|
||||
let ts: Double
|
||||
|
||||
var id: String { self.requestId }
|
||||
}
|
||||
|
||||
private struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let deviceId: String
|
||||
let decision: String
|
||||
let ts: Double
|
||||
}
|
||||
|
||||
private enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.isStopping = true
|
||||
self.endActiveAlert()
|
||||
self.task?.cancel()
|
||||
self.task = 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.resolvedByRequestId.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
do {
|
||||
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||
await self.apply(list: list)
|
||||
} catch {
|
||||
self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(list: PairingList) async {
|
||||
self.queue = list.pending.sorted(by: { $0.ts > $1.ts })
|
||||
self.updatePendingCounts()
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
private func updatePendingCounts() {
|
||||
self.pendingCount = self.queue.count
|
||||
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count
|
||||
}
|
||||
|
||||
private func presentNextIfNeeded() {
|
||||
guard !self.isStopping else { return }
|
||||
guard !self.isPresenting else { return }
|
||||
guard let next = self.queue.first else { return }
|
||||
self.isPresenting = true
|
||||
self.presentAlert(for: next)
|
||||
}
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
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 handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
defer {
|
||||
if self.queue.first == request {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
self.queue.removeAll { $0 == request }
|
||||
}
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
guard !self.isStopping else { return }
|
||||
|
||||
if self.resolvedByRequestId.remove(request.requestId) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
do {
|
||||
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 {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
guard let alert = self.activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
self.activeAlert = nil
|
||||
self.activeRequestId = nil
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = AlertHostWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = ""
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.isOpaque = false
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
self.alertHostWindow = window
|
||||
return window
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "device.pair.requested":
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||
self.enqueue(req)
|
||||
} catch {
|
||||
self.logger.error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case let .event(evt) where evt.event == "device.pair.resolved":
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
|
||||
self.handleResolved(resolved)
|
||||
} catch {
|
||||
self.logger.error("failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ req: PendingRequest) {
|
||||
guard !self.queue.contains(req) else { return }
|
||||
self.queue.append(req)
|
||||
self.updatePendingCounts()
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution.approved : .rejected
|
||||
if let activeRequestId, activeRequestId == resolved.requestId {
|
||||
self.resolvedByRequestId.insert(resolved.requestId)
|
||||
self.endActiveAlert()
|
||||
self.logger.info("device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) decision=\(resolution.rawValue, privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||
self.updatePendingCounts()
|
||||
}
|
||||
|
||||
private static func describe(_ req: PendingRequest) -> String {
|
||||
var lines: [String] = []
|
||||
lines.append("Device: \(req.displayName ?? req.deviceId)")
|
||||
if let platform = req.platform {
|
||||
lines.append("Platform: \(platform)")
|
||||
}
|
||||
if let role = req.role {
|
||||
lines.append("Role: \(role)")
|
||||
}
|
||||
if let scopes = req.scopes, !scopes.isEmpty {
|
||||
lines.append("Scopes: \(scopes.joined(separator: \", \"))")
|
||||
}
|
||||
if let remoteIp = req.remoteIp {
|
||||
lines.append("IP: \(remoteIp)")
|
||||
}
|
||||
if req.isRepair == true {
|
||||
lines.append("Repair: yes")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class ExecApprovalsGatewayPrompter {
|
||||
static let shared = ExecApprovalsGatewayPrompter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.gateway")
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
struct GatewayApprovalRequest: Codable, Sendable {
|
||||
var id: String
|
||||
var request: ExecApprovalPromptRequest
|
||||
var createdAtMs: Int
|
||||
var expiresAtMs: Int
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.task = Task { [weak self] in
|
||||
await self?.run()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
private func run() async {
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await self.handle(push: push)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) async {
|
||||
guard case let .event(evt) = push else { return }
|
||||
guard evt.event == "exec.approval.requested" else { return }
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
params: [
|
||||
"id": AnyCodable(request.id),
|
||||
"decision": AnyCodable(decision.rawValue),
|
||||
],
|
||||
timeoutMs: 10_000)
|
||||
} catch {
|
||||
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@ actor GatewayChannelActor {
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientDisplayName = InstanceIdentity.displayName
|
||||
let clientId = "clawdbot-macos"
|
||||
let clientMode = "ui"
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
@@ -212,7 +213,7 @@ actor GatewayChannelActor {
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable("ui"),
|
||||
"mode": ProtoAnyCodable(clientMode),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||
@@ -226,12 +227,36 @@ actor GatewayChannelActor {
|
||||
"caps": ProtoAnyCodable([] as [String]),
|
||||
"locale": ProtoAnyCodable(primaryLocale),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
"role": ProtoAnyCodable("operator"),
|
||||
"scopes": ProtoAnyCodable(["operator.admin", "operator.approvals", "operator.pairing"]),
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopes = "operator.admin,operator.approvals,operator.pairing"
|
||||
let payload = [
|
||||
"v1",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
"operator",
|
||||
scopes,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
].joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
params["device"] = ProtoAnyCodable([
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
])
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
|
||||
@@ -76,6 +76,10 @@ actor GatewayConnection {
|
||||
case voicewakeSet = "voicewake.set"
|
||||
case nodePairApprove = "node.pair.approve"
|
||||
case nodePairReject = "node.pair.reject"
|
||||
case devicePairList = "device.pair.list"
|
||||
case devicePairApprove = "device.pair.approve"
|
||||
case devicePairReject = "device.pair.reject"
|
||||
case execApprovalResolve = "exec.approval.resolve"
|
||||
case cronList = "cron.list"
|
||||
case cronRuns = "cron.runs"
|
||||
case cronRun = "cron.run"
|
||||
@@ -610,6 +614,22 @@ extension GatewayConnection {
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
// MARK: - Device pairing
|
||||
|
||||
func devicePairApprove(requestId: String) async throws {
|
||||
try await self.requestVoid(
|
||||
method: .devicePairApprove,
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
func devicePairReject(requestId: String) async throws {
|
||||
try await self.requestVoid(
|
||||
method: .devicePairReject,
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
// MARK: - Cron
|
||||
|
||||
struct CronSchedulerStatus: Decodable, Sendable {
|
||||
|
||||
@@ -256,7 +256,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
TerminationSignalWatcher.shared.start()
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
DevicePairingApprovalPrompter.shared.start()
|
||||
ExecApprovalsPromptServer.shared.start()
|
||||
ExecApprovalsGatewayPrompter.shared.start()
|
||||
MacNodeModeCoordinator.shared.start()
|
||||
VoiceWakeGlobalSettingsSync.shared.start()
|
||||
Task { PresenceReporter.shared.start() }
|
||||
@@ -281,7 +283,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
PresenceReporter.shared.stop()
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
DevicePairingApprovalPrompter.shared.stop()
|
||||
ExecApprovalsPromptServer.shared.stop()
|
||||
ExecApprovalsGatewayPrompter.shared.stop()
|
||||
MacNodeModeCoordinator.shared.stop()
|
||||
TerminationSignalWatcher.shared.stop()
|
||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||
|
||||
@@ -15,6 +15,7 @@ struct MenuContent: View {
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
||||
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@@ -50,6 +51,13 @@ struct MenuContent: View {
|
||||
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)",
|
||||
color: .orange)
|
||||
}
|
||||
if self.devicePairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.devicePairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
self.statusLine(
|
||||
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)",
|
||||
color: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(self.state.connectionMode == .unconfigured)
|
||||
|
||||
@@ -42,6 +42,7 @@ final class TerminationSignalWatcher {
|
||||
self.logger.info("received signal \(sig, privacy: .public); terminating")
|
||||
// Ensure any pairing prompt can't accidentally approve during shutdown.
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
DevicePairingApprovalPrompter.shared.stop()
|
||||
NSApp.terminate(nil)
|
||||
|
||||
// Safety net: don't hang forever if something blocks termination.
|
||||
|
||||
Reference in New Issue
Block a user