Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

Commit b60200227d

b60200227d60f1f9f51962e43ec433bfc9e8cfb5

parent: df0366a9b0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 17:25 UTC

Add menubar-pet, the Swift menu-bar face

A sibling SwiftPM package, kept out of macos-collector because that package is
the hardware collector and this is a renderer. scripts/bundle.sh assembles a
.app around the executable with LSUIElement set, so there is no Dock icon and
no window; SwiftPM cannot emit a bundle and an .xcodeproj would stop swift
build being the whole story.

The app holds no logic worth testing. It runs pet-life on a five-second timer
and draws the result — no socket, no wire decoding. One decoder, in Rust, is
the point of the arrangement.

Deviates from the design on one point, in its favour: PetKit is a library
rather than everything living in the executable, so the contract with pet-life
is testable, and it is tested against the exact bytes pet-life's own test pins.
The design said the Swift side would go untested; the Rust-to-Swift contract
has drifted twice in this project and both times a pinned fixture caught it, so
this is the one part worth the target. The null case a plain String rather than
String? would break on is covered, since a dead pet is exactly the state the
app has to render.

The hook gates both Swift packages and asserts the bundle assembles. A package
outside the gate rots.

menubar-pet/.gitignore mirrors macos-collector's: without it, adding the
package would have committed 2,601 build artifacts.

