salidium 0.4.1 → 0.5.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/bundle/native/SalidiumMenuBar.swift +669 -91
- package/bundle/salidium.mjs +272 -9
- package/bundle/ui/assets/{index-oV3BVDrO.css → index-BR7P08_S.css} +1 -1
- package/bundle/ui/assets/index-DIfdilCQ.js +78 -0
- package/bundle/ui/index.html +2 -2
- package/package.json +1 -1
- package/bundle/ui/assets/index-YXUyKAPE.js +0 -78
|
@@ -32,27 +32,177 @@ private enum Health: String {
|
|
|
32
32
|
case offline
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/*
|
|
36
|
+
* The one sentence the menu exists to say, and the single action that answers it.
|
|
37
|
+
*
|
|
38
|
+
* The daemon already writes this text. `alerts.ts` gives every condition a title and a detail
|
|
39
|
+
* composed for a person ("Salidium is falling behind", "Nothing is lost while it waits"), and the
|
|
40
|
+
* native notification sink delivers them verbatim. This menu used to read the same payload, keep
|
|
41
|
+
* `alerts.active.count`, and render a number, so the product spoke plainly when it pushed and in
|
|
42
|
+
* gauges when you pulled. These fields carry the sentences through instead.
|
|
43
|
+
*/
|
|
44
|
+
private struct Situation {
|
|
45
|
+
var headline: String
|
|
46
|
+
var detail: String?
|
|
47
|
+
var tone: Health
|
|
48
|
+
/// Whether Salidium has something to account for, as opposed to a state the user chose.
|
|
49
|
+
var explain: Bool = false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private struct Provider {
|
|
53
|
+
var name: String
|
|
54
|
+
var detected: Bool
|
|
55
|
+
var configuration: String
|
|
56
|
+
var trust: String
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* The same set the daemon counts as a hook problem in `health.ts`, and for the same reasons.
|
|
60
|
+
*
|
|
61
|
+
* `not-configured` is deliberately absent even though it means no hooks: the interface offers
|
|
62
|
+
* Disconnect, so a detected provider without them is usually a choice, and a menu that reports
|
|
63
|
+
* a choice back as a fault is arguing with the reader. `detected` gates the whole thing,
|
|
64
|
+
* because a provider that is not installed is not a problem to have; without that check a
|
|
65
|
+
* machine with only one agent would have carried a permanent complaint about the other.
|
|
66
|
+
*/
|
|
67
|
+
var problem: String? {
|
|
68
|
+
guard detected else { return nil }
|
|
69
|
+
if configuration == "invalid" { return "needs repair" }
|
|
70
|
+
if trust == "untrusted" { return "needs approval" }
|
|
71
|
+
if trust == "modified" { return "changed since approval" }
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private struct Alert {
|
|
77
|
+
var title: String
|
|
78
|
+
var detail: String
|
|
79
|
+
var severity: String
|
|
80
|
+
}
|
|
81
|
+
|
|
35
82
|
private struct Snapshot {
|
|
36
83
|
var health: Health = .offline
|
|
37
84
|
var pid: Int?
|
|
38
|
-
var
|
|
85
|
+
var collectionPaused = false
|
|
86
|
+
var collectionKnown = false
|
|
87
|
+
var pauseExpiresAt: String?
|
|
39
88
|
var queueFiles: Int?
|
|
40
89
|
var queueBytes: Int?
|
|
41
90
|
var storeBytes: Int?
|
|
91
|
+
var warnAtBytes: Int?
|
|
92
|
+
var storageBytesPerMinute: Double?
|
|
42
93
|
var retention = "Unavailable"
|
|
43
|
-
var
|
|
94
|
+
var alerts: [Alert] = []
|
|
95
|
+
var providers: [Provider] = []
|
|
96
|
+
/// Only set while a phase is actually running. A finished operation is a record, not a status.
|
|
44
97
|
var maintenance: String?
|
|
45
98
|
|
|
46
99
|
static let offline = Snapshot()
|
|
47
100
|
}
|
|
48
101
|
|
|
102
|
+
/*
|
|
103
|
+
* The storage row, drawn rather than written.
|
|
104
|
+
*
|
|
105
|
+
* A number on its own ("On this Mac · 3.02 GB · kept forever") is a true fact nobody can act on:
|
|
106
|
+
* it has no direction and no edge, so a store that quietly doubles reads the same as one that has
|
|
107
|
+
* not moved. The bar gives it an edge and the subtitle gives it direction. The reference is the
|
|
108
|
+
* configured warning size and it is labelled as one, because it is a threshold Salidium chose and
|
|
109
|
+
* not a capacity the disk imposes; calling it a capacity would be a more comfortable shape and a
|
|
110
|
+
* false one.
|
|
111
|
+
*/
|
|
112
|
+
private final class StorageView: NSView {
|
|
113
|
+
private let heading = NSTextField(labelWithString: "")
|
|
114
|
+
private let value = NSTextField(labelWithString: "")
|
|
115
|
+
private let subtitle = NSTextField(labelWithString: "")
|
|
116
|
+
private var fraction: Double = 0
|
|
117
|
+
private var tone: NSColor = .systemGreen
|
|
118
|
+
private var known = false
|
|
119
|
+
|
|
120
|
+
override init(frame: NSRect) {
|
|
121
|
+
super.init(frame: frame)
|
|
122
|
+
heading.font = .menuFont(ofSize: NSFont.systemFontSize)
|
|
123
|
+
heading.textColor = .labelColor
|
|
124
|
+
value.font = .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
|
|
125
|
+
value.textColor = .secondaryLabelColor
|
|
126
|
+
value.alignment = .right
|
|
127
|
+
subtitle.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
128
|
+
subtitle.textColor = .tertiaryLabelColor
|
|
129
|
+
for label in [heading, value, subtitle] {
|
|
130
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
131
|
+
addSubview(label)
|
|
132
|
+
}
|
|
133
|
+
NSLayoutConstraint.activate([
|
|
134
|
+
heading.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 21),
|
|
135
|
+
heading.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
|
136
|
+
value.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
137
|
+
value.firstBaselineAnchor.constraint(equalTo: heading.firstBaselineAnchor),
|
|
138
|
+
value.leadingAnchor.constraint(greaterThanOrEqualTo: heading.trailingAnchor, constant: 8),
|
|
139
|
+
subtitle.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 21),
|
|
140
|
+
subtitle.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -14),
|
|
141
|
+
subtitle.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 11),
|
|
142
|
+
])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
required init?(coder: NSCoder) { return nil }
|
|
146
|
+
|
|
147
|
+
override var intrinsicContentSize: NSSize { NSSize(width: 300, height: 50) }
|
|
148
|
+
|
|
149
|
+
/// Takes finished strings: byte rendering lives beside the formatter it is pinned against.
|
|
150
|
+
func apply(value valueText: String, subtitle subtitleText: String, fraction filled: Double?) {
|
|
151
|
+
heading.stringValue = "On this Mac"
|
|
152
|
+
value.stringValue = valueText
|
|
153
|
+
subtitle.stringValue = subtitleText
|
|
154
|
+
known = filled != nil
|
|
155
|
+
fraction = filled ?? 0
|
|
156
|
+
/*
|
|
157
|
+
* Amber before the mark rather than at it. An indicator that only changes once the alert
|
|
158
|
+
* has already fired tells you a thing you have just been told; the point of drawing this
|
|
159
|
+
* is the part of the curve where there is still a choice.
|
|
160
|
+
*/
|
|
161
|
+
tone = fraction >= 1 ? .systemRed : fraction >= 0.75 ? .systemOrange : .systemGreen
|
|
162
|
+
needsDisplay = true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
166
|
+
super.draw(dirtyRect)
|
|
167
|
+
let track = NSRect(x: 21, y: 20, width: bounds.width - 35, height: 4)
|
|
168
|
+
let radius: CGFloat = 2
|
|
169
|
+
NSColor.quaternaryLabelColor.setFill()
|
|
170
|
+
NSBezierPath(roundedRect: track, xRadius: radius, yRadius: radius).fill()
|
|
171
|
+
guard known, fraction > 0 else { return }
|
|
172
|
+
var filled = track
|
|
173
|
+
filled.size.width = max(track.width * CGFloat(fraction), radius * 2)
|
|
174
|
+
tone.setFill()
|
|
175
|
+
NSBezierPath(roundedRect: filled, xRadius: radius, yRadius: radius).fill()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
49
179
|
private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
50
180
|
private let configuration: Configuration
|
|
51
|
-
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.
|
|
181
|
+
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
52
182
|
private let menu = NSMenu()
|
|
53
183
|
private var snapshot = Snapshot.offline
|
|
54
184
|
private var refreshInProgress = false
|
|
55
185
|
private var timer: Timer?
|
|
186
|
+
/*
|
|
187
|
+
* What a command the reader started is doing, and the pulse that says it is still doing it.
|
|
188
|
+
*
|
|
189
|
+
* Choosing a menu item closes the menu, so every action was silent: "Store 412 Waiting Files
|
|
190
|
+
* Now" runs the CLI for up to thirty seconds with nothing on screen to say so, and the only
|
|
191
|
+
* evidence was the number eventually changing. The label answers what, on the next open. The
|
|
192
|
+
* pulse answers whether it is still going, without one.
|
|
193
|
+
*/
|
|
194
|
+
private var runningAction: String?
|
|
195
|
+
private var pulseTimer: Timer?
|
|
196
|
+
private var pulsePhase = 0
|
|
197
|
+
/*
|
|
198
|
+
* The last command that failed, kept after its alert is dismissed.
|
|
199
|
+
*
|
|
200
|
+
* An alert is a moment and a failure is a state. Pressing OK on "Salidium could not start" put
|
|
201
|
+
* the menu back to "Not running" with nothing to say a start had just been refused, so the only
|
|
202
|
+
* remaining evidence of it was that the reader remembered. It is cleared by the next attempt or
|
|
203
|
+
* by the daemon turning up, both of which make it no longer true.
|
|
204
|
+
*/
|
|
205
|
+
private var lastFailure: String?
|
|
56
206
|
|
|
57
207
|
init(configuration: Configuration) {
|
|
58
208
|
self.configuration = configuration
|
|
@@ -63,10 +213,6 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
63
213
|
NSApp.setActivationPolicy(.accessory)
|
|
64
214
|
menu.delegate = self
|
|
65
215
|
statusItem.menu = menu
|
|
66
|
-
if let button = statusItem.button {
|
|
67
|
-
button.toolTip = "Salidium local operations"
|
|
68
|
-
button.setAccessibilityLabel("Salidium local operations")
|
|
69
|
-
}
|
|
70
216
|
rebuildMenu()
|
|
71
217
|
refresh()
|
|
72
218
|
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
@@ -76,6 +222,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
76
222
|
|
|
77
223
|
func applicationWillTerminate(_ notification: Notification) {
|
|
78
224
|
timer?.invalidate()
|
|
225
|
+
pulseTimer?.invalidate()
|
|
79
226
|
}
|
|
80
227
|
|
|
81
228
|
func menuWillOpen(_ menu: NSMenu) {
|
|
@@ -91,6 +238,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
91
238
|
DispatchQueue.main.async {
|
|
92
239
|
guard let self = self else { return }
|
|
93
240
|
self.snapshot = next
|
|
241
|
+
if next.pid != nil { self.lastFailure = nil }
|
|
94
242
|
self.refreshInProgress = false
|
|
95
243
|
self.rebuildMenu()
|
|
96
244
|
}
|
|
@@ -138,7 +286,9 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
138
286
|
snapshot.pid = integer(daemonHealth["pid"])
|
|
139
287
|
if let collection = health["collection"] as? [String: Any],
|
|
140
288
|
let state = collection["state"] as? String {
|
|
141
|
-
snapshot.
|
|
289
|
+
snapshot.collectionKnown = true
|
|
290
|
+
snapshot.collectionPaused = state != "active"
|
|
291
|
+
snapshot.pauseExpiresAt = collection["pauseExpiresAt"] as? String
|
|
142
292
|
}
|
|
143
293
|
if let queue = health["queue"] as? [String: Any] {
|
|
144
294
|
snapshot.queueFiles = integer(queue["files"])
|
|
@@ -150,12 +300,48 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
150
300
|
snapshot.retention = retentionLabel(value)
|
|
151
301
|
}
|
|
152
302
|
}
|
|
303
|
+
/*
|
|
304
|
+
* The warning size is policy rather than measurement, so it comes from the config half of
|
|
305
|
+
* the payload. Without it the bar has no edge to draw and the row falls back to a number.
|
|
306
|
+
*/
|
|
307
|
+
if let config = overview["config"] as? [String: Any],
|
|
308
|
+
let values = config["values"] as? [String: Any],
|
|
309
|
+
let alerts = values["alerts"] as? [String: Any],
|
|
310
|
+
let databaseSize = alerts["databaseSizeBytes"] as? [String: Any] {
|
|
311
|
+
snapshot.warnAtBytes = integer(databaseSize["value"])
|
|
312
|
+
}
|
|
313
|
+
if let estimates = health["estimates"] as? [String: Any],
|
|
314
|
+
let growth = estimates["storageGrowth"] as? [String: Any],
|
|
315
|
+
let value = growth["value"] as? NSNumber {
|
|
316
|
+
snapshot.storageBytesPerMinute = value.doubleValue
|
|
317
|
+
}
|
|
153
318
|
if let alerts = overview["alerts"] as? [String: Any],
|
|
154
|
-
let active = alerts["active"] as? [Any] {
|
|
155
|
-
snapshot.
|
|
319
|
+
let active = alerts["active"] as? [[String: Any]] {
|
|
320
|
+
snapshot.alerts = active.compactMap { entry in
|
|
321
|
+
guard let title = entry["title"] as? String else { return nil }
|
|
322
|
+
return Alert(title: title,
|
|
323
|
+
detail: entry["detail"] as? String ?? "",
|
|
324
|
+
severity: entry["severity"] as? String ?? "warning")
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if let hooks = health["hooks"] as? [[String: Any]] {
|
|
328
|
+
snapshot.providers = hooks.compactMap { entry in
|
|
329
|
+
guard let name = entry["name"] as? String else { return nil }
|
|
330
|
+
return Provider(name: name,
|
|
331
|
+
detected: entry["detected"] as? Bool ?? false,
|
|
332
|
+
configuration: entry["configuration"] as? String ?? "unknown",
|
|
333
|
+
trust: entry["trust"] as? String ?? "not-applicable")
|
|
334
|
+
}
|
|
156
335
|
}
|
|
336
|
+
/*
|
|
337
|
+
* `maintenance.json` is written on every phase and deliberately never deleted, because it
|
|
338
|
+
* is the only durable evidence of an interrupted one. That makes it a record, and reading
|
|
339
|
+
* a record as a status left a finished operation pinned in this menu for days. Only a
|
|
340
|
+
* phase still in flight is news.
|
|
341
|
+
*/
|
|
157
342
|
if let maintenance = health["maintenance"] as? [String: Any],
|
|
158
|
-
let phase = maintenance["phase"] as? String
|
|
343
|
+
let phase = maintenance["phase"] as? String,
|
|
344
|
+
!["completed", "idle"].contains(phase) {
|
|
159
345
|
let message = maintenance["message"] as? String
|
|
160
346
|
snapshot.maintenance = message?.isEmpty == false
|
|
161
347
|
? "\(titleCase(phase)) · \(shortLabel(message!))"
|
|
@@ -182,87 +368,437 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
182
368
|
.joined(separator: " ")
|
|
183
369
|
}
|
|
184
370
|
|
|
371
|
+
/*
|
|
372
|
+
* Cut at a word, never through one. Cutting on a character count alone produced
|
|
373
|
+
* "…100 more files are w…" in the menu, which reads as a rendering fault rather than an
|
|
374
|
+
* abbreviation. The full text is one click away in the interface either way.
|
|
375
|
+
*/
|
|
185
376
|
private static func shortLabel(_ value: String) -> String {
|
|
186
377
|
let limit = 88
|
|
187
|
-
|
|
378
|
+
guard value.count > limit else { return value }
|
|
379
|
+
let clipped = value.prefix(limit - 1)
|
|
380
|
+
guard let lastSpace = clipped.lastIndex(of: " ") else {
|
|
381
|
+
return String(clipped) + "…"
|
|
382
|
+
}
|
|
383
|
+
return clipped[..<lastSpace].trimmingCharacters(in: .punctuationCharacters) + "…"
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/*
|
|
387
|
+
* The first sentence of an alert detail, because the daemon writes these as a claim followed
|
|
388
|
+
* by its consequence and the claim is what a menu has room for.
|
|
389
|
+
*/
|
|
390
|
+
private static func leadSentence(_ value: String) -> String {
|
|
391
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
392
|
+
guard let stop = trimmed.firstIndex(of: ".") else { return shortLabel(trimmed) }
|
|
393
|
+
return shortLabel(String(trimmed[...stop]))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Renders an ISO instant the way a person would say it, for the one place a time is shown.
|
|
397
|
+
private static func whenLabel(_ iso: String) -> String? {
|
|
398
|
+
let parser = ISO8601DateFormatter()
|
|
399
|
+
parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
400
|
+
let date = parser.date(from: iso) ?? {
|
|
401
|
+
let plain = ISO8601DateFormatter()
|
|
402
|
+
plain.formatOptions = [.withInternetDateTime]
|
|
403
|
+
return plain.date(from: iso)
|
|
404
|
+
}()
|
|
405
|
+
guard let date = date else { return nil }
|
|
406
|
+
let formatter = DateFormatter()
|
|
407
|
+
formatter.doesRelativeDateFormatting = true
|
|
408
|
+
formatter.dateStyle = .short
|
|
409
|
+
formatter.timeStyle = .short
|
|
410
|
+
return formatter.string(from: date)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/*
|
|
414
|
+
* Which single fact the menu leads with.
|
|
415
|
+
*
|
|
416
|
+
* Ordered by what stops the product working, not by internal severity. A provider that is not
|
|
417
|
+
* connected outranks a queue that is behind, because a backlog still ends up recorded and an
|
|
418
|
+
* unconfigured hook never arrives at all. Recording is last: when everything is fine there is
|
|
419
|
+
* exactly one line and nothing to read.
|
|
420
|
+
*/
|
|
421
|
+
/*
|
|
422
|
+
* What the reader just asked for outranks what was true before they asked, and it is the only
|
|
423
|
+
* line about to change on its own, so while a command runs it is the line worth having.
|
|
424
|
+
*
|
|
425
|
+
* Only the words though. The icon keeps its resting shape, because "working" is a passing
|
|
426
|
+
* condition and the badge vocabulary is for standing ones: sprouting a warning dot for the
|
|
427
|
+
* duration of a drain the reader asked for would be reporting their own click back to them as
|
|
428
|
+
* a problem. The pulse carries it instead, which is why the two are separated here.
|
|
429
|
+
*/
|
|
430
|
+
private func situation() -> Situation {
|
|
431
|
+
if let action = runningAction {
|
|
432
|
+
return Situation(headline: "\(action)…", detail: nil, tone: restingSituation().tone)
|
|
433
|
+
}
|
|
434
|
+
return restingSituation()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private func restingSituation() -> Situation {
|
|
438
|
+
/*
|
|
439
|
+
* The second line has to earn itself. "Not running" already says nothing is being stored,
|
|
440
|
+
* so restating that as "agent work is not being recorded" spent a row saying the same
|
|
441
|
+
* thing twice, and it was not even true: the relay stays installed, so a hook that fires
|
|
442
|
+
* while the daemon is down publishes a spool file instead of posting, and the next start
|
|
443
|
+
* drains it. What a reader wants to know here is whether they have lost anything.
|
|
444
|
+
*/
|
|
445
|
+
if snapshot.pid == nil {
|
|
446
|
+
return Situation(
|
|
447
|
+
headline: "Not running",
|
|
448
|
+
// The refusal outranks the reassurance: a reader who just tried to start it is not
|
|
449
|
+
// asking what happens to new work, they are asking why nothing happened.
|
|
450
|
+
detail: lastFailure ?? "New work waits to be stored until you start it.",
|
|
451
|
+
tone: lastFailure == nil ? .offline : .critical,
|
|
452
|
+
explain: lastFailure != nil)
|
|
453
|
+
}
|
|
454
|
+
if let critical = snapshot.alerts.first(where: { $0.severity == "critical" }) {
|
|
455
|
+
return Situation(headline: critical.title, detail: Self.leadSentence(critical.detail),
|
|
456
|
+
tone: .critical, explain: true)
|
|
457
|
+
}
|
|
458
|
+
/*
|
|
459
|
+
* Named by what is actually wrong, in the words the interface already uses for the same
|
|
460
|
+
* states: a provider whose hooks are malformed is not "not connected", and one whose hooks
|
|
461
|
+
* changed since Codex approved them is connected and refusing to run. "Incomplete" rather
|
|
462
|
+
* than "not recorded" because transcript tailing carries on either way, so the sessions
|
|
463
|
+
* still arrive; what they lose is the hook-only signal, which is what a gap means here.
|
|
464
|
+
*/
|
|
465
|
+
let broken = snapshot.providers.compactMap { provider in
|
|
466
|
+
provider.problem.map { (name: provider.name, reason: $0) }
|
|
467
|
+
}
|
|
468
|
+
if let first = broken.first {
|
|
469
|
+
return Situation(
|
|
470
|
+
headline: broken.count == 1
|
|
471
|
+
? "\(first.name) \(first.reason)"
|
|
472
|
+
: "\(broken.count) providers need attention",
|
|
473
|
+
detail: "Reports for \(broken.count == 1 ? "it" : "them") will be incomplete.",
|
|
474
|
+
tone: .critical,
|
|
475
|
+
explain: true)
|
|
476
|
+
}
|
|
477
|
+
/*
|
|
478
|
+
* A pause the user asked for is not something to explain back to them, so it keeps the
|
|
479
|
+
* plain action label even though it is not the healthy state.
|
|
480
|
+
*/
|
|
481
|
+
if snapshot.collectionPaused {
|
|
482
|
+
let when = snapshot.pauseExpiresAt.flatMap(Self.whenLabel)
|
|
483
|
+
return Situation(headline: "Paused",
|
|
484
|
+
detail: when.map { "Recording resumes \($0)." }
|
|
485
|
+
?? "Recording stays off until you resume it.",
|
|
486
|
+
tone: .attention)
|
|
487
|
+
}
|
|
488
|
+
if let alert = snapshot.alerts.first {
|
|
489
|
+
return Situation(headline: alert.title, detail: Self.leadSentence(alert.detail),
|
|
490
|
+
tone: .attention, explain: true)
|
|
491
|
+
}
|
|
492
|
+
if !snapshot.collectionKnown {
|
|
493
|
+
return Situation(headline: "Collection state unavailable", detail: nil,
|
|
494
|
+
tone: .attention, explain: true)
|
|
495
|
+
}
|
|
496
|
+
return Situation(headline: "Recording your agent work", detail: nil, tone: .healthy)
|
|
188
497
|
}
|
|
189
498
|
|
|
190
499
|
private func rebuildMenu() {
|
|
191
500
|
menu.removeAllItems()
|
|
192
|
-
|
|
501
|
+
let situation = situation()
|
|
502
|
+
configureStatusIcon(situation: situation)
|
|
193
503
|
|
|
194
504
|
// No em dash in anything the product says. `printedVoice.test.ts` reads this file for them.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
addLabel(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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)") }
|
|
505
|
+
/*
|
|
506
|
+
* No name row. This menu opens from the Salidium mark and from nothing else, so a line
|
|
507
|
+
* saying "Salidium" spent the most prominent row in it on a fact the reader established by
|
|
508
|
+
* clicking. Time Machine and Dropbox both open straight into their status for the same
|
|
509
|
+
* reason. The status sentence takes the emphasis it was using.
|
|
510
|
+
*/
|
|
511
|
+
addLabel(situation.headline, emphasized: true)
|
|
512
|
+
if let detail = situation.detail { addLabel(detail, secondary: true) }
|
|
513
|
+
if runningAction == nil, let maintenance = snapshot.maintenance {
|
|
514
|
+
addLabel("Maintenance · \(maintenance)")
|
|
515
|
+
}
|
|
213
516
|
|
|
214
517
|
menu.addItem(.separator())
|
|
215
|
-
addAction("Open Salidium", #selector(openSalidium), key: "o", enabled: true)
|
|
216
518
|
if snapshot.pid == nil {
|
|
217
|
-
|
|
519
|
+
/*
|
|
520
|
+
* `salidium open` starts the daemon on its way to the browser, so offering both was
|
|
521
|
+
* offering the same outcome twice. Starting is the whole of what this state needs.
|
|
522
|
+
*/
|
|
523
|
+
addAction("Start Salidium", #selector(startSalidium), key: "o")
|
|
218
524
|
} else {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
525
|
+
addAction(situation.explain ? "See What Happened" : "Open Salidium",
|
|
526
|
+
#selector(openSalidium), key: "o")
|
|
527
|
+
if snapshot.collectionPaused {
|
|
528
|
+
addAction("Resume Recording", #selector(resumeCollection))
|
|
529
|
+
} else {
|
|
530
|
+
addAction("Pause Recording", #selector(pauseCollection))
|
|
531
|
+
}
|
|
532
|
+
/*
|
|
533
|
+
* Draining is part of collection: `drainSpool` returns immediately while paused, and
|
|
534
|
+
* an empty queue is the steady state rather than a stage work passes through. The
|
|
535
|
+
* action appears only when there is something for it to do.
|
|
536
|
+
*/
|
|
537
|
+
if runningAction == nil, !snapshot.collectionPaused,
|
|
538
|
+
let files = snapshot.queueFiles, files > 0 {
|
|
539
|
+
addAction("Store \(files) Waiting \(files == 1 ? "File" : "Files") Now",
|
|
540
|
+
#selector(drainQueue))
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/*
|
|
545
|
+
* Nothing was measured while the daemon is down, and a row that says "Unavailable" twice
|
|
546
|
+
* is worse than no row: it spends space to report the absence the line above already gave.
|
|
547
|
+
*/
|
|
548
|
+
if snapshot.pid != nil {
|
|
549
|
+
menu.addItem(.separator())
|
|
550
|
+
addStorage()
|
|
551
|
+
menu.addItem(.separator())
|
|
552
|
+
addAction("Local Operations…", #selector(openSalidium))
|
|
553
|
+
addAction("Stop Salidium", #selector(stopSalidium))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/*
|
|
557
|
+
* Its own group because it is a different kind of off. Stopping is undone by the item
|
|
558
|
+
* directly above it; this one removes the menu and needs a terminal to come back.
|
|
559
|
+
*/
|
|
227
560
|
menu.addItem(.separator())
|
|
228
|
-
|
|
229
|
-
addAction("Open Data Folder", #selector(openDataFolder), enabled: true)
|
|
230
|
-
addAction("Turn Off Always-On Mode…", #selector(turnOffAlwaysOn), enabled: true)
|
|
561
|
+
addAction("Turn Off Always-On Mode…", #selector(turnOffAlwaysOn))
|
|
231
562
|
}
|
|
232
563
|
|
|
233
|
-
private func
|
|
234
|
-
let
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
case .offline:
|
|
247
|
-
symbol = "circle.slash"
|
|
248
|
-
color = .secondaryLabelColor
|
|
564
|
+
private func addStorage() {
|
|
565
|
+
let mark = snapshot.warnAtBytes ?? 0
|
|
566
|
+
var value = "Unavailable"
|
|
567
|
+
var fraction: Double?
|
|
568
|
+
var parts: [String] = [snapshot.retention]
|
|
569
|
+
if let total = snapshot.storeBytes {
|
|
570
|
+
value = mark > 0
|
|
571
|
+
? "\(Self.byteLabel(total)) of \(Self.byteLabel(mark))"
|
|
572
|
+
: Self.byteLabel(total)
|
|
573
|
+
if mark > 0 {
|
|
574
|
+
fraction = min(Double(total) / Double(mark), 1)
|
|
575
|
+
parts.append("warns at \(Self.byteLabel(mark))")
|
|
576
|
+
}
|
|
249
577
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
578
|
+
/*
|
|
579
|
+
* "At this rate" is the honest form. The rate is sampled over an hour, and an hour of
|
|
580
|
+
* heavy agent use does not continue overnight, so the projection is a shape and not a date.
|
|
581
|
+
*/
|
|
582
|
+
if let rate = snapshot.storageBytesPerMinute, rate > 0 {
|
|
583
|
+
parts.append("about \(Self.byteLabel(Int(rate * 60 * 24))) a day at this rate")
|
|
584
|
+
}
|
|
585
|
+
let subtitle = parts.joined(separator: " · ")
|
|
586
|
+
|
|
587
|
+
let view = StorageView(frame: NSRect(x: 0, y: 0, width: 300, height: 50))
|
|
588
|
+
view.apply(value: value, subtitle: subtitle, fraction: fraction)
|
|
589
|
+
view.setAccessibilityLabel("On this Mac, \(value). \(subtitle).")
|
|
590
|
+
let item = NSMenuItem()
|
|
591
|
+
item.view = view
|
|
592
|
+
item.isEnabled = false
|
|
593
|
+
menu.addItem(item)
|
|
254
594
|
}
|
|
255
595
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
596
|
+
/*
|
|
597
|
+
* The mark, drawn as a template image so the system owns its appearance.
|
|
598
|
+
*
|
|
599
|
+
* This was `checkmark.circle.fill` with `isTemplate = false` and an explicit tint, which is
|
|
600
|
+
* two problems at once. A green checkmark is the most generic glyph in the menu bar and says
|
|
601
|
+
* nothing about which app it belongs to, and turning off template mode opts the glyph out of
|
|
602
|
+
* the light, dark and selected rendering the system would otherwise do for it. The HIG asks
|
|
603
|
+
* for black and clear shapes for exactly that reason.
|
|
604
|
+
*
|
|
605
|
+
* Status rides along as a badge, and every state differs in shape as well as colour: a bare
|
|
606
|
+
* mark, a pause glyph, a dot, a dimmed mark. Colour confirms the state and never carries it
|
|
607
|
+
* alone. The geometry is the canonical mark from `assets/brand/salidium-mark.svg` on its
|
|
608
|
+
* 64 x 44 viewBox; the packaged helper is a single compiled file with no bundle to hold an
|
|
609
|
+
* asset, so the paths live here.
|
|
610
|
+
*/
|
|
611
|
+
private static func markPath() -> NSBezierPath {
|
|
612
|
+
let path = NSBezierPath()
|
|
613
|
+
path.move(to: NSPoint(x: 12, y: 1.1))
|
|
614
|
+
path.curve(to: NSPoint(x: 26.1, y: 2.6),
|
|
615
|
+
controlPoint1: NSPoint(x: 17.2, y: -0.2), controlPoint2: NSPoint(x: 21.6, y: 0.1))
|
|
616
|
+
path.line(to: NSPoint(x: 35.2, y: 7.6))
|
|
617
|
+
path.curve(to: NSPoint(x: 38.2, y: 19.5),
|
|
618
|
+
controlPoint1: NSPoint(x: 40.6, y: 10.6), controlPoint2: NSPoint(x: 41.8, y: 15.4))
|
|
619
|
+
path.line(to: NSPoint(x: 35.5, y: 22.4))
|
|
620
|
+
path.curve(to: NSPoint(x: 36.3, y: 27.6),
|
|
621
|
+
controlPoint1: NSPoint(x: 34.1, y: 23.9), controlPoint2: NSPoint(x: 34.5, y: 26.3))
|
|
622
|
+
path.line(to: NSPoint(x: 39.8, y: 30.4))
|
|
623
|
+
path.curve(to: NSPoint(x: 41.1, y: 40.2),
|
|
624
|
+
controlPoint1: NSPoint(x: 43.0, y: 32.9), controlPoint2: NSPoint(x: 43.7, y: 37.1))
|
|
625
|
+
path.curve(to: NSPoint(x: 34.8, y: 42.9),
|
|
626
|
+
controlPoint1: NSPoint(x: 39.6, y: 42.0), controlPoint2: NSPoint(x: 37.5, y: 42.9))
|
|
627
|
+
path.line(to: NSPoint(x: 14.1, y: 42.9))
|
|
628
|
+
path.curve(to: NSPoint(x: 5.1, y: 36.9),
|
|
629
|
+
controlPoint1: NSPoint(x: 9.8, y: 42.9), controlPoint2: NSPoint(x: 6.8, y: 40.8))
|
|
630
|
+
path.line(to: NSPoint(x: 0.9, y: 27.1))
|
|
631
|
+
path.curve(to: NSPoint(x: 1.0, y: 18.6),
|
|
632
|
+
controlPoint1: NSPoint(x: -0.3, y: 24.4), controlPoint2: NSPoint(x: 0.0, y: 21.3))
|
|
633
|
+
path.line(to: NSPoint(x: 5.3, y: 7.3))
|
|
634
|
+
path.curve(to: NSPoint(x: 12, y: 1.1),
|
|
635
|
+
controlPoint1: NSPoint(x: 6.5, y: 4.2), controlPoint2: NSPoint(x: 8.8, y: 2.0))
|
|
636
|
+
path.close()
|
|
637
|
+
|
|
638
|
+
path.move(to: NSPoint(x: 46.3, y: 14.1))
|
|
639
|
+
path.curve(to: NSPoint(x: 52.5, y: 13.7),
|
|
640
|
+
controlPoint1: NSPoint(x: 48.1, y: 12.7), controlPoint2: NSPoint(x: 50.5, y: 12.5))
|
|
641
|
+
path.line(to: NSPoint(x: 58.1, y: 17.0))
|
|
642
|
+
path.curve(to: NSPoint(x: 63.4, y: 25.4),
|
|
643
|
+
controlPoint1: NSPoint(x: 61.2, y: 18.8), controlPoint2: NSPoint(x: 62.9, y: 21.7))
|
|
644
|
+
path.line(to: NSPoint(x: 64.0, y: 34.8))
|
|
645
|
+
path.curve(to: NSPoint(x: 57.5, y: 42.9),
|
|
646
|
+
controlPoint1: NSPoint(x: 64.3, y: 39.4), controlPoint2: NSPoint(x: 61.9, y: 42.9))
|
|
647
|
+
path.line(to: NSPoint(x: 50.4, y: 42.9))
|
|
648
|
+
path.curve(to: NSPoint(x: 44.2, y: 35.9),
|
|
649
|
+
controlPoint1: NSPoint(x: 46.3, y: 42.9), controlPoint2: NSPoint(x: 43.8, y: 39.8))
|
|
650
|
+
path.curve(to: NSPoint(x: 40.8, y: 28.2),
|
|
651
|
+
controlPoint1: NSPoint(x: 44.6, y: 32.7), controlPoint2: NSPoint(x: 43.4, y: 30.1))
|
|
652
|
+
path.curve(to: NSPoint(x: 39.5, y: 20.1),
|
|
653
|
+
controlPoint1: NSPoint(x: 38.1, y: 26.2), controlPoint2: NSPoint(x: 37.7, y: 22.7))
|
|
654
|
+
path.close()
|
|
655
|
+
return path
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/*
|
|
659
|
+
* The five states differ in shape, not colour, and that is forced rather than chosen: the
|
|
660
|
+
* system paints a template image in one colour of its own choosing, so an amber badge and a
|
|
661
|
+
* red one would arrive identical. It is also what the HIG asks for anyway, since colour alone
|
|
662
|
+
* is not allowed to carry meaning. Shape does the work and the menu carries the words.
|
|
663
|
+
*/
|
|
664
|
+
private enum IconState {
|
|
665
|
+
case recording
|
|
666
|
+
case paused
|
|
667
|
+
/// Recording, but Salidium has something to report.
|
|
668
|
+
case attention
|
|
669
|
+
/// Reachable and not recording, which a slash says the way a dot cannot.
|
|
670
|
+
case notRecording
|
|
671
|
+
case notRunning
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private static func statusImage(for state: IconState) -> NSImage {
|
|
675
|
+
let markHeight: CGFloat = 15
|
|
676
|
+
let scale = markHeight / 44
|
|
677
|
+
let markWidth = 64 * scale
|
|
678
|
+
let badge: CGFloat = (state == .paused || state == .attention) ? 7 : 0
|
|
679
|
+
let size = NSSize(width: markWidth + badge * 0.45, height: markHeight + badge * 0.3)
|
|
680
|
+
|
|
681
|
+
let mark = NSImage(size: size, flipped: true) { _ in
|
|
682
|
+
let transform = NSAffineTransform()
|
|
683
|
+
transform.scaleX(by: scale, yBy: scale)
|
|
684
|
+
transform.concat()
|
|
685
|
+
NSColor.black.withAlphaComponent(state == .notRunning ? 0.4 : 1).setFill()
|
|
686
|
+
markPath().fill()
|
|
687
|
+
return true
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if state == .recording || state == .notRunning {
|
|
691
|
+
mark.isTemplate = true
|
|
692
|
+
return mark
|
|
259
693
|
}
|
|
260
|
-
|
|
694
|
+
|
|
695
|
+
/*
|
|
696
|
+
* Badges and the slash are punched out of the glyph before they are drawn, so the shape
|
|
697
|
+
* stays legible over a busy menu bar instead of merging into the mark behind it.
|
|
698
|
+
* Everything stays black and clear, which is what lets the whole image be a template.
|
|
699
|
+
*/
|
|
700
|
+
let drawn = NSImage(size: size, flipped: false) { rect in
|
|
701
|
+
mark.draw(in: NSRect(x: 0, y: rect.height - markHeight,
|
|
702
|
+
width: markWidth, height: markHeight))
|
|
703
|
+
NSColor.black.setFill()
|
|
704
|
+
NSColor.black.setStroke()
|
|
705
|
+
|
|
706
|
+
if state == .notRecording {
|
|
707
|
+
let slash = NSBezierPath()
|
|
708
|
+
slash.move(to: NSPoint(x: 1.5, y: 1.5))
|
|
709
|
+
slash.line(to: NSPoint(x: markWidth - 1.5, y: rect.height - 1.5))
|
|
710
|
+
slash.lineCapStyle = .round
|
|
711
|
+
NSGraphicsContext.current?.compositingOperation = .clear
|
|
712
|
+
slash.lineWidth = 4.4
|
|
713
|
+
slash.stroke()
|
|
714
|
+
NSGraphicsContext.current?.compositingOperation = .sourceOver
|
|
715
|
+
slash.lineWidth = 2.0
|
|
716
|
+
slash.stroke()
|
|
717
|
+
return true
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
let centre = NSPoint(x: rect.width - badge / 2, y: rect.height - badge / 2)
|
|
721
|
+
NSGraphicsContext.current?.compositingOperation = .clear
|
|
722
|
+
NSBezierPath(ovalIn: NSRect(x: centre.x - badge / 2 - 1.4, y: centre.y - badge / 2 - 1.4,
|
|
723
|
+
width: badge + 2.8, height: badge + 2.8)).fill()
|
|
724
|
+
NSGraphicsContext.current?.compositingOperation = .sourceOver
|
|
725
|
+
if state == .paused {
|
|
726
|
+
// Two bars read as paused at any size, where a second dot would read as noise.
|
|
727
|
+
let barWidth = badge * 0.26
|
|
728
|
+
let barHeight = badge * 0.82
|
|
729
|
+
NSRect(x: centre.x - badge * 0.36, y: centre.y - barHeight / 2,
|
|
730
|
+
width: barWidth, height: barHeight).fill()
|
|
731
|
+
NSRect(x: centre.x + badge * 0.1, y: centre.y - barHeight / 2,
|
|
732
|
+
width: barWidth, height: barHeight).fill()
|
|
733
|
+
} else {
|
|
734
|
+
NSBezierPath(ovalIn: NSRect(x: centre.x - badge / 2, y: centre.y - badge / 2,
|
|
735
|
+
width: badge, height: badge)).fill()
|
|
736
|
+
}
|
|
737
|
+
return true
|
|
738
|
+
}
|
|
739
|
+
drawn.isTemplate = true
|
|
740
|
+
return drawn
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/*
|
|
744
|
+
* A slow fade of the mark itself while a command runs, and nothing else.
|
|
745
|
+
*
|
|
746
|
+
* Not a spinner: a spinner in the menu bar is a second glyph competing with the one that says
|
|
747
|
+
* which app this is, and it would be on screen for a thirty second drain. Fading the mark that
|
|
748
|
+
* is already there says "working" using the space it already occupies, and it is the same
|
|
749
|
+
* device Time Machine uses for the same reason.
|
|
750
|
+
*
|
|
751
|
+
* Reduce Motion turns it off rather than substituting something: the label on the next open is
|
|
752
|
+
* the substance and it is still there. Motion is the convenience, so it is the part that goes.
|
|
753
|
+
*/
|
|
754
|
+
private func startPulse() {
|
|
755
|
+
guard pulseTimer == nil,
|
|
756
|
+
!NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return }
|
|
757
|
+
pulsePhase = 0
|
|
758
|
+
pulseTimer = Timer.scheduledTimer(withTimeInterval: 0.28, repeats: true) { [weak self] _ in
|
|
759
|
+
guard let self = self else { return }
|
|
760
|
+
self.pulsePhase = (self.pulsePhase + 1) % 4
|
|
761
|
+
self.applyIconAlpha()
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private func stopPulse() {
|
|
766
|
+
pulseTimer?.invalidate()
|
|
767
|
+
pulseTimer = nil
|
|
768
|
+
pulsePhase = 0
|
|
769
|
+
applyIconAlpha()
|
|
261
770
|
}
|
|
262
771
|
|
|
263
|
-
private func
|
|
264
|
-
|
|
265
|
-
|
|
772
|
+
private func applyIconAlpha() {
|
|
773
|
+
// A triangle wave, so the fade reads as breathing rather than as a blink.
|
|
774
|
+
let steps: [CGFloat] = [1.0, 0.72, 0.45, 0.72]
|
|
775
|
+
statusItem.button?.alphaValue = pulseTimer == nil ? 1.0 : steps[pulsePhase]
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private func configureStatusIcon(situation: Situation) {
|
|
779
|
+
let state: IconState
|
|
780
|
+
let described: String
|
|
781
|
+
switch situation.tone {
|
|
782
|
+
case .offline:
|
|
783
|
+
state = .notRunning
|
|
784
|
+
described = "Salidium, not running"
|
|
785
|
+
case .critical:
|
|
786
|
+
state = .notRecording
|
|
787
|
+
described = "Salidium, not recording"
|
|
788
|
+
case .attention:
|
|
789
|
+
state = snapshot.collectionPaused ? .paused : .attention
|
|
790
|
+
described = snapshot.collectionPaused
|
|
791
|
+
? "Salidium, paused" : "Salidium, needs attention"
|
|
792
|
+
case .healthy:
|
|
793
|
+
state = .recording
|
|
794
|
+
described = "Salidium, recording"
|
|
795
|
+
}
|
|
796
|
+
let image = Self.statusImage(for: state)
|
|
797
|
+
image.accessibilityDescription = described
|
|
798
|
+
statusItem.button?.image = image
|
|
799
|
+
statusItem.button?.contentTintColor = nil
|
|
800
|
+
statusItem.button?.toolTip = "\(described). \(situation.headline)."
|
|
801
|
+
statusItem.button?.setAccessibilityLabel(described)
|
|
266
802
|
}
|
|
267
803
|
|
|
268
804
|
/*
|
|
@@ -281,7 +817,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
281
817
|
return String(format: "%.2f GB", value / (1000 * 1000 * 1000))
|
|
282
818
|
}
|
|
283
819
|
|
|
284
|
-
private func addLabel(_ title: String, emphasized: Bool = false) {
|
|
820
|
+
private func addLabel(_ title: String, emphasized: Bool = false, secondary: Bool = false) {
|
|
285
821
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
286
822
|
item.isEnabled = false
|
|
287
823
|
if emphasized {
|
|
@@ -289,21 +825,35 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
289
825
|
string: title,
|
|
290
826
|
attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)]
|
|
291
827
|
)
|
|
828
|
+
} else if secondary {
|
|
829
|
+
item.attributedTitle = NSAttributedString(
|
|
830
|
+
string: title,
|
|
831
|
+
attributes: [
|
|
832
|
+
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
|
833
|
+
.foregroundColor: NSColor.secondaryLabelColor,
|
|
834
|
+
]
|
|
835
|
+
)
|
|
292
836
|
}
|
|
293
837
|
menu.addItem(item)
|
|
294
838
|
}
|
|
295
839
|
|
|
296
|
-
private func addAction(_ title: String,
|
|
297
|
-
_ action: Selector,
|
|
298
|
-
key: String = "",
|
|
299
|
-
enabled: Bool) {
|
|
840
|
+
private func addAction(_ title: String, _ action: Selector, key: String = "") {
|
|
300
841
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
|
|
301
842
|
item.target = self
|
|
302
|
-
item.isEnabled =
|
|
843
|
+
item.isEnabled = true
|
|
303
844
|
menu.addItem(item)
|
|
304
845
|
}
|
|
305
846
|
|
|
306
|
-
private func runCLI(_ arguments: [String],
|
|
847
|
+
private func runCLI(_ arguments: [String],
|
|
848
|
+
label: String? = nil,
|
|
849
|
+
failed: String? = nil,
|
|
850
|
+
refreshAfter: Bool = true) {
|
|
851
|
+
lastFailure = nil
|
|
852
|
+
if let label = label {
|
|
853
|
+
runningAction = label
|
|
854
|
+
startPulse()
|
|
855
|
+
rebuildMenu()
|
|
856
|
+
}
|
|
307
857
|
let process = Process()
|
|
308
858
|
process.executableURL = URL(fileURLWithPath: configuration.node)
|
|
309
859
|
process.arguments = [configuration.cli] + arguments
|
|
@@ -321,9 +871,15 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
321
871
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
322
872
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
323
873
|
guard let self = self else { return }
|
|
874
|
+
self.runningAction = nil
|
|
875
|
+
self.stopPulse()
|
|
324
876
|
if finished.terminationStatus != 0 {
|
|
325
|
-
|
|
326
|
-
|
|
877
|
+
let reason = detail?.isEmpty == false
|
|
878
|
+
? detail!
|
|
879
|
+
: "The command exited with status \(finished.terminationStatus)."
|
|
880
|
+
self.lastFailure = Self.shortLabel(reason)
|
|
881
|
+
self.showError("Salidium could not \(failed ?? "complete that command")",
|
|
882
|
+
detail: reason)
|
|
327
883
|
}
|
|
328
884
|
self.refresh()
|
|
329
885
|
}
|
|
@@ -338,27 +894,49 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
338
894
|
}
|
|
339
895
|
}
|
|
340
896
|
|
|
897
|
+
/*
|
|
898
|
+
* Names the thing that failed, says why in the daemon's own words, and offers the log rather
|
|
899
|
+
* than printing a path into the body. The old text was "Salidium could not complete that
|
|
900
|
+
* command" over a sentence ending in an absolute path inside a state directory, which told a
|
|
901
|
+
* reader neither what they had asked for nor what to do next.
|
|
902
|
+
*/
|
|
341
903
|
private func showError(_ message: String, detail: String) {
|
|
342
904
|
NSApp.activate(ignoringOtherApps: true)
|
|
343
905
|
let alert = NSAlert()
|
|
344
906
|
alert.alertStyle = .warning
|
|
345
907
|
alert.messageText = message
|
|
346
908
|
alert.informativeText = detail
|
|
347
|
-
alert.
|
|
909
|
+
alert.addButton(withTitle: "OK")
|
|
910
|
+
alert.addButton(withTitle: "Show Log")
|
|
911
|
+
if alert.runModal() == .alertSecondButtonReturn { revealLog() }
|
|
348
912
|
}
|
|
349
913
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
914
|
+
/// The startup log when there is one, because a refused start is what writes it, else the daemon log.
|
|
915
|
+
private func revealLog() {
|
|
916
|
+
let home = URL(fileURLWithPath: configuration.home, isDirectory: true)
|
|
917
|
+
for name in ["daemon-startup.log", "daemon.log"] {
|
|
918
|
+
let candidate = home.appendingPathComponent(name)
|
|
919
|
+
if FileManager.default.fileExists(atPath: candidate.path) {
|
|
920
|
+
NSWorkspace.shared.activateFileViewerSelecting([candidate])
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
NSWorkspace.shared.open(home)
|
|
360
925
|
}
|
|
361
926
|
|
|
927
|
+
/*
|
|
928
|
+
* `--no-resume` because this menu only offers actions after a live health response, so the
|
|
929
|
+
* dead-daemon case implicit resume exists to recover from cannot apply here, and because
|
|
930
|
+
* `runCLI` sends stdout to the null device, so a resume would happen with nothing to show it.
|
|
931
|
+
* Opening the interface is not a request to start recording again.
|
|
932
|
+
*/
|
|
933
|
+
@objc private func openSalidium() { runCLI(["open", "--no-resume"]) }
|
|
934
|
+
@objc private func startSalidium() { runCLI(["start"], label: "Starting", failed: "start") }
|
|
935
|
+
@objc private func pauseCollection() { runCLI(["pause", "--quiet"], label: "Pausing", failed: "pause recording") }
|
|
936
|
+
@objc private func resumeCollection() { runCLI(["resume", "--quiet"], label: "Resuming", failed: "resume recording") }
|
|
937
|
+
@objc private func drainQueue() { runCLI(["maintenance", "drain", "--quiet"], label: "Storing waiting work", failed: "store the waiting work") }
|
|
938
|
+
@objc private func stopSalidium() { runCLI(["stop", "--quiet"], label: "Stopping", failed: "stop") }
|
|
939
|
+
|
|
362
940
|
@objc private func turnOffAlwaysOn() {
|
|
363
941
|
NSApp.activate(ignoringOtherApps: true)
|
|
364
942
|
let alert = NSAlert()
|