agentix-cli 0.56.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +43 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -2
- package/apps/mac-voice/Sources/AgentXVoice/HotkeySpec.swift +80 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +518 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +8 -2
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +4 -1
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +101 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +100 -38
- package/dist/{App-4EM7IPB3.js → App-Z5VOAUXB.js} +2 -2
- package/dist/{ChatApp-FYTEEGSU.js → ChatApp-BE7ONQKS.js} +2 -2
- package/dist/{agent-RZ46ZB4W.js → agent-Q74EISKP.js} +2 -2
- package/dist/{board-dashboard-NRRYCDZW.js → board-dashboard-A7PQOI6C.js} +4 -4
- package/dist/{chunk-W4YMMWJL.js → chunk-2MIOMYT2.js} +2 -2
- package/dist/{chunk-EZN2YXRY.js → chunk-547G3HYQ.js} +2 -2
- package/dist/{chunk-LKJSPO3S.js → chunk-5LLGIX5M.js} +2 -2
- package/dist/{chunk-WOMQ2VGA.js → chunk-5W7NJHCS.js} +2 -2
- package/dist/{chunk-4HEVSWO3.js → chunk-6OABTCRA.js} +2 -2
- package/dist/{chunk-UIVHSYCN.js → chunk-6QNPCVRN.js} +3 -3
- package/dist/chunk-6QNPCVRN.js.map +1 -0
- package/dist/{chunk-CELYD5QW.js → chunk-CQUVUVXR.js} +2 -2
- package/dist/chunk-ERL6AS3O.js +15 -0
- package/dist/chunk-ERL6AS3O.js.map +1 -0
- package/dist/{chunk-EJ5ISSWW.js → chunk-I2LYULR4.js} +5 -5
- package/dist/{chunk-76AHNHWC.js → chunk-KKGFZEXS.js} +2 -2
- package/dist/{chunk-6AK6WKE5.js → chunk-KW7TIOS3.js} +2 -2
- package/dist/{chunk-SA23V7WX.js → chunk-NKMFHEGV.js} +2 -2
- package/dist/{chunk-WKMTT2UY.js → chunk-OZZPJ2YG.js} +197 -188
- package/dist/chunk-OZZPJ2YG.js.map +1 -0
- package/dist/{chunk-IL2XLW4H.js → chunk-POL7RAJQ.js} +13 -13
- package/dist/{chunk-D3LSPFHK.js → chunk-Q6SMLSRO.js} +2 -2
- package/dist/{chunk-723K4SAW.js → chunk-RXEAACVH.js} +2 -2
- package/dist/{chunk-O3XG2NVR.js → chunk-U5YBQJ5Q.js} +2 -2
- package/dist/{chunk-XEOAKD5C.js → chunk-V7ZK6ZIO.js} +2 -2
- package/dist/{chunk-M4AG2YNX.js → chunk-VI7GBDKV.js} +2 -2
- package/dist/{chunk-GSOL2LW6.js → chunk-VVBGPT6W.js} +2 -2
- package/dist/{chunk-C3VRBEJ7.js → chunk-XTD3SZIP.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-EFP2PKA3.js +2 -0
- package/dist/config-P6YXC6XZ.js +2 -0
- package/dist/config-mutator-3GKXLTHH.js +2 -0
- package/dist/doctor-JEWMHXYH.js +2 -0
- package/dist/index.d.ts +109 -3
- package/dist/index.js +1 -1
- package/dist/program-7DUQJDSQ.js +2 -0
- package/dist/{promote-T4UJROEH.js → promote-KVPLDSKT.js} +2 -2
- package/dist/query-AGOMCYTT.js +2 -0
- package/dist/{registry-DX2RI7I7.js → registry-AUOI4TW2.js} +2 -2
- package/dist/{request-planner-B7DE7VJR.js → request-planner-LMZ5ZAXC.js} +2 -2
- package/dist/{review-candidates-4YJHIBAF.js → review-candidates-WCBXLLDS.js} +2 -2
- package/dist/{routing-MZG3UODH.js → routing-FBASYKUL.js} +2 -2
- package/dist/schedule-tool-RJRJ63SS.js +2 -0
- package/dist/scheduler-ATA7Z44S.js +2 -0
- package/dist/{seat-2PEI7JRX.js → seat-DBFW3Z5P.js} +2 -2
- package/dist/{select-4KIUR27O.js → select-6CNVVFA5.js} +2 -2
- package/dist/{set-enabled-SE7HQKPF.js → set-enabled-AYZH3KSB.js} +2 -2
- package/dist/{tool-5VMD7IE4.js → tool-PKS4NBZ6.js} +2 -2
- package/dist/wiki-QW6BDNEQ.js +2 -0
- package/package.json +1 -1
- package/dist/chunk-U4H35SJS.js +0 -15
- package/dist/chunk-U4H35SJS.js.map +0 -1
- package/dist/chunk-UIVHSYCN.js.map +0 -1
- package/dist/chunk-WKMTT2UY.js.map +0 -1
- package/dist/client-IA7ZPE56.js +0 -2
- package/dist/config-HPN2F7LH.js +0 -2
- package/dist/config-mutator-7GPTMTVB.js +0 -2
- package/dist/doctor-IBWQVQV4.js +0 -2
- package/dist/program-G6DIFJUF.js +0 -2
- package/dist/query-VUJC4Y5O.js +0 -2
- package/dist/schedule-tool-A4OFQ4YZ.js +0 -2
- package/dist/scheduler-RHJW2V4M.js +0 -2
- package/dist/wiki-2QFLAH4U.js +0 -2
- /package/dist/{App-4EM7IPB3.js.map → App-Z5VOAUXB.js.map} +0 -0
- /package/dist/{ChatApp-FYTEEGSU.js.map → ChatApp-BE7ONQKS.js.map} +0 -0
- /package/dist/{agent-RZ46ZB4W.js.map → agent-Q74EISKP.js.map} +0 -0
- /package/dist/{board-dashboard-NRRYCDZW.js.map → board-dashboard-A7PQOI6C.js.map} +0 -0
- /package/dist/{chunk-W4YMMWJL.js.map → chunk-2MIOMYT2.js.map} +0 -0
- /package/dist/{chunk-EZN2YXRY.js.map → chunk-547G3HYQ.js.map} +0 -0
- /package/dist/{chunk-LKJSPO3S.js.map → chunk-5LLGIX5M.js.map} +0 -0
- /package/dist/{chunk-WOMQ2VGA.js.map → chunk-5W7NJHCS.js.map} +0 -0
- /package/dist/{chunk-4HEVSWO3.js.map → chunk-6OABTCRA.js.map} +0 -0
- /package/dist/{chunk-CELYD5QW.js.map → chunk-CQUVUVXR.js.map} +0 -0
- /package/dist/{chunk-EJ5ISSWW.js.map → chunk-I2LYULR4.js.map} +0 -0
- /package/dist/{chunk-76AHNHWC.js.map → chunk-KKGFZEXS.js.map} +0 -0
- /package/dist/{chunk-6AK6WKE5.js.map → chunk-KW7TIOS3.js.map} +0 -0
- /package/dist/{chunk-SA23V7WX.js.map → chunk-NKMFHEGV.js.map} +0 -0
- /package/dist/{chunk-IL2XLW4H.js.map → chunk-POL7RAJQ.js.map} +0 -0
- /package/dist/{chunk-D3LSPFHK.js.map → chunk-Q6SMLSRO.js.map} +0 -0
- /package/dist/{chunk-723K4SAW.js.map → chunk-RXEAACVH.js.map} +0 -0
- /package/dist/{chunk-O3XG2NVR.js.map → chunk-U5YBQJ5Q.js.map} +0 -0
- /package/dist/{chunk-XEOAKD5C.js.map → chunk-V7ZK6ZIO.js.map} +0 -0
- /package/dist/{chunk-M4AG2YNX.js.map → chunk-VI7GBDKV.js.map} +0 -0
- /package/dist/{chunk-GSOL2LW6.js.map → chunk-VVBGPT6W.js.map} +0 -0
- /package/dist/{chunk-C3VRBEJ7.js.map → chunk-XTD3SZIP.js.map} +0 -0
- /package/dist/{client-IA7ZPE56.js.map → client-EFP2PKA3.js.map} +0 -0
- /package/dist/{config-HPN2F7LH.js.map → config-P6YXC6XZ.js.map} +0 -0
- /package/dist/{config-mutator-7GPTMTVB.js.map → config-mutator-3GKXLTHH.js.map} +0 -0
- /package/dist/{doctor-IBWQVQV4.js.map → doctor-JEWMHXYH.js.map} +0 -0
- /package/dist/{program-G6DIFJUF.js.map → program-7DUQJDSQ.js.map} +0 -0
- /package/dist/{promote-T4UJROEH.js.map → promote-KVPLDSKT.js.map} +0 -0
- /package/dist/{query-VUJC4Y5O.js.map → query-AGOMCYTT.js.map} +0 -0
- /package/dist/{registry-DX2RI7I7.js.map → registry-AUOI4TW2.js.map} +0 -0
- /package/dist/{request-planner-B7DE7VJR.js.map → request-planner-LMZ5ZAXC.js.map} +0 -0
- /package/dist/{review-candidates-4YJHIBAF.js.map → review-candidates-WCBXLLDS.js.map} +0 -0
- /package/dist/{routing-MZG3UODH.js.map → routing-FBASYKUL.js.map} +0 -0
- /package/dist/{schedule-tool-A4OFQ4YZ.js.map → schedule-tool-RJRJ63SS.js.map} +0 -0
- /package/dist/{scheduler-RHJW2V4M.js.map → scheduler-ATA7Z44S.js.map} +0 -0
- /package/dist/{seat-2PEI7JRX.js.map → seat-DBFW3Z5P.js.map} +0 -0
- /package/dist/{select-4KIUR27O.js.map → select-6CNVVFA5.js.map} +0 -0
- /package/dist/{set-enabled-SE7HQKPF.js.map → set-enabled-AYZH3KSB.js.map} +0 -0
- /package/dist/{tool-5VMD7IE4.js.map → tool-PKS4NBZ6.js.map} +0 -0
- /package/dist/{wiki-2QFLAH4U.js.map → wiki-QW6BDNEQ.js.map} +0 -0
|
@@ -161,6 +161,49 @@ enum AgentClient {
|
|
|
161
161
|
return try await URLSession.shared.data(for: req)
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// MARK: Settings
|
|
165
|
+
//
|
|
166
|
+
// The settings window reads and saves through the daemon, which checks
|
|
167
|
+
// every value and writes agentx.json. The app keeps no copy of its own.
|
|
168
|
+
// See src/daemon/voice-settings-api.ts.
|
|
169
|
+
|
|
170
|
+
/// GET /voice/settings, or nil when the daemon cannot be reached.
|
|
171
|
+
static func settings() async -> VoiceSettings? {
|
|
172
|
+
guard let url = URL(string: "\(Config.daemonURL)/voice/settings") else { return nil }
|
|
173
|
+
var req = URLRequest(url: url)
|
|
174
|
+
// The first call lists the Mac's voices, which can take a moment.
|
|
175
|
+
req.timeoutInterval = 15
|
|
176
|
+
guard let (data, response) = try? await URLSession.shared.data(for: req),
|
|
177
|
+
(response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
|
178
|
+
return try? JSONDecoder().decode(VoiceSettings.self, from: data)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Save a change. The saved settings, or the daemon's reason in words.
|
|
182
|
+
static func saveSettings(_ patch: [String: Any]) async -> Result<VoiceSettings, SettingsFailure> {
|
|
183
|
+
struct Reply: Decodable { let settings: VoiceSettings?; let error: String? }
|
|
184
|
+
do {
|
|
185
|
+
let (data, response) = try await post("/voice/settings", patch, timeout: 20)
|
|
186
|
+
let reply = try? JSONDecoder().decode(Reply.self, from: data)
|
|
187
|
+
if let s = reply?.settings, (response as? HTTPURLResponse)?.statusCode == 200 { return .success(s) }
|
|
188
|
+
return .failure(SettingsFailure(message: reply?.error ?? "The daemon did not save the settings."))
|
|
189
|
+
} catch {
|
|
190
|
+
return .failure(SettingsFailure(message: "The AgentX daemon isn't reachable: \(error.localizedDescription)"))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Say a sample line in an agent's voice with unsaved changes. Nil on
|
|
195
|
+
/// success, else why not.
|
|
196
|
+
static func preview(agentID: String, voice: [String: Any]) async -> String? {
|
|
197
|
+
struct Reply: Decodable { let error: String? }
|
|
198
|
+
guard let (data, response) = try? await post("/voice/preview", ["agentId": agentID, "voice": voice], timeout: 10) else {
|
|
199
|
+
return "The AgentX daemon isn't reachable."
|
|
200
|
+
}
|
|
201
|
+
if (response as? HTTPURLResponse)?.statusCode == 202 { return nil }
|
|
202
|
+
return (try? JSONDecoder().decode(Reply.self, from: data))?.error ?? "The preview didn't play."
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
struct SettingsFailure: Error { let message: String }
|
|
206
|
+
|
|
164
207
|
/// One row of GET /agents: who can answer, and whether they are busy.
|
|
165
208
|
struct AgentInfo: Decodable {
|
|
166
209
|
let id: String
|
|
@@ -28,7 +28,13 @@ final class Hotkey {
|
|
|
28
28
|
self.onRelease = onRelease
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/// Register one shortcut; false when macOS refused it (another app
|
|
32
|
+
/// already holds it).
|
|
33
|
+
@discardableResult
|
|
34
|
+
func register(_ spec: HotkeySpec) -> Bool { register(keyCode: spec.keyCode, modifiers: spec.modifiers) }
|
|
35
|
+
|
|
36
|
+
@discardableResult
|
|
37
|
+
func register(keyCode: UInt32 = UInt32(kVK_Space), modifiers: UInt32 = UInt32(optionKey)) -> Bool {
|
|
32
38
|
var spec = [
|
|
33
39
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
|
34
40
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
|
|
@@ -66,7 +72,7 @@ final class Hotkey {
|
|
|
66
72
|
}, spec.count, &spec, context, &handler)
|
|
67
73
|
|
|
68
74
|
let hotKeyID = EventHotKeyID(signature: OSType(0x41475856), id: id) // 'AGXV'
|
|
69
|
-
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
|
|
75
|
+
return RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref) == noErr
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
deinit {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Carbon.HIToolbox
|
|
2
|
+
|
|
3
|
+
/// A shortcut as agentx.json stores it: "opt+space", "cmd+opt+period",
|
|
4
|
+
/// "ctrl+opt+1". The daemon checks and normalises the same form
|
|
5
|
+
/// (src/voice/hotkey.ts); this turns it into what Carbon registers, and a
|
|
6
|
+
/// pressed key back into it.
|
|
7
|
+
struct HotkeySpec: Equatable {
|
|
8
|
+
let keyCode: UInt32
|
|
9
|
+
/// Carbon modifier flags (cmdKey, optionKey, …).
|
|
10
|
+
let modifiers: UInt32
|
|
11
|
+
|
|
12
|
+
static let keys: [String: Int] = {
|
|
13
|
+
var k: [String: Int] = [
|
|
14
|
+
"a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E,
|
|
15
|
+
"f": kVK_ANSI_F, "g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J,
|
|
16
|
+
"k": kVK_ANSI_K, "l": kVK_ANSI_L, "m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O,
|
|
17
|
+
"p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R, "s": kVK_ANSI_S, "t": kVK_ANSI_T,
|
|
18
|
+
"u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X, "y": kVK_ANSI_Y,
|
|
19
|
+
"z": kVK_ANSI_Z,
|
|
20
|
+
"0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4,
|
|
21
|
+
"5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9,
|
|
22
|
+
"space": kVK_Space, "period": kVK_ANSI_Period, "comma": kVK_ANSI_Comma,
|
|
23
|
+
"slash": kVK_ANSI_Slash, "semicolon": kVK_ANSI_Semicolon, "quote": kVK_ANSI_Quote,
|
|
24
|
+
"minus": kVK_ANSI_Minus, "equal": kVK_ANSI_Equal,
|
|
25
|
+
"leftbracket": kVK_ANSI_LeftBracket, "rightbracket": kVK_ANSI_RightBracket,
|
|
26
|
+
"backslash": kVK_ANSI_Backslash, "grave": kVK_ANSI_Grave,
|
|
27
|
+
"return": kVK_Return, "tab": kVK_Tab,
|
|
28
|
+
]
|
|
29
|
+
let f = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10,
|
|
30
|
+
kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20]
|
|
31
|
+
for (i, code) in f.enumerated() { k["f\(i + 1)"] = code }
|
|
32
|
+
return k
|
|
33
|
+
}()
|
|
34
|
+
|
|
35
|
+
private static let modifierFlags: [(String, UInt32)] = [
|
|
36
|
+
("ctrl", UInt32(controlKey)), ("opt", UInt32(optionKey)),
|
|
37
|
+
("shift", UInt32(shiftKey)), ("cmd", UInt32(cmdKey)),
|
|
38
|
+
]
|
|
39
|
+
private static let aliases = ["control": "ctrl", "option": "opt", "alt": "opt", "command": "cmd"]
|
|
40
|
+
|
|
41
|
+
/// Nil for anything the daemon would refuse.
|
|
42
|
+
init?(_ text: String) {
|
|
43
|
+
let parts = text.lowercased().split(separator: "+", omittingEmptySubsequences: false)
|
|
44
|
+
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
45
|
+
guard let key = parts.last, let code = Self.keys[key] else { return nil }
|
|
46
|
+
var mods: UInt32 = 0
|
|
47
|
+
for p in parts.dropLast() {
|
|
48
|
+
let name = Self.aliases[p] ?? p
|
|
49
|
+
guard let flag = Self.modifierFlags.first(where: { $0.0 == name })?.1, mods & flag == 0 else { return nil }
|
|
50
|
+
mods |= flag
|
|
51
|
+
}
|
|
52
|
+
let isF = key.hasPrefix("f") && key.count > 1
|
|
53
|
+
guard isF || mods & UInt32(controlKey | optionKey | cmdKey) != 0 else { return nil }
|
|
54
|
+
keyCode = UInt32(code)
|
|
55
|
+
modifiers = mods
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
init(keyCode: UInt32, modifiers: UInt32) {
|
|
59
|
+
self.keyCode = keyCode
|
|
60
|
+
self.modifiers = modifiers
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// The stored form, modifiers in the Mac's order.
|
|
64
|
+
var text: String? {
|
|
65
|
+
guard let key = Self.keys.first(where: { UInt32($0.value) == keyCode })?.key else { return nil }
|
|
66
|
+
let mods = Self.modifierFlags.filter { modifiers & $0.1 != 0 }.map(\.0)
|
|
67
|
+
return (mods + [key]).joined(separator: "+")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// "⌃⌥1", as menus show shortcuts.
|
|
71
|
+
static func display(_ text: String) -> String {
|
|
72
|
+
let symbols = ["ctrl": "⌃", "opt": "⌥", "shift": "⇧", "cmd": "⌘"]
|
|
73
|
+
let names = ["space": "Space", "period": ".", "comma": ",", "slash": "/", "semicolon": ";",
|
|
74
|
+
"quote": "'", "minus": "-", "equal": "=", "leftbracket": "[", "rightbracket": "]",
|
|
75
|
+
"backslash": "\\", "grave": "`", "return": "Return", "tab": "Tab"]
|
|
76
|
+
var parts = text.lowercased().split(separator: "+").map(String.init)
|
|
77
|
+
guard let key = parts.popLast() else { return text }
|
|
78
|
+
return parts.map { symbols[Self.aliases[$0] ?? $0] ?? $0 }.joined() + (names[key] ?? key.uppercased())
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Carbon.HIToolbox
|
|
3
|
+
import ServiceManagement
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
/// Settings: per-agent voices and the app's own shortcuts and engines.
|
|
7
|
+
///
|
|
8
|
+
/// Everything but launch at login lives in agentx.json and goes through
|
|
9
|
+
/// the daemon, which checks each value against the config schema before
|
|
10
|
+
/// writing. The window shows what the daemon has and keeps no copy: it
|
|
11
|
+
/// loads fresh every time it opens and after every save.
|
|
12
|
+
@MainActor
|
|
13
|
+
final class SettingsModel: ObservableObject {
|
|
14
|
+
@Published var saved: VoiceSettings?
|
|
15
|
+
@Published var draft: VoiceSettings?
|
|
16
|
+
@Published var selected: String?
|
|
17
|
+
@Published var loading = false
|
|
18
|
+
@Published var saving = false
|
|
19
|
+
@Published var error = ""
|
|
20
|
+
@Published var notice = ""
|
|
21
|
+
@Published var launchAtLogin = LoginItem.isOn
|
|
22
|
+
/// "agents" or "general".
|
|
23
|
+
@Published var tab = "agents"
|
|
24
|
+
/// A shortcut field is waiting for keys: the app's own shortcuts are
|
|
25
|
+
/// off meanwhile, or they would fire instead of being recorded.
|
|
26
|
+
@Published var recording: String? {
|
|
27
|
+
didSet { if (recording == nil) != (oldValue == nil) { onRecording?(recording != nil) } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var onSaved: ((VoiceSettings) -> Void)?
|
|
31
|
+
var onRecording: ((Bool) -> Void)?
|
|
32
|
+
|
|
33
|
+
var hasChanges: Bool {
|
|
34
|
+
guard let saved, let draft else { return false }
|
|
35
|
+
return !draft.patch(from: saved).isEmpty
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func load() {
|
|
39
|
+
loading = true
|
|
40
|
+
error = ""
|
|
41
|
+
Task {
|
|
42
|
+
let s = await AgentClient.settings()
|
|
43
|
+
loading = false
|
|
44
|
+
guard let s else {
|
|
45
|
+
error = "The AgentX daemon isn't reachable. Start it, then open Settings again."
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
saved = s
|
|
49
|
+
draft = s
|
|
50
|
+
if selected == nil || !s.agents.contains(where: { $0.id == selected }) { selected = s.agents.first?.id }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func revert() {
|
|
55
|
+
draft = saved
|
|
56
|
+
error = ""
|
|
57
|
+
notice = ""
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func save() {
|
|
61
|
+
guard let saved, let draft, hasChanges else { return }
|
|
62
|
+
saving = true
|
|
63
|
+
error = ""
|
|
64
|
+
notice = ""
|
|
65
|
+
Task {
|
|
66
|
+
let result = await AgentClient.saveSettings(draft.patch(from: saved))
|
|
67
|
+
saving = false
|
|
68
|
+
switch result {
|
|
69
|
+
case .success(let s):
|
|
70
|
+
self.saved = s
|
|
71
|
+
self.draft = s
|
|
72
|
+
notice = "Saved. The next line spoken uses it; no restart needed."
|
|
73
|
+
onSaved?(s)
|
|
74
|
+
case .failure(let f):
|
|
75
|
+
error = f.message
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func preview(_ id: String) {
|
|
81
|
+
guard let draft else { return }
|
|
82
|
+
error = ""
|
|
83
|
+
notice = "Playing a sample…"
|
|
84
|
+
Task {
|
|
85
|
+
if let problem = await AgentClient.preview(agentID: id, voice: draft.previewVoice(for: id)) {
|
|
86
|
+
notice = ""
|
|
87
|
+
error = problem
|
|
88
|
+
} else {
|
|
89
|
+
notice = "Playing a sample. It is not saved until you press Save."
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func setLaunchAtLogin(_ on: Bool) {
|
|
95
|
+
do {
|
|
96
|
+
try LoginItem.set(on)
|
|
97
|
+
error = ""
|
|
98
|
+
} catch {
|
|
99
|
+
self.error = "macOS didn't change the login item: \(error.localizedDescription)"
|
|
100
|
+
}
|
|
101
|
+
launchAtLogin = LoginItem.isOn
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// A binding into the draft for the agent `id`.
|
|
105
|
+
func agent(_ id: String) -> Binding<VoiceSettings.Agent>? {
|
|
106
|
+
guard let i = draft?.agents.firstIndex(where: { $0.id == id }) else { return nil }
|
|
107
|
+
return Binding(get: { self.draft!.agents[i] }, set: { self.draft!.agents[i] = $0 })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Launch at login is macOS's own setting, never agentx.json.
|
|
112
|
+
enum LoginItem {
|
|
113
|
+
/// The launchd label `agentx desktop install` gives the app.
|
|
114
|
+
static let installerLabel = "tn.acme.agentx.voice"
|
|
115
|
+
|
|
116
|
+
/// Installed with `agentx desktop install`, which starts the app at
|
|
117
|
+
/// login with launchd. A second login item would start a second copy.
|
|
118
|
+
static var managedByInstaller: Bool {
|
|
119
|
+
if ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"] == installerLabel { return true }
|
|
120
|
+
let plist = "\(NSHomeDirectory())/Library/LaunchAgents/\(installerLabel).plist"
|
|
121
|
+
return FileManager.default.fileExists(atPath: plist)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static var isOn: Bool { managedByInstaller || SMAppService.mainApp.status == .enabled }
|
|
125
|
+
|
|
126
|
+
static func set(_ on: Bool) throws {
|
|
127
|
+
if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
struct SettingsView: View {
|
|
132
|
+
@ObservedObject var model: SettingsModel
|
|
133
|
+
|
|
134
|
+
var body: some View {
|
|
135
|
+
VStack(spacing: 0) {
|
|
136
|
+
if let draft = model.draft {
|
|
137
|
+
TabView(selection: $model.tab) {
|
|
138
|
+
AgentsTab(model: model, settings: draft)
|
|
139
|
+
.tabItem { Text("Agents") }
|
|
140
|
+
.tag("agents")
|
|
141
|
+
GeneralTab(model: model)
|
|
142
|
+
.tabItem { Text("General") }
|
|
143
|
+
.tag("general")
|
|
144
|
+
}
|
|
145
|
+
.padding([.horizontal, .top], 12)
|
|
146
|
+
} else {
|
|
147
|
+
VStack(spacing: 12) {
|
|
148
|
+
if model.loading { ProgressView() }
|
|
149
|
+
Text(model.loading ? "Loading settings…" : model.error)
|
|
150
|
+
.foregroundStyle(.secondary)
|
|
151
|
+
if !model.loading { Button("Try again") { model.load() } }
|
|
152
|
+
}
|
|
153
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
154
|
+
}
|
|
155
|
+
if model.draft != nil { footer }
|
|
156
|
+
}
|
|
157
|
+
.frame(width: 660, height: 640)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private var footer: some View {
|
|
161
|
+
HStack(alignment: .top, spacing: 12) {
|
|
162
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
163
|
+
if !model.error.isEmpty {
|
|
164
|
+
Text(model.error)
|
|
165
|
+
.foregroundStyle(Color(nsColor: .systemRed))
|
|
166
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
167
|
+
.textSelection(.enabled)
|
|
168
|
+
} else if !model.notice.isEmpty {
|
|
169
|
+
Text(model.notice).foregroundStyle(.secondary)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
.font(.callout)
|
|
173
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
174
|
+
Button("Revert") { model.revert() }
|
|
175
|
+
.disabled(!model.hasChanges || model.saving)
|
|
176
|
+
Button(model.saving ? "Saving…" : "Save") { model.save() }
|
|
177
|
+
.keyboardShortcut("s", modifiers: .command)
|
|
178
|
+
.disabled(!model.hasChanges || model.saving)
|
|
179
|
+
}
|
|
180
|
+
.padding(12)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// MARK: Agents
|
|
185
|
+
|
|
186
|
+
private struct AgentsTab: View {
|
|
187
|
+
@ObservedObject var model: SettingsModel
|
|
188
|
+
let settings: VoiceSettings
|
|
189
|
+
|
|
190
|
+
var body: some View {
|
|
191
|
+
HStack(spacing: 0) {
|
|
192
|
+
List(settings.agents, selection: $model.selected) { agent in
|
|
193
|
+
HStack(spacing: 8) {
|
|
194
|
+
Circle().fill(Color(hex: agent.color)).frame(width: 10, height: 10)
|
|
195
|
+
Text(agent.name)
|
|
196
|
+
}
|
|
197
|
+
.tag(agent.id)
|
|
198
|
+
}
|
|
199
|
+
.frame(width: 170)
|
|
200
|
+
Divider()
|
|
201
|
+
if let id = model.selected, let agent = model.agent(id) {
|
|
202
|
+
AgentForm(model: model, agent: agent, settings: settings)
|
|
203
|
+
} else {
|
|
204
|
+
Text("No agents in agentx.json").foregroundStyle(.secondary)
|
|
205
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private struct AgentForm: View {
|
|
212
|
+
@ObservedObject var model: SettingsModel
|
|
213
|
+
@Binding var agent: VoiceSettings.Agent
|
|
214
|
+
let settings: VoiceSettings
|
|
215
|
+
|
|
216
|
+
private var provider: String { agent.voice.provider ?? settings.general.provider }
|
|
217
|
+
|
|
218
|
+
var body: some View {
|
|
219
|
+
Form {
|
|
220
|
+
voiceSection
|
|
221
|
+
workSection
|
|
222
|
+
shortcutSection
|
|
223
|
+
}
|
|
224
|
+
.formStyle(.grouped)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private var voiceSection: some View {
|
|
228
|
+
Section {
|
|
229
|
+
providerPicker
|
|
230
|
+
systemVoicePicker
|
|
231
|
+
elevenLabsField
|
|
232
|
+
previewRow
|
|
233
|
+
rateRow
|
|
234
|
+
} header: {
|
|
235
|
+
Text("Voice")
|
|
236
|
+
} footer: {
|
|
237
|
+
Text("Voice changes apply to the next line this agent speaks. ElevenLabs speaks at most 1.2× and at least 0.7×.")
|
|
238
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private var providerPicker: some View {
|
|
243
|
+
let fallback = settings.general.provider == "elevenlabs" ? "ElevenLabs" : "Mac voices"
|
|
244
|
+
return Picker("Voice provider", selection: $agent.voice.provider) {
|
|
245
|
+
Text("Default (\(fallback))").tag(String?.none)
|
|
246
|
+
Text("Mac voices (free)").tag(String?.some("system"))
|
|
247
|
+
Text("ElevenLabs").tag(String?.some("elevenlabs"))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// A configured voice the Mac doesn't list (a plain name such as
|
|
252
|
+
/// "Daniel"), kept selectable so the picker can show it.
|
|
253
|
+
private var unlistedVoice: String? {
|
|
254
|
+
guard let s = agent.voice.system, s != "system",
|
|
255
|
+
!settings.systemVoices.contains(where: { $0.id == s }) else { return nil }
|
|
256
|
+
return s
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@ViewBuilder private var systemVoicePicker: some View {
|
|
260
|
+
if agent.voice.systemPerLanguage {
|
|
261
|
+
LabeledContent("Mac voice", value: "One voice per language, set in agentx.json")
|
|
262
|
+
} else {
|
|
263
|
+
Picker("Mac voice", selection: $agent.voice.system) {
|
|
264
|
+
Text("Assigned automatically").tag(String?.none)
|
|
265
|
+
Text("The Mac's default voice").tag(String?.some("system"))
|
|
266
|
+
if let s = unlistedVoice { Text(s).tag(String?.some(s)) }
|
|
267
|
+
ForEach(settings.systemVoices) { v in
|
|
268
|
+
Text(v.label).tag(String?.some(v.id))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private var elevenLabsField: some View {
|
|
275
|
+
let text = Binding<String>(
|
|
276
|
+
get: { agent.voice.elevenlabsVoiceId ?? "" },
|
|
277
|
+
set: { agent.voice.elevenlabsVoiceId = $0.isEmpty ? nil : $0 })
|
|
278
|
+
return TextField("ElevenLabs voice ID", text: text, prompt: Text("Default voice"))
|
|
279
|
+
.disabled(provider != "elevenlabs")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private var previewRow: some View {
|
|
283
|
+
let now: String = agent.speaks.provider == "elevenlabs" ? "ElevenLabs" : (agent.speaks.systemVoice ?? "the Mac's default voice")
|
|
284
|
+
return HStack {
|
|
285
|
+
Text("Saved voice: \(now)").font(.caption).foregroundStyle(.secondary)
|
|
286
|
+
Spacer()
|
|
287
|
+
Button("Preview") { model.preview(agent.id) }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private var rateRow: some View {
|
|
292
|
+
let rate = Binding<Double>(
|
|
293
|
+
get: { agent.voice.rate ?? 1 },
|
|
294
|
+
set: { v in
|
|
295
|
+
let snapped = (v * 20).rounded() / 20
|
|
296
|
+
agent.voice.rate = abs(snapped - 1) < 0.001 ? nil : snapped
|
|
297
|
+
})
|
|
298
|
+
return HStack {
|
|
299
|
+
Slider(value: rate, in: 0.75...1.5, step: 0.05) { Text("Speaking speed") }
|
|
300
|
+
Text(String(format: "%.2f×", agent.voice.rate ?? 1)).monospacedDigit().frame(width: 48)
|
|
301
|
+
Button("Normal") { agent.voice.rate = nil }.disabled(agent.voice.rate == nil)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private var workSection: some View {
|
|
306
|
+
// "off" written out and unset mean the same: both show as Off.
|
|
307
|
+
let narrate = Binding<String?>(
|
|
308
|
+
get: { agent.voice.narrate == "off" ? nil : agent.voice.narrate },
|
|
309
|
+
set: { agent.voice.narrate = $0 })
|
|
310
|
+
return Section("While it works") {
|
|
311
|
+
Picker("Narration", selection: narrate) {
|
|
312
|
+
Text("Off").tag(String?.none)
|
|
313
|
+
Text("On, except scheduled jobs").tag(String?.some("on"))
|
|
314
|
+
Text("On, scheduled jobs too").tag(String?.some("all"))
|
|
315
|
+
}
|
|
316
|
+
Picker("Queue priority", selection: $agent.voice.priority) {
|
|
317
|
+
Text("Normal").tag(String?.none)
|
|
318
|
+
Text("High: ahead of waiting lines").tag(String?.some("high"))
|
|
319
|
+
Text("Low: after waiting lines").tag(String?.some("low"))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private var shortcutSection: some View {
|
|
325
|
+
let color = Binding<Color>(
|
|
326
|
+
get: { Color(hex: agent.color) },
|
|
327
|
+
set: { agent.color = $0.hex; agent.colorSet = true })
|
|
328
|
+
let footer = agent.colorSet
|
|
329
|
+
? "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour also paints this agent's pointer."
|
|
330
|
+
: "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour comes from the agent's id until you pick one."
|
|
331
|
+
return Section {
|
|
332
|
+
HotkeyField(title: "Ask with shortcut", value: $agent.voice.hotkey, optional: true, model: model, id: "agent.\(agent.id)")
|
|
333
|
+
HStack {
|
|
334
|
+
ColorPicker("Orb colour", selection: color, supportsOpacity: false)
|
|
335
|
+
Button("Use default") { agent.colorSet = false }.disabled(!agent.colorSet)
|
|
336
|
+
}
|
|
337
|
+
} header: {
|
|
338
|
+
Text("Shortcut and colour")
|
|
339
|
+
} footer: {
|
|
340
|
+
Text(footer).font(.caption).foregroundStyle(.secondary)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// MARK: General
|
|
346
|
+
|
|
347
|
+
private struct GeneralTab: View {
|
|
348
|
+
@ObservedObject var model: SettingsModel
|
|
349
|
+
|
|
350
|
+
var body: some View {
|
|
351
|
+
Form {
|
|
352
|
+
if let draft = model.draft {
|
|
353
|
+
Section {
|
|
354
|
+
HotkeyField(title: "Talk (hold)", value: nonOptional(\.talk, draft.general.hotkeys.talk), optional: false, model: model, id: "talk")
|
|
355
|
+
HotkeyField(title: "Stop every voice", value: nonOptional(\.stop, draft.general.hotkeys.stop), optional: false, model: model, id: "stop")
|
|
356
|
+
HotkeyField(title: "Smart paste", value: nonOptional(\.paste, draft.general.hotkeys.paste), optional: false, model: model, id: "paste")
|
|
357
|
+
LabeledContent("Open the menu", value: HotkeySpec.display(draft.menuHotkey))
|
|
358
|
+
} header: {
|
|
359
|
+
Text("Shortcuts")
|
|
360
|
+
} footer: {
|
|
361
|
+
Text("Shortcuts change as soon as you save. If one does nothing, another app already uses it.")
|
|
362
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
363
|
+
}
|
|
364
|
+
Section {
|
|
365
|
+
Picker("Speech to text", selection: binding(\.stt)) {
|
|
366
|
+
Text("Automatic").tag("auto")
|
|
367
|
+
Text("ElevenLabs").tag("elevenlabs")
|
|
368
|
+
Text("On this Mac (Whisper)").tag("local")
|
|
369
|
+
}
|
|
370
|
+
Picker("Default voice provider", selection: binding(\.provider)) {
|
|
371
|
+
Text("Mac voices (free)").tag("system")
|
|
372
|
+
Text("ElevenLabs").tag("elevenlabs")
|
|
373
|
+
}
|
|
374
|
+
} header: {
|
|
375
|
+
Text("Speech")
|
|
376
|
+
} footer: {
|
|
377
|
+
Text("Automatic uses ElevenLabs when a key is set, and Whisper on this Mac otherwise. The default provider is for agents that don't choose their own. Both apply to the next question.")
|
|
378
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
379
|
+
}
|
|
380
|
+
Section {
|
|
381
|
+
Toggle("Launch at login", isOn: Binding(get: { model.launchAtLogin }, set: { model.setLaunchAtLogin($0) }))
|
|
382
|
+
.disabled(LoginItem.managedByInstaller)
|
|
383
|
+
} footer: {
|
|
384
|
+
Text(LoginItem.managedByInstaller
|
|
385
|
+
? "Started at login by `agentx desktop install`. To stop that, run `agentx desktop stop` in Terminal."
|
|
386
|
+
: "Saved by macOS as a login item, not in agentx.json. Changes right away.")
|
|
387
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
.formStyle(.grouped)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private func binding(_ path: WritableKeyPath<VoiceSettings.General, String>) -> Binding<String> {
|
|
395
|
+
Binding(get: { model.draft?.general[keyPath: path] ?? "" },
|
|
396
|
+
set: { model.draft?.general[keyPath: path] = $0 })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private func nonOptional(_ path: WritableKeyPath<VoiceSettings.Hotkeys, String>, _ now: String) -> Binding<String?> {
|
|
400
|
+
Binding(get: { now }, set: { if let v = $0 { model.draft?.general.hotkeys[keyPath: path] = v } })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// MARK: Shortcut field
|
|
405
|
+
|
|
406
|
+
/// Click, then press the keys. Escape cancels; Delete clears an optional one.
|
|
407
|
+
private struct HotkeyField: View {
|
|
408
|
+
let title: String
|
|
409
|
+
@Binding var value: String?
|
|
410
|
+
let optional: Bool
|
|
411
|
+
@ObservedObject var model: SettingsModel
|
|
412
|
+
let id: String
|
|
413
|
+
@State private var monitor: Any?
|
|
414
|
+
@State private var hint = ""
|
|
415
|
+
|
|
416
|
+
private var recording: Bool { model.recording == id }
|
|
417
|
+
|
|
418
|
+
var body: some View {
|
|
419
|
+
LabeledContent(title) {
|
|
420
|
+
HStack(spacing: 6) {
|
|
421
|
+
if !hint.isEmpty { Text(hint).font(.caption).foregroundStyle(.secondary) }
|
|
422
|
+
Button(recording ? "Type the shortcut…" : value.map(HotkeySpec.display) ?? "None") {
|
|
423
|
+
recording ? stop() : start()
|
|
424
|
+
}
|
|
425
|
+
.frame(minWidth: 110)
|
|
426
|
+
if optional && value != nil && !recording {
|
|
427
|
+
Button { value = nil } label: { Image(systemName: "xmark.circle.fill") }
|
|
428
|
+
.buttonStyle(.borderless)
|
|
429
|
+
.accessibilityLabel("Clear \(title)")
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
.onDisappear { stop() }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func start() {
|
|
437
|
+
model.recording = id
|
|
438
|
+
hint = "Esc to cancel"
|
|
439
|
+
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
|
440
|
+
let flags = event.modifierFlags.intersection([.control, .option, .shift, .command])
|
|
441
|
+
if event.keyCode == 53 && flags.isEmpty { stop(); return nil } // Escape
|
|
442
|
+
if (event.keyCode == 51 || event.keyCode == 117) && flags.isEmpty && optional { // Delete
|
|
443
|
+
value = nil; stop(); return nil
|
|
444
|
+
}
|
|
445
|
+
var mods: UInt32 = 0
|
|
446
|
+
if flags.contains(.control) { mods |= UInt32(controlKey) }
|
|
447
|
+
if flags.contains(.option) { mods |= UInt32(optionKey) }
|
|
448
|
+
if flags.contains(.shift) { mods |= UInt32(shiftKey) }
|
|
449
|
+
if flags.contains(.command) { mods |= UInt32(cmdKey) }
|
|
450
|
+
let spec = HotkeySpec(keyCode: UInt32(event.keyCode), modifiers: mods)
|
|
451
|
+
guard let text = spec.text, HotkeySpec(text) != nil else {
|
|
452
|
+
hint = "Add ⌃, ⌥ or ⌘"
|
|
453
|
+
return nil
|
|
454
|
+
}
|
|
455
|
+
value = text
|
|
456
|
+
stop()
|
|
457
|
+
return nil
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private func stop() {
|
|
462
|
+
if let monitor { NSEvent.removeMonitor(monitor) }
|
|
463
|
+
monitor = nil
|
|
464
|
+
hint = ""
|
|
465
|
+
if model.recording == id { model.recording = nil }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// MARK: Window
|
|
470
|
+
|
|
471
|
+
@MainActor
|
|
472
|
+
final class SettingsWindow: NSObject, NSWindowDelegate {
|
|
473
|
+
let model = SettingsModel()
|
|
474
|
+
private var window: NSWindow?
|
|
475
|
+
|
|
476
|
+
func show() {
|
|
477
|
+
if window == nil {
|
|
478
|
+
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: 640),
|
|
479
|
+
styleMask: [.titled, .closable, .miniaturizable],
|
|
480
|
+
backing: .buffered, defer: false)
|
|
481
|
+
w.title = "AgentX Voice Settings"
|
|
482
|
+
w.isReleasedWhenClosed = false
|
|
483
|
+
w.contentView = NSHostingView(rootView: SettingsView(model: model))
|
|
484
|
+
w.delegate = self
|
|
485
|
+
w.center()
|
|
486
|
+
window = w
|
|
487
|
+
}
|
|
488
|
+
model.launchAtLogin = LoginItem.isOn
|
|
489
|
+
model.load()
|
|
490
|
+
// An accessory app has to ask to come forward before its window can
|
|
491
|
+
// take typing; the window was opened on purpose, so that is fine.
|
|
492
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
493
|
+
window?.makeKeyAndOrderFront(nil)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
func windowWillClose(_ notification: Notification) {
|
|
497
|
+
model.recording = nil
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
extension Color {
|
|
502
|
+
/// "#RRGGBB"; the brand teal when it isn't one.
|
|
503
|
+
init(hex: String) {
|
|
504
|
+
if let c = OrbMath.parseHex(hex) { self.init(.sRGB, red: c.r, green: c.g, blue: c.b) }
|
|
505
|
+
else { self.init(nsColor: Brand.accent) }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/// "#RRGGBB" in sRGB.
|
|
509
|
+
var hex: String {
|
|
510
|
+
let c = NSColor(self).usingColorSpace(.sRGB) ?? .black
|
|
511
|
+
let v = [c.redComponent, c.greenComponent, c.blueComponent].map { Int(($0 * 255).rounded()).clamped(0, 255) }
|
|
512
|
+
return String(format: "#%02X%02X%02X", v[0], v[1], v[2])
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private extension Int {
|
|
517
|
+
func clamped(_ lo: Int, _ hi: Int) -> Int { Swift.min(hi, Swift.max(lo, self)) }
|
|
518
|
+
}
|