Closes #18
.githooks/pre-push +17 −1
@@ -26,11 +26,27 @@ cargo test --locked
2626echo "pre-push: cargo clippy" >&2
2727cargo clippy --all-targets --locked -- -D warnings
2828
29# macos-collector is a Swift package; its tests only run on macOS.
29# The Swift packages are siblings of the cargo workspace; their tests only run
30# on macOS. Both are gated here, or a package outside the gate quietly rots.
3031case "$(uname -s)" in
3132Darwin)
3233 echo "pre-push: swift test (macos-collector)" >&2
3334 swift test --package-path "$root/macos-collector"
35
36 echo "pre-push: swift test (menubar-pet)" >&2
37 swift test --package-path "$root/menubar-pet"
38
39 # The app is only an app if the bundle assembles: LSUIElement is what
40 # keeps it out of the Dock, and a broken Info.plist is invisible until
41 # someone launches it.
42 echo "pre-push: menubar-pet bundle" >&2
43 swift build -c release --package-path "$root/menubar-pet" >/dev/null
44 bundle_dir=$(mktemp -d)
45 "$root/menubar-pet/scripts/bundle.sh" \
46 "$root/menubar-pet/.build/release/menubar-pet" "$bundle_dir" >/dev/null
47 plutil -extract LSUIElement raw \
48 "$bundle_dir/menubar-pet.app/Contents/Info.plist" >/dev/null
49 rm -rf "$bundle_dir"
3450 ;;
3551*)
3652 echo "pre-push: not macOS, skipping swift test" >&2
menubar-pet/.gitignore added +1
@@ -0,0 +1 @@
1.build/
menubar-pet/Package.swift added +24
@@ -0,0 +1,24 @@
1// swift-tools-version:5.9
2import PackageDescription
3
4// The menu-bar face. A *sibling* of the cargo workspace and of macos-collector,
5// for the same reason: SwiftPM and cargo do not share a build system.
6//
7// This package holds no logic worth testing. Ageing, death and the cemetery all
8// live in the `pet-life` Rust crate, which this app runs on a timer and draws.
9// Keeping it that way is deliberate the logic belongs where the test suite
10// and the privacy gate are.
11let package = Package(
12 name: "menubar-pet",
13 platforms: [.macOS(.v13)],
14 products: [
15 .executable(name: "menubar-pet", targets: ["menubar-pet"]),
16 ],
17 targets: [
18 // The contract with pet-life, in a library so it can be tested.
19 .target(name: "PetKit"),
20 // Thin app: status item, timer, menu.
21 .executableTarget(name: "menubar-pet", dependencies: ["PetKit"]),
22 .testTarget(name: "PetKitTests", dependencies: ["PetKit"]),
23 ]
24)
menubar-pet/Sources/PetKit/PetState.swift added +37
@@ -0,0 +1,37 @@
1import Foundation
2
3// The models for what `pet-life` prints, in a library so the contract can be
4// tested. Field names match its JSON exactly, and that JSON is pinned to exact
5// bytes by a test on the Rust side this is the other half of the same
6// contract. The wire format between the daemon and the collector has drifted
7// twice in this project; both times a pinned fixture caught it.
8
9/// What `pet-life` prints. Field names match its JSON exactly; that contract is
10/// pinned to exact bytes by a test on the Rust side.
11public struct Grave: Decodable {
12 public init(name: String, generation: Int, lived_days: Double) {
13 self.name = name; self.generation = generation; self.lived_days = lived_days
14 }
15
16 public let name: String
17 public let generation: Int
18 public let lived_days: Double
19}
20
21public struct PetState: Decodable {
22 public let alive: Bool
23 public let name: String?
24 public let generation: Int?
25 public let stage: String
26 public let face: String
27 public let age_days: Double
28 public let quiet_days: Double
29 public let cemetery: [Grave]
30}
31
32public extension PetState {
33 /// Decode what `pet-life` printed.
34 static func decode(_ data: Data) throws -> PetState {
35 try JSONDecoder().decode(PetState.self, from: data)
36 }
37}
menubar-pet/Sources/menubar-pet/main.swift added +133
@@ -0,0 +1,133 @@
1import AppKit
2import Foundation
3import PetKit
4
5// The menu-bar face: a status item, a timer, and a menu. It runs `pet-life`
6// and draws what comes back. There is no socket here and no wire decoding
7// one decoder, in Rust, is the whole point of the arrangement.
8
9/// How often to ask. The decline plays out over days, so this is already far
10/// faster than the mechanic needs; it is short enough that the menu is never
11/// visibly stale when you open it.
12let pollInterval: TimeInterval = 5
13
14final class PetMenuBar: NSObject, NSApplicationDelegate {
15 private var item: NSStatusItem!
16 private var timer: Timer?
17
18 func applicationDidFinishLaunching(_: Notification) {
19 item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
20 item.button?.title = ""
21 refresh()
22 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
23 self?.refresh()
24 }
25 }
26
27 /// Locate `pet-life` beside this binary first, then on PATH the order
28 /// `signald` uses to find `macos-collector`. Homebrew installs them into
29 /// the same prefix, so the neighbour case is the normal one.
30 private func petLifePath() -> String? {
31 let sibling = URL(fileURLWithPath: CommandLine.arguments[0])
32 .deletingLastPathComponent()
33 .appendingPathComponent("pet-life")
34 if FileManager.default.isExecutableFile(atPath: sibling.path) {
35 return sibling.path
36 }
37 for dir in (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":") {
38 let candidate = "\(dir)/pet-life"
39 if FileManager.default.isExecutableFile(atPath: candidate) {
40 return candidate
41 }
42 }
43 return nil
44 }
45
46 private func readPet() -> PetState? {
47 guard let path = petLifePath() else { return nil }
48 let process = Process()
49 process.executableURL = URL(fileURLWithPath: path)
50 let out = Pipe()
51 process.standardOutput = out
52 process.standardError = FileHandle.nullDevice
53 do {
54 try process.run()
55 } catch {
56 return nil
57 }
58 let data = out.fileHandleForReading.readDataToEndOfFile()
59 process.waitUntilExit()
60 guard process.terminationStatus == 0 else { return nil }
61 return try? JSONDecoder().decode(PetState.self, from: data)
62 }
63
64 private func refresh() {
65 guard let pet = readPet() else {
66 // A face that cannot read the bus says so rather than showing a
67 // stale pet, which is the rule the terminal pet follows.
68 item.button?.title = "?"
69 item.menu = unreachableMenu()
70 return
71 }
72 item.button?.title = pet.face
73 item.menu = menu(for: pet)
74 }
75
76 private func unreachableMenu() -> NSMenu {
77 let menu = NSMenu()
78 menu.addItem(disabled("signald is not reachable"))
79 menu.addItem(disabled("is the daemon running?"))
80 menu.addItem(.separator())
81 menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
82 menu.items.last?.target = self
83 return menu
84 }
85
86 private func menu(for pet: PetState) -> NSMenu {
87 let menu = NSMenu()
88 if pet.alive, let name = pet.name, let generation = pet.generation {
89 menu.addItem(disabled("\(name)\(pet.stage)"))
90 menu.addItem(disabled(String(format: "generation %d, %.1f days old", generation, pet.age_days)))
91 if pet.quiet_days >= 1 {
92 menu.addItem(disabled(String(format: "unattended %.1f days", pet.quiet_days)))
93 }
94 } else {
95 menu.addItem(disabled("no pet"))
96 menu.addItem(disabled("a new one arrives when you do"))
97 }
98
99 if !pet.cemetery.isEmpty {
100 menu.addItem(.separator())
101 menu.addItem(disabled("cemetery"))
102 // Most recent first: the one you just lost is the one you want.
103 for grave in pet.cemetery.reversed() {
104 menu.addItem(disabled(String(format: " %@ — %.1f days", grave.name, grave.lived_days)))
105 }
106 }
107
108 menu.addItem(.separator())
109 let quit = NSMenuItem(title: "Quit", action: #selector(self.quit), keyEquivalent: "q")
110 quit.target = self
111 menu.addItem(quit)
112 return menu
113 }
114
115 private func disabled(_ title: String) -> NSMenuItem {
116 let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
117 item.isEnabled = false
118 return item
119 }
120
121 @objc private func quit() {
122 NSApplication.shared.terminate(nil)
123 }
124}
125
126let app = NSApplication.shared
127// .accessory: menu bar only, no Dock icon and no window. The bundle's
128// LSUIElement says the same thing to launchd; this covers running the binary
129// directly, outside a bundle.
130app.setActivationPolicy(.accessory)
131let delegate = PetMenuBar()
132app.delegate = delegate
133app.run()
menubar-pet/Tests/PetKitTests/PetStateTests.swift added +46
@@ -0,0 +1,46 @@
1import XCTest
2@testable import PetKit
3
4/// The other half of the contract with `pet-life`. Its JSON is pinned to exact
5/// bytes by a Rust test; this asserts Swift reads those same bytes. The wire
6/// format between the daemon and the collector drifted twice in this project,
7/// and both times a pinned fixture is what caught it.
8final class PetStateTests: XCTestCase {
9 /// Verbatim from pet-life's own `the_json_contract_is_exact` test.
10 let living = #"""
11 {"alive":true,"name":"Ash","generation":2,"stage":"content","face":"(^ω^)","age_days":0.50,"quiet_days":0.50,"cemetery":[{"name":"Marble","generation":1,"lived_days":7.00}]}
12 """#
13
14 /// A pet that has died: name and generation are null, not absent.
15 let dead = #"""
16 {"alive":false,"name":null,"generation":null,"stage":"dead","face":"","age_days":0.00,"quiet_days":31.00,"cemetery":[{"name":"Marble","generation":1,"lived_days":7.00}]}
17 """#
18
19 func testDecodesALivingPet() throws {
20 let pet = try PetState.decode(Data(living.utf8))
21 XCTAssertTrue(pet.alive)
22 XCTAssertEqual(pet.name, "Ash")
23 XCTAssertEqual(pet.generation, 2)
24 XCTAssertEqual(pet.stage, "content")
25 XCTAssertEqual(pet.face, "(^ω^)")
26 XCTAssertEqual(pet.age_days, 0.5, accuracy: 0.001)
27 XCTAssertEqual(pet.cemetery.count, 1)
28 XCTAssertEqual(pet.cemetery[0].name, "Marble")
29 XCTAssertEqual(pet.cemetery[0].lived_days, 7.0, accuracy: 0.001)
30 }
31
32 /// The null case is the one a naive `String` rather than `String?` would
33 /// break on, and it is exactly the state the app must render.
34 func testDecodesADeadPet() throws {
35 let pet = try PetState.decode(Data(dead.utf8))
36 XCTAssertFalse(pet.alive)
37 XCTAssertNil(pet.name)
38 XCTAssertNil(pet.generation)
39 XCTAssertEqual(pet.stage, "dead")
40 XCTAssertEqual(pet.cemetery.count, 1, "the dead are still remembered")
41 }
42
43 func testRejectsSomethingThatIsNotAPet() {
44 XCTAssertThrowsError(try PetState.decode(Data(#"{"nope":1}"#.utf8)))
45 }
46}
menubar-pet/scripts/bundle.sh added +52
@@ -0,0 +1,52 @@
1#!/bin/sh
2# Assemble menubar-pet.app around the SwiftPM executable.
3#
4# SwiftPM cannot emit a .app, and a menu-bar app needs one: LSUIElement is what
5# keeps it out of the Dock and off the window list. Rather than take on an
6# .xcodeproj — which would stop `swift build` being the whole story and make
7# the Homebrew formula harder — the bundle is four files and a script.
8#
9# bundle.sh <built-executable> <output-dir>
10#
11# Writes <output-dir>/menubar-pet.app.
12set -eu
13
14exe=${1:?usage: bundle.sh <built-executable> <output-dir>}
15outdir=${2:?usage: bundle.sh <built-executable> <output-dir>}
16
17[ -x "$exe" ] || { echo "bundle.sh: $exe is not executable" >&2; exit 1; }
18
19app="$outdir/menubar-pet.app"
20rm -rf "$app"
21mkdir -p "$app/Contents/MacOS"
22
23cp "$exe" "$app/Contents/MacOS/menubar-pet"
24
25cat > "$app/Contents/Info.plist" <<'PLIST'
26<?xml version="1.0" encoding="UTF-8"?>
27<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28<plist version="1.0">
29<dict>
30 <key>CFBundleName</key>
31 <string>menubar-pet</string>
32 <key>CFBundleDisplayName</key>
33 <string>Menubar Pet</string>
34 <key>CFBundleIdentifier</key>
35 <string>net.krz.ambient-companions.menubar-pet</string>
36 <key>CFBundleExecutable</key>
37 <string>menubar-pet</string>
38 <key>CFBundlePackageType</key>
39 <string>APPL</string>
40 <key>CFBundleInfoDictionaryVersion</key>
41 <string>6.0</string>
42 <key>LSMinimumSystemVersion</key>
43 <string>13.0</string>
44 <!-- Menu bar only: no Dock icon, no window. -->
45 <key>LSUIElement</key>
46 <true/>
47</dict>
48</plist>
49PLIST
50
51plutil -lint "$app/Contents/Info.plist" >/dev/null
52echo "$app"