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.
@@ -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 collection = "Unavailable"
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 activeAlerts = 0
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.squareLength)
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.collection = state == "active" ? "On" : "Paused"
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.activeAlerts = active.count
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
- return value.count > limit ? String(value.prefix(limit - 1)) + "…" : value
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
- configureStatusIcon()
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
- 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)") }
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
- addAction("Start Salidium", #selector(startSalidium), enabled: true)
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
- 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)
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
- 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)
561
+ addAction("Turn Off Always-On Mode…", #selector(turnOffAlwaysOn))
231
562
  }
232
563
 
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
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
- let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Salidium \(snapshot.health.rawValue)")
251
- image?.isTemplate = false
252
- statusItem.button?.image = image
253
- statusItem.button?.contentTintColor = color
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
- private func queueLabel() -> String {
257
- guard let files = snapshot.queueFiles, let bytes = snapshot.queueBytes else {
258
- return "Unavailable"
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
- return "\(files) \(files == 1 ? "file" : "files") · \(Self.byteLabel(bytes))"
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 storageLabel() -> String {
264
- guard let bytes = snapshot.storeBytes else { return "Unavailable" }
265
- return "\(Self.byteLabel(bytes)) · \(snapshot.retention)"
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 = enabled
843
+ item.isEnabled = true
303
844
  menu.addItem(item)
304
845
  }
305
846
 
306
- private func runCLI(_ arguments: [String], refreshAfter: Bool = true) {
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
- self.showError("Salidium could not complete that command",
326
- detail: detail?.isEmpty == false ? detail! : "The command exited with status \(finished.terminationStatus).")
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.runModal()
909
+ alert.addButton(withTitle: "OK")
910
+ alert.addButton(withTitle: "Show Log")
911
+ if alert.runModal() == .alertSecondButtonReturn { revealLog() }
348
912
  }
349
913
 
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))
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()