Add menubar-pet, the Swift menu-bar face !28
7 files changed, +310 −1
.githooks/pre-push +17 −1
| @@ -26,11 +26,27 @@ cargo test --locked | ||
| 26 | 26 | echo "pre-push: cargo clippy" >&2 |
| 27 | 27 | cargo clippy --all-targets --locked -- -D warnings |
| 28 | 28 | |
| 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. | |
| 30 | 31 | case "$(uname -s)" in |
| 31 | 32 | Darwin) |
| 32 | 33 | echo "pre-push: swift test (macos-collector)" >&2 |
| 33 | 34 | 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" | |
| 34 | 50 | ;; |
| 35 | 51 | *) |
| 36 | 52 | 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 | |
| 2 | import 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. | |
| 11 | let 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 @@ | ||
| 1 | import 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. | |
| 11 | public 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 | ||
| 21 | public 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 | ||
| 32 | public 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 @@ | ||
| 1 | import AppKit | |
| 2 | import Foundation | |
| 3 | import 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. | |
| 12 | let pollInterval: TimeInterval = 5 | |
| 13 | ||
| 14 | final 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 | ||
| 126 | let 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. | |
| 130 | app.setActivationPolicy(.accessory) | |
| 131 | let delegate = PetMenuBar() | |
| 132 | app.delegate = delegate | |
| 133 | app.run() | |
menubar-pet/Tests/PetKitTests/PetStateTests.swift added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | import 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. | |
| 8 | final 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. | |
| 12 | set -eu | |
| 13 | ||
| 14 | exe=${1:?usage: bundle.sh <built-executable> <output-dir>} | |
| 15 | outdir=${2:?usage: bundle.sh <built-executable> <output-dir>} | |
| 16 | ||
| 17 | [ -x "$exe" ] || { echo "bundle.sh: $exe is not executable" >&2; exit 1; } | |
| 18 | ||
| 19 | app="$outdir/menubar-pet.app" | |
| 20 | rm -rf "$app" | |
| 21 | mkdir -p "$app/Contents/MacOS" | |
| 22 | ||
| 23 | cp "$exe" "$app/Contents/MacOS/menubar-pet" | |
| 24 | ||
| 25 | cat > "$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> | |
| 49 | PLIST | |
| 50 | ||
| 51 | plutil -lint "$app/Contents/Info.plist" >/dev/null | |
| 52 | echo "$app" | |