salidium 0.3.0 → 0.4.1
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/README.md +9 -0
- package/THIRD_PARTY_NOTICES +38 -0
- package/bundle/native/SalidiumMenuBar.swift +383 -0
- package/bundle/salidium.mjs +9217 -3542
- package/bundle/ui/assets/index-YXUyKAPE.js +78 -0
- package/bundle/ui/assets/index-oV3BVDrO.css +1 -0
- package/bundle/ui/assets/react-runtime-DfSeidf4.js +9 -0
- package/bundle/ui/assets/rolldown-runtime-CbXtAM7H.js +1 -0
- package/bundle/ui/assets/ui-runtime-C1c-P_PN.js +1 -0
- package/bundle/ui/index.html +5 -2
- package/package.json +3 -2
- package/bundle/ui/assets/index-D4Id-7NF.css +0 -1
- package/bundle/ui/assets/index-D7VTE7Js.js +0 -86
package/README.md
CHANGED
|
@@ -23,6 +23,15 @@ may contact its provider; the first run asks before enabling them.
|
|
|
23
23
|
An optional local profile can restate an existing generated Why and How in familiar terms. Saving
|
|
24
24
|
it sends nothing; each personalization call is explicit, ephemeral, and never changes evidence.
|
|
25
25
|
|
|
26
|
+
Local operations are available through the interface and CLI: queue and storage readings are exact
|
|
27
|
+
when safely observable and explicitly unavailable rather than partial, while health trends remain
|
|
28
|
+
bounded estimates. Both surfaces show durable maintenance progress, transition-based local alerts,
|
|
29
|
+
and versioned policy with source labels. The CLI can also produce a redacted diagnostic bundle, and
|
|
30
|
+
native desktop notifications are separately opt-in. The browser is only a control panel: closing it
|
|
31
|
+
does not stop the local daemon, and CLI status and control remain available without it. On macOS,
|
|
32
|
+
optional always-on mode adds login startup, crash recovery, and a native menu-bar control without
|
|
33
|
+
adding telemetry or a cloud dependency.
|
|
34
|
+
|
|
26
35
|
<picture>
|
|
27
36
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twinkling-reality/salidium/main/apps/site/public/report-dark.png">
|
|
28
37
|
<img src="https://raw.githubusercontent.com/twinkling-reality/salidium/main/apps/site/public/report-light.png" alt="A full report: the verdict, Why drawn as two paths converging on one order charged twice, How drawn as one idempotency key per order, and the approach the agent abandoned beside the one it adopted.">
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Third-Party Notices
|
|
2
|
+
===================
|
|
3
|
+
|
|
4
|
+
The Salidium npm package contains bundled code from these MIT-licensed projects:
|
|
5
|
+
|
|
6
|
+
- Zod 4.4.3 — Copyright (c) 2025 Colin McDonnell
|
|
7
|
+
- React 19.2.8, React DOM 19.2.8, and Scheduler 0.27.0 — Copyright (c) Meta
|
|
8
|
+
Platforms, Inc. and affiliates
|
|
9
|
+
- Zustand 5.0.15 — Copyright (c) 2019 Paul Henschel
|
|
10
|
+
- TanStack React Virtual 3.14.9 and TanStack Virtual Core 3.17.7 — Copyright (c)
|
|
11
|
+
2021-present Tanner Linsley
|
|
12
|
+
- Vite 8.2.1 generated browser runtime — Copyright (c) 2019-present, VoidZero Inc.
|
|
13
|
+
and Vite contributors
|
|
14
|
+
- Rolldown 1.2.4 generated browser runtime — Copyright (c) 2024-present VoidZero
|
|
15
|
+
Inc. & Contributors
|
|
16
|
+
- esbuild 0.28.2 generated bundle helpers — Copyright (c) 2020 Evan Wallace
|
|
17
|
+
- Rollup-derived portions of the Rolldown runtime — Copyright (c) 2017 Rollup
|
|
18
|
+
contributors
|
|
19
|
+
|
|
20
|
+
MIT License
|
|
21
|
+
|
|
22
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
23
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
24
|
+
in the Software without restriction, including without limitation the rights
|
|
25
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
26
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
27
|
+
furnished to do so, subject to the following conditions:
|
|
28
|
+
|
|
29
|
+
The above copyright notice and this permission notice shall be included in all
|
|
30
|
+
copies or substantial portions of the Software.
|
|
31
|
+
|
|
32
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
33
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
34
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
35
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
36
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
37
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
38
|
+
SOFTWARE.
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
private struct Configuration {
|
|
5
|
+
let home: String
|
|
6
|
+
let node: String
|
|
7
|
+
let cli: String
|
|
8
|
+
|
|
9
|
+
static func parse(_ arguments: [String]) -> Configuration? {
|
|
10
|
+
var values: [String: String] = [:]
|
|
11
|
+
var index = 1
|
|
12
|
+
while index + 1 < arguments.count {
|
|
13
|
+
let key = arguments[index]
|
|
14
|
+
if key.hasPrefix("--") {
|
|
15
|
+
values[key] = arguments[index + 1]
|
|
16
|
+
index += 2
|
|
17
|
+
} else {
|
|
18
|
+
index += 1
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
guard let home = values["--home"],
|
|
22
|
+
let node = values["--node"],
|
|
23
|
+
let cli = values["--cli"] else { return nil }
|
|
24
|
+
return Configuration(home: home, node: node, cli: cli)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private enum Health: String {
|
|
29
|
+
case healthy
|
|
30
|
+
case attention
|
|
31
|
+
case critical
|
|
32
|
+
case offline
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private struct Snapshot {
|
|
36
|
+
var health: Health = .offline
|
|
37
|
+
var pid: Int?
|
|
38
|
+
var collection = "Unavailable"
|
|
39
|
+
var queueFiles: Int?
|
|
40
|
+
var queueBytes: Int?
|
|
41
|
+
var storeBytes: Int?
|
|
42
|
+
var retention = "Unavailable"
|
|
43
|
+
var activeAlerts = 0
|
|
44
|
+
var maintenance: String?
|
|
45
|
+
|
|
46
|
+
static let offline = Snapshot()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
50
|
+
private let configuration: Configuration
|
|
51
|
+
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
52
|
+
private let menu = NSMenu()
|
|
53
|
+
private var snapshot = Snapshot.offline
|
|
54
|
+
private var refreshInProgress = false
|
|
55
|
+
private var timer: Timer?
|
|
56
|
+
|
|
57
|
+
init(configuration: Configuration) {
|
|
58
|
+
self.configuration = configuration
|
|
59
|
+
super.init()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
63
|
+
NSApp.setActivationPolicy(.accessory)
|
|
64
|
+
menu.delegate = self
|
|
65
|
+
statusItem.menu = menu
|
|
66
|
+
if let button = statusItem.button {
|
|
67
|
+
button.toolTip = "Salidium local operations"
|
|
68
|
+
button.setAccessibilityLabel("Salidium local operations")
|
|
69
|
+
}
|
|
70
|
+
rebuildMenu()
|
|
71
|
+
refresh()
|
|
72
|
+
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
73
|
+
self?.refresh()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func applicationWillTerminate(_ notification: Notification) {
|
|
78
|
+
timer?.invalidate()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func menuWillOpen(_ menu: NSMenu) {
|
|
82
|
+
refresh()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private func refresh() {
|
|
86
|
+
guard !refreshInProgress else { return }
|
|
87
|
+
refreshInProgress = true
|
|
88
|
+
let configuration = self.configuration
|
|
89
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
90
|
+
let next = Self.readSnapshot(configuration: configuration)
|
|
91
|
+
DispatchQueue.main.async {
|
|
92
|
+
guard let self = self else { return }
|
|
93
|
+
self.snapshot = next
|
|
94
|
+
self.refreshInProgress = false
|
|
95
|
+
self.rebuildMenu()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private static func readSnapshot(configuration: Configuration) -> Snapshot {
|
|
101
|
+
let daemonPath = URL(fileURLWithPath: configuration.home)
|
|
102
|
+
.appendingPathComponent("daemon.json")
|
|
103
|
+
guard let daemonData = try? Data(contentsOf: daemonPath),
|
|
104
|
+
let daemon = try? JSONSerialization.jsonObject(with: daemonData) as? [String: Any],
|
|
105
|
+
let port = integer(daemon["port"]),
|
|
106
|
+
let token = daemon["token"] as? String,
|
|
107
|
+
let recordedPID = integer(daemon["pid"]),
|
|
108
|
+
(1...65535).contains(port) else { return .offline }
|
|
109
|
+
|
|
110
|
+
guard let url = URL(string: "http://127.0.0.1:\(port)/api/operations") else {
|
|
111
|
+
return .offline
|
|
112
|
+
}
|
|
113
|
+
var request = URLRequest(url: url)
|
|
114
|
+
request.timeoutInterval = 2
|
|
115
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
116
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
117
|
+
var responseData: Data?
|
|
118
|
+
var responseStatus: Int?
|
|
119
|
+
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
|
|
120
|
+
responseData = data
|
|
121
|
+
responseStatus = (response as? HTTPURLResponse)?.statusCode
|
|
122
|
+
semaphore.signal()
|
|
123
|
+
}
|
|
124
|
+
task.resume()
|
|
125
|
+
guard semaphore.wait(timeout: .now() + 2.5) == .success,
|
|
126
|
+
responseStatus == 200,
|
|
127
|
+
let data = responseData,
|
|
128
|
+
let overview = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
129
|
+
let health = overview["health"] as? [String: Any],
|
|
130
|
+
let daemonHealth = health["daemon"] as? [String: Any],
|
|
131
|
+
integer(daemonHealth["pid"]) == recordedPID else {
|
|
132
|
+
task.cancel()
|
|
133
|
+
return .offline
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
var snapshot = Snapshot()
|
|
137
|
+
snapshot.health = Health(rawValue: health["overall"] as? String ?? "") ?? .attention
|
|
138
|
+
snapshot.pid = integer(daemonHealth["pid"])
|
|
139
|
+
if let collection = health["collection"] as? [String: Any],
|
|
140
|
+
let state = collection["state"] as? String {
|
|
141
|
+
snapshot.collection = state == "active" ? "On" : "Paused"
|
|
142
|
+
}
|
|
143
|
+
if let queue = health["queue"] as? [String: Any] {
|
|
144
|
+
snapshot.queueFiles = integer(queue["files"])
|
|
145
|
+
snapshot.queueBytes = integer(queue["bytes"])
|
|
146
|
+
}
|
|
147
|
+
if let store = health["store"] as? [String: Any] {
|
|
148
|
+
snapshot.storeBytes = integer(store["totalBytes"])
|
|
149
|
+
if let value = store["retention"] {
|
|
150
|
+
snapshot.retention = retentionLabel(value)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if let alerts = overview["alerts"] as? [String: Any],
|
|
154
|
+
let active = alerts["active"] as? [Any] {
|
|
155
|
+
snapshot.activeAlerts = active.count
|
|
156
|
+
}
|
|
157
|
+
if let maintenance = health["maintenance"] as? [String: Any],
|
|
158
|
+
let phase = maintenance["phase"] as? String {
|
|
159
|
+
let message = maintenance["message"] as? String
|
|
160
|
+
snapshot.maintenance = message?.isEmpty == false
|
|
161
|
+
? "\(titleCase(phase)) · \(shortLabel(message!))"
|
|
162
|
+
: titleCase(phase)
|
|
163
|
+
}
|
|
164
|
+
return snapshot
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private static func integer(_ value: Any?) -> Int? {
|
|
168
|
+
if value is NSNull { return nil }
|
|
169
|
+
return (value as? NSNumber)?.intValue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static func retentionLabel(_ value: Any) -> String {
|
|
173
|
+
if let text = value as? String { return text == "forever" ? "kept forever" : text }
|
|
174
|
+
if let days = integer(value) { return "kept \(days) days" }
|
|
175
|
+
return "retention unavailable"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static func titleCase(_ value: String) -> String {
|
|
179
|
+
value.replacingOccurrences(of: "-", with: " ")
|
|
180
|
+
.split(separator: " ")
|
|
181
|
+
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
|
182
|
+
.joined(separator: " ")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private static func shortLabel(_ value: String) -> String {
|
|
186
|
+
let limit = 88
|
|
187
|
+
return value.count > limit ? String(value.prefix(limit - 1)) + "…" : value
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func rebuildMenu() {
|
|
191
|
+
menu.removeAllItems()
|
|
192
|
+
configureStatusIcon()
|
|
193
|
+
|
|
194
|
+
// No em dash in anything the product says. `printedVoice.test.ts` reads this file for them.
|
|
195
|
+
let heading: String
|
|
196
|
+
switch snapshot.health {
|
|
197
|
+
case .healthy: heading = "Salidium · Healthy"
|
|
198
|
+
case .attention: heading = "Salidium · Needs Attention"
|
|
199
|
+
case .critical: heading = "Salidium · Critical"
|
|
200
|
+
case .offline: heading = "Salidium · Not Running"
|
|
201
|
+
}
|
|
202
|
+
addLabel(heading, emphasized: true)
|
|
203
|
+
menu.addItem(.separator())
|
|
204
|
+
|
|
205
|
+
addLabel(snapshot.pid.map { "Running · PID \($0)" } ?? "Not running")
|
|
206
|
+
addLabel("Recording · \(snapshot.collection)")
|
|
207
|
+
addLabel("Waiting to be stored · \(queueLabel())")
|
|
208
|
+
addLabel("On this Mac · \(storageLabel())")
|
|
209
|
+
addLabel(snapshot.activeAlerts == 0
|
|
210
|
+
? "Nothing needs attention"
|
|
211
|
+
: "\(snapshot.activeAlerts) need\(snapshot.activeAlerts == 1 ? "s" : "") attention")
|
|
212
|
+
if let maintenance = snapshot.maintenance { addLabel("Maintenance · \(maintenance)") }
|
|
213
|
+
|
|
214
|
+
menu.addItem(.separator())
|
|
215
|
+
addAction("Open Salidium", #selector(openSalidium), key: "o", enabled: true)
|
|
216
|
+
if snapshot.pid == nil {
|
|
217
|
+
addAction("Start Salidium", #selector(startSalidium), enabled: true)
|
|
218
|
+
} else {
|
|
219
|
+
let paused = snapshot.collection == "Paused"
|
|
220
|
+
addAction(paused ? "Resume Collection" : "Pause Collection",
|
|
221
|
+
paused ? #selector(resumeCollection) : #selector(pauseCollection),
|
|
222
|
+
enabled: true)
|
|
223
|
+
addAction("Store One Batch Now", #selector(drainQueue), enabled: true)
|
|
224
|
+
addAction("Stop Salidium", #selector(stopSalidium), enabled: true)
|
|
225
|
+
}
|
|
226
|
+
addAction("Refresh Now", #selector(refreshNow), key: "r", enabled: true)
|
|
227
|
+
menu.addItem(.separator())
|
|
228
|
+
addLabel("Always-on · Starts at login")
|
|
229
|
+
addAction("Open Data Folder", #selector(openDataFolder), enabled: true)
|
|
230
|
+
addAction("Turn Off Always-On Mode…", #selector(turnOffAlwaysOn), enabled: true)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private func configureStatusIcon() {
|
|
234
|
+
let symbol: String
|
|
235
|
+
let color: NSColor
|
|
236
|
+
switch snapshot.health {
|
|
237
|
+
case .healthy:
|
|
238
|
+
symbol = "checkmark.circle.fill"
|
|
239
|
+
color = .systemGreen
|
|
240
|
+
case .attention:
|
|
241
|
+
symbol = "exclamationmark.triangle.fill"
|
|
242
|
+
color = .systemOrange
|
|
243
|
+
case .critical:
|
|
244
|
+
symbol = "exclamationmark.octagon.fill"
|
|
245
|
+
color = .systemRed
|
|
246
|
+
case .offline:
|
|
247
|
+
symbol = "circle.slash"
|
|
248
|
+
color = .secondaryLabelColor
|
|
249
|
+
}
|
|
250
|
+
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Salidium \(snapshot.health.rawValue)")
|
|
251
|
+
image?.isTemplate = false
|
|
252
|
+
statusItem.button?.image = image
|
|
253
|
+
statusItem.button?.contentTintColor = color
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func queueLabel() -> String {
|
|
257
|
+
guard let files = snapshot.queueFiles, let bytes = snapshot.queueBytes else {
|
|
258
|
+
return "Unavailable"
|
|
259
|
+
}
|
|
260
|
+
return "\(files) \(files == 1 ? "file" : "files") · \(Self.byteLabel(bytes))"
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private func storageLabel() -> String {
|
|
264
|
+
guard let bytes = snapshot.storeBytes else { return "Unavailable" }
|
|
265
|
+
return "\(Self.byteLabel(bytes)) · \(snapshot.retention)"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* The same rendering as `formatBytes` in `@salidium/core`, which the app and the CLI use.
|
|
270
|
+
*
|
|
271
|
+
* Decimal, matching Finder, but not `ByteCountFormatter`: its adaptive mode renders one byte as
|
|
272
|
+
* "0 KB" and 999999 as "1 MB", and the same function formats rates where that loses the value.
|
|
273
|
+
* `byteLabelVectors` in that module pins the cases, and `macosService.test.ts` checks them
|
|
274
|
+
* against this function, because Swift cannot import it.
|
|
275
|
+
*/
|
|
276
|
+
private static func byteLabel(_ bytes: Int) -> String {
|
|
277
|
+
let value = Double(bytes)
|
|
278
|
+
if bytes < 1000 { return "\(bytes) B" }
|
|
279
|
+
if bytes < 1000 * 1000 { return String(format: "%.1f KB", value / 1000) }
|
|
280
|
+
if bytes < 1000 * 1000 * 1000 { return String(format: "%.1f MB", value / (1000 * 1000)) }
|
|
281
|
+
return String(format: "%.2f GB", value / (1000 * 1000 * 1000))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func addLabel(_ title: String, emphasized: Bool = false) {
|
|
285
|
+
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
286
|
+
item.isEnabled = false
|
|
287
|
+
if emphasized {
|
|
288
|
+
item.attributedTitle = NSAttributedString(
|
|
289
|
+
string: title,
|
|
290
|
+
attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)]
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
menu.addItem(item)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private func addAction(_ title: String,
|
|
297
|
+
_ action: Selector,
|
|
298
|
+
key: String = "",
|
|
299
|
+
enabled: Bool) {
|
|
300
|
+
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
|
|
301
|
+
item.target = self
|
|
302
|
+
item.isEnabled = enabled
|
|
303
|
+
menu.addItem(item)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private func runCLI(_ arguments: [String], refreshAfter: Bool = true) {
|
|
307
|
+
let process = Process()
|
|
308
|
+
process.executableURL = URL(fileURLWithPath: configuration.node)
|
|
309
|
+
process.arguments = [configuration.cli] + arguments
|
|
310
|
+
var environment = ProcessInfo.processInfo.environment
|
|
311
|
+
environment["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path
|
|
312
|
+
environment["SALIDIUM_HOME"] = configuration.home
|
|
313
|
+
process.environment = environment
|
|
314
|
+
process.standardOutput = FileHandle.nullDevice
|
|
315
|
+
let errorPipe = Pipe()
|
|
316
|
+
process.standardError = errorPipe
|
|
317
|
+
if refreshAfter {
|
|
318
|
+
process.terminationHandler = { [weak self] finished in
|
|
319
|
+
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
320
|
+
let detail = String(data: data, encoding: .utf8)?
|
|
321
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
322
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
323
|
+
guard let self = self else { return }
|
|
324
|
+
if finished.terminationStatus != 0 {
|
|
325
|
+
self.showError("Salidium could not complete that command",
|
|
326
|
+
detail: detail?.isEmpty == false ? detail! : "The command exited with status \(finished.terminationStatus).")
|
|
327
|
+
}
|
|
328
|
+
self.refresh()
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
process.standardError = FileHandle.nullDevice
|
|
333
|
+
}
|
|
334
|
+
do {
|
|
335
|
+
try process.run()
|
|
336
|
+
} catch {
|
|
337
|
+
showError("Salidium could not run that command", detail: error.localizedDescription)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private func showError(_ message: String, detail: String) {
|
|
342
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
343
|
+
let alert = NSAlert()
|
|
344
|
+
alert.alertStyle = .warning
|
|
345
|
+
alert.messageText = message
|
|
346
|
+
alert.informativeText = detail
|
|
347
|
+
alert.runModal()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
@objc private func openSalidium() { runCLI(["open"]) }
|
|
351
|
+
@objc private func startSalidium() { runCLI(["start"]) }
|
|
352
|
+
@objc private func pauseCollection() { runCLI(["pause", "--quiet"]) }
|
|
353
|
+
@objc private func resumeCollection() { runCLI(["resume", "--quiet"]) }
|
|
354
|
+
@objc private func drainQueue() { runCLI(["maintenance", "drain", "--quiet"]) }
|
|
355
|
+
@objc private func stopSalidium() { runCLI(["stop", "--quiet"]) }
|
|
356
|
+
@objc private func refreshNow() { refresh() }
|
|
357
|
+
|
|
358
|
+
@objc private func openDataFolder() {
|
|
359
|
+
NSWorkspace.shared.open(URL(fileURLWithPath: configuration.home, isDirectory: true))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@objc private func turnOffAlwaysOn() {
|
|
363
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
364
|
+
let alert = NSAlert()
|
|
365
|
+
alert.alertStyle = .warning
|
|
366
|
+
alert.messageText = "Turn off Salidium always-on mode?"
|
|
367
|
+
alert.informativeText = "This stops Salidium and removes its menu-bar control until you run “salidium service enable”. Your reports and settings stay on this Mac."
|
|
368
|
+
alert.addButton(withTitle: "Turn Off")
|
|
369
|
+
alert.addButton(withTitle: "Cancel")
|
|
370
|
+
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
371
|
+
runCLI(["service", "disable"], refreshAfter: false)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
guard let configuration = Configuration.parse(CommandLine.arguments) else {
|
|
376
|
+
FileHandle.standardError.write(Data("usage: salidium-menubar --home PATH --node PATH --cli PATH\n".utf8))
|
|
377
|
+
exit(2)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let application = NSApplication.shared
|
|
381
|
+
private let delegate = AppDelegate(configuration: configuration)
|
|
382
|
+
application.delegate = delegate
|
|
383
|
+
application.run()
|