salidium 0.4.0 → 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 +686 -98
- package/bundle/salidium.mjs +384 -38
- package/bundle/ui/assets/{index-DdBwm8Cm.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-U_KufSwb.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!))"
|
|
@@ -170,9 +356,9 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
170
356
|
}
|
|
171
357
|
|
|
172
358
|
private static func retentionLabel(_ value: Any) -> String {
|
|
173
|
-
if let text = value as? String { return text == "forever" ? "
|
|
174
|
-
if let days = integer(value) { return "\(days) days" }
|
|
175
|
-
return "
|
|
359
|
+
if let text = value as? String { return text == "forever" ? "kept forever" : text }
|
|
360
|
+
if let days = integer(value) { return "kept \(days) days" }
|
|
361
|
+
return "retention unavailable"
|
|
176
362
|
}
|
|
177
363
|
|
|
178
364
|
private static func titleCase(_ value: String) -> String {
|
|
@@ -182,96 +368,456 @@ 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) + "…"
|
|
188
384
|
}
|
|
189
385
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
412
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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)
|
|
200
433
|
}
|
|
201
|
-
|
|
202
|
-
|
|
434
|
+
return restingSituation()
|
|
435
|
+
}
|
|
203
436
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private func rebuildMenu() {
|
|
500
|
+
menu.removeAllItems()
|
|
501
|
+
let situation = situation()
|
|
502
|
+
configureStatusIcon(situation: situation)
|
|
503
|
+
|
|
504
|
+
// No em dash in anything the product says. `printedVoice.test.ts` reads this file for them.
|
|
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
|
+
}
|
|
210
516
|
|
|
211
517
|
menu.addItem(.separator())
|
|
212
|
-
addAction("Open Salidium", #selector(openSalidium), key: "o", enabled: true)
|
|
213
518
|
if snapshot.pid == nil {
|
|
214
|
-
|
|
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")
|
|
215
524
|
} else {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
*/
|
|
224
560
|
menu.addItem(.separator())
|
|
225
|
-
|
|
226
|
-
addAction("Open Data Folder", #selector(openDataFolder), enabled: true)
|
|
227
|
-
addAction("Turn Off Always-On Mode…", #selector(turnOffAlwaysOn), enabled: true)
|
|
561
|
+
addAction("Turn Off Always-On Mode…", #selector(turnOffAlwaysOn))
|
|
228
562
|
}
|
|
229
563
|
|
|
230
|
-
private func
|
|
231
|
-
let
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
case .offline:
|
|
244
|
-
symbol = "circle.slash"
|
|
245
|
-
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
|
+
}
|
|
246
577
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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)
|
|
594
|
+
}
|
|
595
|
+
|
|
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
|
|
693
|
+
}
|
|
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
|
|
251
741
|
}
|
|
252
742
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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()
|
|
256
762
|
}
|
|
257
|
-
return "\(files) \(files == 1 ? "file" : "files") · \(Self.byteLabel(bytes))"
|
|
258
763
|
}
|
|
259
764
|
|
|
260
|
-
private func
|
|
261
|
-
|
|
262
|
-
|
|
765
|
+
private func stopPulse() {
|
|
766
|
+
pulseTimer?.invalidate()
|
|
767
|
+
pulseTimer = nil
|
|
768
|
+
pulsePhase = 0
|
|
769
|
+
applyIconAlpha()
|
|
770
|
+
}
|
|
771
|
+
|
|
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]
|
|
263
776
|
}
|
|
264
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)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/*
|
|
805
|
+
* The same rendering as `formatBytes` in `@salidium/core`, which the app and the CLI use.
|
|
806
|
+
*
|
|
807
|
+
* Decimal, matching Finder, but not `ByteCountFormatter`: its adaptive mode renders one byte as
|
|
808
|
+
* "0 KB" and 999999 as "1 MB", and the same function formats rates where that loses the value.
|
|
809
|
+
* `byteLabelVectors` in that module pins the cases, and `macosService.test.ts` checks them
|
|
810
|
+
* against this function, because Swift cannot import it.
|
|
811
|
+
*/
|
|
265
812
|
private static func byteLabel(_ bytes: Int) -> String {
|
|
266
|
-
let
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return formatter.string(fromByteCount: Int64(bytes))
|
|
813
|
+
let value = Double(bytes)
|
|
814
|
+
if bytes < 1000 { return "\(bytes) B" }
|
|
815
|
+
if bytes < 1000 * 1000 { return String(format: "%.1f KB", value / 1000) }
|
|
816
|
+
if bytes < 1000 * 1000 * 1000 { return String(format: "%.1f MB", value / (1000 * 1000)) }
|
|
817
|
+
return String(format: "%.2f GB", value / (1000 * 1000 * 1000))
|
|
272
818
|
}
|
|
273
819
|
|
|
274
|
-
private func addLabel(_ title: String, emphasized: Bool = false) {
|
|
820
|
+
private func addLabel(_ title: String, emphasized: Bool = false, secondary: Bool = false) {
|
|
275
821
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
276
822
|
item.isEnabled = false
|
|
277
823
|
if emphasized {
|
|
@@ -279,21 +825,35 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
279
825
|
string: title,
|
|
280
826
|
attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)]
|
|
281
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
|
+
)
|
|
282
836
|
}
|
|
283
837
|
menu.addItem(item)
|
|
284
838
|
}
|
|
285
839
|
|
|
286
|
-
private func addAction(_ title: String,
|
|
287
|
-
_ action: Selector,
|
|
288
|
-
key: String = "",
|
|
289
|
-
enabled: Bool) {
|
|
840
|
+
private func addAction(_ title: String, _ action: Selector, key: String = "") {
|
|
290
841
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
|
|
291
842
|
item.target = self
|
|
292
|
-
item.isEnabled =
|
|
843
|
+
item.isEnabled = true
|
|
293
844
|
menu.addItem(item)
|
|
294
845
|
}
|
|
295
846
|
|
|
296
|
-
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
|
+
}
|
|
297
857
|
let process = Process()
|
|
298
858
|
process.executableURL = URL(fileURLWithPath: configuration.node)
|
|
299
859
|
process.arguments = [configuration.cli] + arguments
|
|
@@ -311,9 +871,15 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
311
871
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
312
872
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
313
873
|
guard let self = self else { return }
|
|
874
|
+
self.runningAction = nil
|
|
875
|
+
self.stopPulse()
|
|
314
876
|
if finished.terminationStatus != 0 {
|
|
315
|
-
|
|
316
|
-
|
|
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)
|
|
317
883
|
}
|
|
318
884
|
self.refresh()
|
|
319
885
|
}
|
|
@@ -328,27 +894,49 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
|
|
|
328
894
|
}
|
|
329
895
|
}
|
|
330
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
|
+
*/
|
|
331
903
|
private func showError(_ message: String, detail: String) {
|
|
332
904
|
NSApp.activate(ignoringOtherApps: true)
|
|
333
905
|
let alert = NSAlert()
|
|
334
906
|
alert.alertStyle = .warning
|
|
335
907
|
alert.messageText = message
|
|
336
908
|
alert.informativeText = detail
|
|
337
|
-
alert.
|
|
909
|
+
alert.addButton(withTitle: "OK")
|
|
910
|
+
alert.addButton(withTitle: "Show Log")
|
|
911
|
+
if alert.runModal() == .alertSecondButtonReturn { revealLog() }
|
|
338
912
|
}
|
|
339
913
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
|
350
925
|
}
|
|
351
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
|
+
|
|
352
940
|
@objc private func turnOffAlwaysOn() {
|
|
353
941
|
NSApp.activate(ignoringOtherApps: true)
|
|
354
942
|
let alert = NSAlert()
|