From cb7ce6ff303d5f12c77162a67c5cd5d19334299a Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 14:45:57 +0200 Subject: [PATCH 01/19] feat: add a menu bar app that shows and controls the stack A native SwiftUI menu bar app in `apps/macos/`, built with the Command Line Tools alone (`apps/macos/build.sh`). It is a client of the stack CLI: it reads `./stack status --json` every minute and when the panel opens, and runs `./stack up|down|restart ` in the background. - one row per installed stacklet with its state, port and health issues - start, stop, restart and open in the browser from the row - first installs, logs and doctor open in Terminal, since they prompt or never end - restart for stacklets running code the checkout has moved past - disk and memory from the host figures in the status The app starts no services and keeps no state beyond the chosen checkout. It is a prototype: not built in CI and not distributed. --- apps/macos/.gitignore | 2 + apps/macos/Package.swift | 10 + apps/macos/README.md | 29 ++ apps/macos/Sources/StackMenu/App.swift | 25 ++ apps/macos/Sources/StackMenu/MenuPanel.swift | 254 ++++++++++++++++++ apps/macos/Sources/StackMenu/Model.swift | 76 ++++++ apps/macos/Sources/StackMenu/StackCLI.swift | 132 +++++++++ apps/macos/Sources/StackMenu/StackStore.swift | 128 +++++++++ apps/macos/build.sh | 40 +++ 9 files changed, 696 insertions(+) create mode 100644 apps/macos/.gitignore create mode 100644 apps/macos/Package.swift create mode 100644 apps/macos/README.md create mode 100644 apps/macos/Sources/StackMenu/App.swift create mode 100644 apps/macos/Sources/StackMenu/MenuPanel.swift create mode 100644 apps/macos/Sources/StackMenu/Model.swift create mode 100644 apps/macos/Sources/StackMenu/StackCLI.swift create mode 100644 apps/macos/Sources/StackMenu/StackStore.swift create mode 100755 apps/macos/build.sh diff --git a/apps/macos/.gitignore b/apps/macos/.gitignore new file mode 100644 index 00000000..f0b71e23 --- /dev/null +++ b/apps/macos/.gitignore @@ -0,0 +1,2 @@ +.build/ +build/ diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift new file mode 100644 index 00000000..2c6a2bd9 --- /dev/null +++ b/apps/macos/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "StackMenu", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget(name: "StackMenu", path: "Sources/StackMenu"), + ] +) diff --git a/apps/macos/README.md b/apps/macos/README.md new file mode 100644 index 00000000..cabf7180 --- /dev/null +++ b/apps/macos/README.md @@ -0,0 +1,29 @@ +# Menu bar app (prototype) + +A native macOS menu bar app for the stack on this Mac: what is running, what +is failing, start, stop and restart, disk and memory. + +It is a client of the `stack` CLI and nothing else. It runs `./stack status +--json` in a checkout every minute and when the panel opens, and runs +`./stack up|down|restart ` in the background. Anything interactive or +endless (a first install, logs, doctor) opens in Terminal. It starts no +services and keeps no state, so the CLI stays the one owner of the lifecycle. + +## Build and run + +Needs macOS 14 and the Command Line Tools; no Xcode. + +```bash +apps/macos/build.sh # prints the path of build/famstack.app +open apps/macos/build/famstack.app +``` + +## Which checkout + +The app runs `./stack` from the checkout you choose in the panel +(**Checkout…**), and remembers it. Without a choice it tries `~/famstack`. +To set it from a shell: + +```bash +defaults write dev.famstack.menubar checkout /path/to/famstack +``` diff --git a/apps/macos/Sources/StackMenu/App.swift b/apps/macos/Sources/StackMenu/App.swift new file mode 100644 index 00000000..1a9b9b76 --- /dev/null +++ b/apps/macos/Sources/StackMenu/App.swift @@ -0,0 +1,25 @@ +import AppKit +import SwiftUI + +@main +struct StackMenuApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @StateObject private var store = StackStore() + + var body: some Scene { + MenuBarExtra { + MenuPanel(store: store) + } label: { + Image(systemName: store.summary.symbol) + } + .menuBarExtraStyle(.window) + } +} + +/// A menu bar app has no Dock icon. The bundle says so with LSUIElement; +/// setting the policy here as well keeps `swift run` from showing one. +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + } +} diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift new file mode 100644 index 00000000..c51e8831 --- /dev/null +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -0,0 +1,254 @@ +import SwiftUI + +// ── The panel under the menu bar icon ───────────────────────────────────── + +struct MenuPanel: View { + @ObservedObject var store: StackStore + @State private var showAvailable = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + if store.checkout == nil { + Text("No checkout found. Choose the folder you run ./stack from.") + .font(.callout).foregroundStyle(.secondary) + } + if let error = store.loadError { + Notice(text: error, tint: .red) + } + if let failure = store.lastFailure { + failureNotice(failure) + } + if let status = store.status { + if !status.stale.isEmpty { staleNotice(status.stale) } + stackletList(status) + if let host = status.host { + Divider() + HostMetrics(host: host) + } + } + Divider() + footer + } + .padding(12) + .frame(width: 320) + .task { await store.refresh() } + } + + // ── Sections ───────────────────────────────────────────────────────── + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("famstack").font(.headline) + if let status = store.status { + let running = status.installed.filter { $0.online }.count + Text("\(running) of \(status.installed.count) running · \(status.version)") + .font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + } + } + Spacer() + if store.refreshing { + ProgressView().controlSize(.small) + } else { + Button { Task { await store.refresh() } } label: { Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless) + .help("Refresh") + } + } + } + + private func stackletList(_ status: StackStatus) -> some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(status.installed) { stacklet in + StackletRow(stacklet: stacklet, busyVerb: store.busy[stacklet.id], store: store) + } + if !status.available.isEmpty { + DisclosureGroup("Not installed (\(status.available.count))", isExpanded: $showAvailable) { + ForEach(status.available) { stacklet in + HStack { + Text(stacklet.name).foregroundStyle(.secondary) + Spacer() + Button("Install…") { store.openInTerminal(["up", stacklet.id]) } + .buttonStyle(.borderless).font(.caption) + .help("Opens Terminal: the first start asks setup questions") + } + .padding(.vertical, 2) + } + } + .font(.callout) + .padding(.top, 6) + } + } + } + + private func staleNotice(_ stale: [String]) -> some View { + HStack { + Image(systemName: "arrow.up.circle").foregroundStyle(.blue) + Text("New code for \(stale.joined(separator: ", "))") + .font(.callout).lineLimit(2) + Spacer() + if store.busy[""] != nil { + ProgressView().controlSize(.small) + } else { + Button("Restart") { Task { await store.perform("restart") } } + .controlSize(.small) + } + } + } + + private func failureNotice(_ failure: StackStore.ActionFailure) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("stack \(failure.command.joined(separator: " ")) failed").font(.callout.bold()) + Text(failure.message).font(.caption.monospaced()).lineLimit(6) + HStack { + Button("Run in Terminal") { + store.openInTerminal(failure.command) + store.lastFailure = nil + } + Button("Dismiss") { store.lastFailure = nil } + } + .controlSize(.small) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + } + + private var footer: some View { + HStack { + Button("Doctor") { store.openInTerminal(["doctor"]) } + .disabled(store.checkout == nil) + Button("Checkout…") { store.chooseCheckout() } + .help(store.checkout?.path ?? "No checkout chosen") + Spacer() + Button("Quit") { NSApp.terminate(nil) } + } + .buttonStyle(.borderless) + .font(.callout) + } +} + +// ── One installed stacklet ──────────────────────────────────────────────── + +struct StackletRow: View { + let stacklet: Stacklet + let busyVerb: String? + @ObservedObject var store: StackStore + @State private var hovering = false + + var body: some View { + HStack(spacing: 8) { + Circle().fill(stacklet.health.color).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(stacklet.name) + ForEach(stacklet.healthIssues, id: \.self) { issue in + Text(issue).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + Spacer() + if let busyVerb { + Text(busyVerb).font(.caption).foregroundStyle(.secondary) + ProgressView().controlSize(.small) + } else { + Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + actions + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(hovering ? Color.primary.opacity(0.08) : .clear, in: RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + .onHover { hovering = $0 } + .onTapGesture { if stacklet.online { store.openInBrowser(stacklet) } } + } + + private var detail: String { + if stacklet.health == .healthy, let port = stacklet.port { return ":\(port)" } + return stacklet.health.label + } + + private var actions: some View { + Menu { + if stacklet.port != nil && stacklet.online { + Button("Open in Browser") { store.openInBrowser(stacklet) } + Divider() + } + if stacklet.health == .down { + Button("Start") { Task { await store.perform("up", stacklet.id) } } + } else { + Button("Restart") { Task { await store.perform("restart", stacklet.id) } } + Button("Stop") { Task { await store.perform("down", stacklet.id) } } + } + Divider() + Button("Logs") { store.openInTerminal(["logs", stacklet.id]) } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } +} + +// ── Host figures from `stack status` ────────────────────────────────────── + +struct HostMetrics: View { + let host: StackStatus.Host + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Meter(label: "Disk", + value: host.diskUsedPct / 100, + detail: "\(Int(host.diskFreeGb)) GB free of \(Int(host.diskTotalGb))") + Meter(label: "Memory", + value: host.memoryUsedGb / max(host.memoryTotalGb, 1), + detail: String(format: "%.1f of %.0f GB", host.memoryUsedGb, host.memoryTotalGb)) + } + } +} + +struct Meter: View { + let label: String + let value: Double + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label).font(.caption) + Spacer() + Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + ProgressView(value: min(max(value, 0), 1)) + .tint(value > 0.9 ? .red : value > 0.75 ? .orange : .accentColor) + } + } +} + +struct Notice: View { + let text: String + let tint: Color + + var body: some View { + Text(text) + .font(.caption) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + } +} + +extension Health { + var color: Color { + switch self { + case .healthy: .green + case .remote: .blue + case .starting: .yellow + case .degraded: .orange + case .failing: .red + case .down, .notInstalled: .gray + } + } +} diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift new file mode 100644 index 00000000..d5a3b048 --- /dev/null +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -0,0 +1,76 @@ +import Foundation + +// ── What `./stack status --json` returns ────────────────────────────────── +// +// Only the fields the menu reads are declared; the decoder ignores the rest, +// so the CLI can grow its output without breaking the app. + +struct StackStatus: Decodable { + let version: String + let host: Host? + let stacklets: [Stacklet] + let stale: [String] + + struct Host: Decodable { + let memoryTotalGb: Double + let memoryUsedGb: Double + let diskTotalGb: Double + let diskFreeGb: Double + let diskUsedPct: Double + } + + var installed: [Stacklet] { stacklets.filter(\.enabled) } + var available: [Stacklet] { stacklets.filter { !$0.enabled } } +} + +struct Stacklet: Decodable, Identifiable { + let id: String + let name: String + let port: Int? + let enabled: Bool + let online: Bool + let starting: Bool + let failing: Bool + let degraded: Bool + let stale: Bool + let remote: String? + let healthIssues: [String] + + /// One state per row, derived in the same order `stack list` reads the + /// flags: a remote stacklet is judged by its remote checks alone, and a + /// stacklet that is installed but has no running project counts as down. + var health: Health { + if !enabled { return .notInstalled } + if remote != nil { return degraded ? .degraded : .remote } + if failing { return .failing } + if starting { return .starting } + if !online { return .down } + if degraded { return .degraded } + return .healthy + } +} + +enum Health { + case healthy, remote, starting, degraded, failing, down, notInstalled + + /// Down is not a fault: `stack down` leaves a stacklet exactly like this, + /// and a crashing one restarts (`unless-stopped`) and reads as failing. + var needsAttention: Bool { + switch self { + case .degraded, .failing: true + default: false + } + } + + var label: String { + switch self { + case .healthy: "running" + case .remote: "remote" + case .starting: "starting" + case .degraded: "degraded" + case .failing: "failing" + case .down: "down" + case .notInstalled: "not installed" + } + } +} diff --git a/apps/macos/Sources/StackMenu/StackCLI.swift b/apps/macos/Sources/StackMenu/StackCLI.swift new file mode 100644 index 00000000..9920ea05 --- /dev/null +++ b/apps/macos/Sources/StackMenu/StackCLI.swift @@ -0,0 +1,132 @@ +import AppKit +import Foundation + +// ── The stack CLI as the app's only backend ─────────────────────────────── +// +// The app holds no state about the stack and starts no services itself. It +// runs `./stack` in a checkout and renders what comes back, so the CLI stays +// the one owner of the lifecycle, as it is for every other client. + +struct StackCLI { + let checkout: URL + + struct Result { + let exitCode: Int32 + let output: String + + var succeeded: Bool { exitCode == 0 } + + /// The last lines are where the CLI says what went wrong. + func tail(_ lines: Int = 6) -> String { + output.split(separator: "\n", omittingEmptySubsequences: true) + .suffix(lines).joined(separator: "\n") + } + } + + enum Failure: LocalizedError { + case exited(Result) + case undecodable(String) + + var errorDescription: String? { + switch self { + case .exited(let result): result.tail(3) + case .undecodable(let reason): "Unexpected output from stack status: \(reason)" + } + } + } + + /// A checkout is a directory holding the `stack` wrapper and `stacklets/`, + /// the same marker the CLI itself walks up to. + static func isCheckout(_ url: URL) -> Bool { + let fm = FileManager.default + return fm.isExecutableFile(atPath: url.appendingPathComponent("stack").path) + && fm.fileExists(atPath: url.appendingPathComponent("stacklets").path) + } + + func status() async throws -> StackStatus { + let result = await run(["status", "--json"]) + guard result.succeeded else { throw Failure.exited(result) } + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + return try decoder.decode(StackStatus.self, from: Data(result.output.utf8)) + } catch { + throw Failure.undecodable(String(describing: error)) + } + } + + /// Runs `./stack ` to completion off the main thread. + /// + /// The working directory is the checkout because the CLI finds its root + /// by walking up from the current directory, not from where the script + /// lives. Stdin is empty so a hook that prompts fails instead of waiting + /// on an answer nobody can give. + func run(_ arguments: [String]) async -> Result { + let checkout = self.checkout + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = checkout.appendingPathComponent("stack") + process.arguments = arguments + process.currentDirectoryURL = checkout + process.environment = Self.environment() + process.standardInput = FileHandle.nullDevice + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + } catch { + continuation.resume(returning: Result(exitCode: -1, output: error.localizedDescription)) + return + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let text = String(decoding: data, as: UTF8.self) + continuation.resume(returning: Result(exitCode: process.terminationStatus, + output: Self.stripANSI(text))) + } + } + } + + /// Opens Terminal on `./stack ` in the checkout. Used for + /// anything interactive or endless: first installs, logs, doctor. + /// + /// A `.command` file opens in Terminal through Launch Services, which + /// needs no Automation permission, unlike scripting Terminal directly. + func openInTerminal(_ arguments: [String]) { + let quoted = ([checkout.appendingPathComponent("stack").path] + arguments) + .map(Self.shellQuote).joined(separator: " ") + let script = "#!/bin/zsh -l\ncd \(Self.shellQuote(checkout.path)) || exit 1\n\(quoted)\n" + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("stack-\(arguments.joined(separator: "-")).command") + do { + try script.write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) + NSWorkspace.shared.open(file) + } catch { + NSSound.beep() + } + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// An app started from Finder or at login gets launchd's minimal PATH, + /// in which the wrapper finds only Apple's Python 3.9 and no docker. + /// Prepend where Homebrew and OrbStack install them. + private static func environment() -> [String: String] { + var env = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + let extra = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "\(home)/.orbstack/bin"] + env["PATH"] = (extra + [env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"]).joined(separator: ":") + return env + } + + private static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func stripANSI(_ s: String) -> String { + s.replacingOccurrences(of: "\u{1B}\\[[0-9;?]*[A-Za-z]", with: "", options: .regularExpression) + } +} diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift new file mode 100644 index 00000000..c69d1a99 --- /dev/null +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -0,0 +1,128 @@ +import AppKit +import Foundation + +// ── App state: the last status, what is running, what failed ────────────── + +@MainActor +final class StackStore: ObservableObject { + @Published private(set) var checkout: URL? + @Published private(set) var status: StackStatus? + @Published private(set) var loadError: String? + @Published private(set) var refreshing = false + /// Stacklet id (or "" for the whole stack) to the verb running on it. + @Published private(set) var busy: [String: String] = [:] + @Published var lastFailure: ActionFailure? + + struct ActionFailure: Identifiable { + let id = UUID() + let command: [String] + let message: String + } + + private static let checkoutKey = "checkout" + private static let pollInterval: TimeInterval = 60 + private var timer: Timer? + + init() { + checkout = Self.resolveCheckout() + timer = Timer.scheduledTimer(withTimeInterval: Self.pollInterval, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.refresh() } + } + Task { await refresh() } + } + + private var cli: StackCLI? { checkout.map(StackCLI.init) } + + // ── Reading ────────────────────────────────────────────────────────── + + func refresh() async { + guard let cli, !refreshing else { return } + refreshing = true + defer { refreshing = false } + do { + status = try await cli.status() + loadError = nil + } catch { + loadError = error.localizedDescription + } + } + + /// What the menu bar icon shows, worst state first. + var summary: Summary { + guard checkout != nil else { return .unconfigured } + guard let status, loadError == nil else { return .unreachable } + if !busy.isEmpty { return .busy } + if status.installed.contains(where: { $0.health.needsAttention }) { return .attention } + return .healthy + } + + enum Summary { + case healthy, attention, busy, unreachable, unconfigured + + var symbol: String { + switch self { + case .healthy: "house.fill" + case .attention: "exclamationmark.triangle.fill" + case .busy: "arrow.triangle.2.circlepath" + case .unreachable, .unconfigured: "house" + } + } + } + + // ── Acting ─────────────────────────────────────────────────────────── + + /// Runs a lifecycle verb in the background, then re-reads the status. + /// One verb per stacklet at a time; the CLI serialises the rest. + func perform(_ verb: String, _ stacklet: String? = nil) async { + guard let cli else { return } + let key = stacklet ?? "" + guard busy[key] == nil else { return } + let command = [verb] + (stacklet.map { [$0] } ?? []) + busy[key] = verb + let result = await cli.run(command) + busy[key] = nil + if !result.succeeded { + lastFailure = ActionFailure(command: command, message: result.tail()) + } + await refresh() + } + + func openInTerminal(_ arguments: [String]) { + cli?.openInTerminal(arguments) + } + + func openInBrowser(_ stacklet: Stacklet) { + // Ports bind on this Mac in both port and domain mode (127.0.0.1 in + // the latter), so localhost reaches every UI from the server itself. + guard let port = stacklet.port, let url = URL(string: "http://localhost:\(port)") else { return } + NSWorkspace.shared.open(url) + } + + // ── Which checkout ─────────────────────────────────────────────────── + + func chooseCheckout() { + let panel = NSOpenPanel() + panel.message = "Choose the folder you run ./stack from" + panel.canChooseDirectories = true + panel.canChooseFiles = false + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + guard StackCLI.isCheckout(url) else { + loadError = "\(url.path) has no ./stack and stacklets/ folder" + return + } + UserDefaults.standard.set(url.path, forKey: Self.checkoutKey) + checkout = url + status = nil + Task { await refresh() } + } + + /// The saved choice, else `~/famstack`, where the README's clone lands + /// when run from the home folder. + private static func resolveCheckout() -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [UserDefaults.standard.string(forKey: checkoutKey).map { URL(fileURLWithPath: $0) }, + home.appendingPathComponent("famstack")] + return candidates.compactMap { $0 }.first(where: StackCLI.isCheckout) + } +} diff --git a/apps/macos/build.sh b/apps/macos/build.sh new file mode 100755 index 00000000..12efd2db --- /dev/null +++ b/apps/macos/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Build the menu bar app into build/famstack.app. +# +# SwiftPM builds the binary; this wraps it in the minimal bundle macOS needs +# to treat it as a menu bar app (LSUIElement, a bundle id for its settings) +# and signs it ad hoc so it runs on this Mac. Command Line Tools are enough, +# no Xcode project. +# +# apps/macos/build.sh release build +# apps/macos/build.sh debug debug build +set -euo pipefail + +cd "$(dirname "$0")" +config="${1:-release}" + +swift build -c "$config" + +app="build/famstack.app" +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" +cp ".build/$config/StackMenu" "$app/Contents/MacOS/StackMenu" + +cat > "$app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier dev.famstack.menubar + CFBundleName famstack + CFBundleExecutable StackMenu + CFBundlePackageType APPL + CFBundleShortVersionString 0.1.0 + LSMinimumSystemVersion 14.0 + LSUIElement + + +PLIST + +codesign --force --sign - "$app" +echo "$(pwd)/$app" From d356b402a2c9d3226cb2e0c282c32bed86cad59b Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:38:16 +0200 Subject: [PATCH 02/19] fix(doctor): check stacklets that run a single container `stack doctor` found a stacklet's containers by the name pattern `stack--*`. A stacklet with one service is named `stack-`, so doctor never saw the code stacklet's Forgejo container: an exited or drifted `stack-code` went unreported. Containers are now matched by their compose project, `stack-`, which every container of a stacklet carries whatever its name. --- lib/stack/docker.py | 71 ++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/lib/stack/docker.py b/lib/stack/docker.py index ff7199b6..46421d9e 100644 --- a/lib/stack/docker.py +++ b/lib/stack/docker.py @@ -330,42 +330,65 @@ def project_states() -> dict[str, str]: return {} -def containers_for(stacklet_id: str) -> list[dict]: - """Every container of a stacklet, running or not. +_PROJECT_LABEL = "com.docker.compose.project" +_PROJECT_PREFIX = "stack-" + + +def _parse_ps_line(line: str) -> dict | None: + """One `docker ps` row (name, project, state, status) as a container dict. - Returns dicts with name, state, exit_code and a human "since" string. - `stack status` only reports the stacklet as a whole, so a single dead - sidecar shows up as "failing" with no clue which one died. + The stacklet id comes from the compose project, `stack-`. A + container that belongs to another project yields None. """ + parts = line.split("\t") + if len(parts) != 4: + return None + name, project, state, status = parts + if not project.startswith(_PROJECT_PREFIX): + return None + # "Exited (128) 3 weeks ago" -> code 128, "3 weeks ago" + code, since = 0, status + if status.startswith("Exited ("): + head, _, tail = status.partition(")") + try: + code = int(head[len("Exited ("):]) + except ValueError: + code = 1 + since = tail.strip() + return {"name": name, "stacklet": project[len(_PROJECT_PREFIX):], + "state": state, "exit_code": code, "since": since} + + +def _ps(label_filter: str) -> list[dict]: try: r = _docker( - "ps", "-a", "--filter", f"name=^stack-{stacklet_id}-", - "--format", "{{.Names}}\t{{.State}}\t{{.Status}}", + "ps", "-a", "--filter", f"label={label_filter}", + "--format", f'{{{{.Names}}}}\t{{{{.Label "{_PROJECT_LABEL}"}}}}' + "\t{{.State}}\t{{.Status}}", capture_output=True, text=True, timeout=10, ) if r.returncode != 0: return [] - out = [] - for line in r.stdout.strip().splitlines(): - parts = line.split("\t") - if len(parts) != 3: - continue - name, state, status = parts - # "Exited (128) 3 weeks ago" -> code 128, "3 weeks ago" - code, since = 0, status - if status.startswith("Exited ("): - head, _, tail = status.partition(")") - try: - code = int(head[len("Exited ("):]) - except ValueError: - code = 1 - since = tail.strip() - out.append({"name": name, "state": state, "exit_code": code, "since": since}) - return out + rows = (_parse_ps_line(line) for line in r.stdout.strip().splitlines()) + return [row for row in rows if row] except Exception: return [] +def containers_for(stacklet_id: str) -> list[dict]: + """Every container of a stacklet, running or not. + + Returns dicts with name, stacklet, state, exit_code and a human "since" + string. `stack status` only reports the stacklet as a whole, so a single + dead sidecar shows up as "failing" with no clue which one died. + + Matched by compose project rather than by name: a stacklet with one + service is named `stack-`, without the `-` suffix a name + pattern would need. + """ + return _ps(f"{_PROJECT_LABEL}={_PROJECT_PREFIX}{stacklet_id}") + + def _parse_env(text: str) -> dict: """Turn `docker inspect`'s KEY=VALUE lines into a dict.""" env = {} From 0eb8635f9cb36662e20aee296eba7895d1dac50b Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:38:16 +0200 Subject: [PATCH 03/19] feat(doctor): report findings as JSON with --json `stack doctor --json` prints the running version, the one-line summary and every finding with its level, title, detail and fix command. The exit code is unchanged: 1 when any finding is an error. --- lib/stack/cli.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/stack/cli.py b/lib/stack/cli.py index b964712b..8bee4441 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -24,6 +24,7 @@ import os import sys import tomllib +from dataclasses import asdict from pathlib import Path from . import caddy @@ -1140,6 +1141,17 @@ def missing_secrets(stacklet_id): if release: findings.insert(0, release) + if args.json: + json.dump({ + "version": running_version_of(stck), + "summary": doctor.summarise(findings), + "findings": [asdict(f) for f in findings], + }, sys.stdout, indent=2) + print() + if any(f.is_error for f in findings): + sys.exit(1) + return + print() if position: print(f" {ORANGE}{BOLD}{stck.product_name()}{RESET} " @@ -1990,7 +2002,8 @@ def main(): ) sub.add_parser("init") sub.add_parser("status") - sub.add_parser("doctor") + p = sub.add_parser("doctor") + p.add_argument("--json", action="store_true", help="Output as JSON") sub.add_parser("list") p = sub.add_parser("config") config_sub = p.add_subparsers(dest="config_action") From dae74d0f409ac7f1c5595d51c9c4523b651c7939 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:38:16 +0200 Subject: [PATCH 04/19] feat(cli): add stack errors for recent error lines across stacklets `stack errors` reads the log of every stacklet container over the last 24 hours and lists each container that logged errors, how many, and the newest three lines. `--since 30m` or `--since 168h` changes the window; `--json` gives the newest five per container with their timestamps. A line counts when its log format marks it as an error: an `ERROR`, `CRITICAL`, `FATAL` or `PANIC` level, Forgejo's `[E]`, nginx's `[error]`, `level=error`, or the start of a Python traceback. Lines that only mention an error in their message are not counted. The README and the admin guide have listed this command for a while; it now exists. --- lib/stack/cli.py | 55 +++++++++++++++++++ lib/stack/docker.py | 20 +++++++ lib/stack/logscan.py | 68 ++++++++++++++++++++++++ tests/unit/framework/test_logscan.py | 79 ++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 lib/stack/logscan.py create mode 100644 tests/unit/framework/test_logscan.py diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 8bee4441..b40692c9 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -22,8 +22,10 @@ import argparse import json import os +import re import sys import tomllib +from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict from pathlib import Path @@ -31,6 +33,7 @@ from . import docker from . import doctor from . import global_command +from . import logscan from .commands import COMMANDS from .prompt import ORANGE, TEAL, GREEN, RED, DIM, BOLD, RESET from .stack import Stack @@ -1296,6 +1299,53 @@ def handle_logs(stck, args): print(output) +# ── errors: what an admin checks first ──────────────────────────────────── + +_DURATION = re.compile(r"^\d+[smh]$") + + +def handle_errors(stck, args): + """Error lines from every stacklet's containers over a recent window. + + One question across the whole stack, so a failure in a sidecar nobody + watches is seen without opening each stacklet's log. What counts as an + error lives in logscan.py; this is the I/O around it. + """ + since = args.since + if not _DURATION.match(since): + print_error({"error": f"--since takes a duration such as 30m or 24h, not '{since}'"}) + sys.exit(1) + + known = {s["id"] for s in stck.discover()} + containers = [c for c in docker.stack_containers() if c["stacklet"] in known] + with ThreadPoolExecutor(max_workers=8) as pool: + scans = list(pool.map( + lambda c: logscan.scan(docker.container_logs(c["name"], since)), containers)) + + found = [{"stacklet": c["stacklet"], "container": c["name"], **scan} + for c, scan in zip(containers, scans) if scan["count"]] + found.sort(key=lambda e: e["last_at"] or "", reverse=True) + report = {"since": since, "scanned": len(containers), "containers": found} + + if args.json: + json.dump(report, sys.stdout, indent=2) + print() + return + + print() + if not found: + print(f" {GREEN}✓{RESET} No errors in the last {since} " + f"across {len(containers)} containers.\n") + return + for entry in found: + noun = "error" if entry["count"] == 1 else "errors" + print(f" {RED}✗{RESET} {BOLD}{entry['container']}{RESET} " + f"{entry['count']} {noun} in the last {since}") + for line in entry["lines"][-3:]: + print(f" {DIM}{line['text'][:160]}{RESET}") + print(f" {DIM}more:{RESET} {TEAL}./stack logs {entry['stacklet']}{RESET}\n") + + def handle_restart(stck, args): cli = CLI(stck) @@ -1899,6 +1949,7 @@ def _plugin_help(module_path: str): "restart": handle_restart, "setup": handle_setup, "logs": handle_logs, + "errors": handle_errors, "version": handle_version, "update": handle_update, } @@ -1920,6 +1971,7 @@ def _plugin_help(module_path: str): ("config admin", "Print tech admin credentials"), ("env ", "Print rendered environment variables"), ("logs ", "Tail container logs"), + ("errors [--since 24h]", "Recent error lines across every stacklet"), ]), ("Setup", [ ("update []", "Move the checkout to a release (says what to restart)"), @@ -2041,6 +2093,9 @@ def main(): p.add_argument("--tail", default=200, type=int) p.add_argument("--grep", default=None, help="Filter log lines with grep pattern") p.add_argument("--json", action="store_true", help="Output as JSON") + p = sub.add_parser("errors") + p.add_argument("--since", default="24h", help="How far back to look: 30m, 24h, 168h") + p.add_argument("--json", action="store_true", help="Output as JSON") # Stacklet CLI plugins stacklet_cmds = {} diff --git a/lib/stack/docker.py b/lib/stack/docker.py index 46421d9e..e002c76d 100644 --- a/lib/stack/docker.py +++ b/lib/stack/docker.py @@ -389,6 +389,26 @@ def containers_for(stacklet_id: str) -> list[dict]: return _ps(f"{_PROJECT_LABEL}={_PROJECT_PREFIX}{stacklet_id}") +def stack_containers() -> list[dict]: + """Every container of every stacklet, in one query. Same dicts as + `containers_for`. Projects of other tools on the same Docker are left + out; a `stack-` project whose stacklet no longer exists is not, so + callers filter by the stacklets they know.""" + return _ps(_PROJECT_LABEL) + + +def container_logs(name: str, since: str) -> str: + """A container's log since `since` (a docker duration such as `24h`), + stdout and stderr together, each line prefixed with its timestamp so + the two streams can be put back in order.""" + try: + r = _docker("logs", "--since", since, "--timestamps", name, + capture_output=True, text=True, timeout=60) + return r.stdout + r.stderr + except Exception: + return "" + + def _parse_env(text: str) -> dict: """Turn `docker inspect`'s KEY=VALUE lines into a dict.""" env = {} diff --git a/lib/stack/logscan.py b/lib/stack/logscan.py new file mode 100644 index 00000000..4c1d819a --- /dev/null +++ b/lib/stack/logscan.py @@ -0,0 +1,68 @@ +"""Find the error lines in container logs. + +`stack errors` answers "what went wrong recently, anywhere?" without the +reader opening every stacklet's log. Each service writes its own format, so +a line is an error when it carries an error *level* in one of the formats +the stack's services use. The word alone does not count: "0 errors", +"error_count=0" and a request path containing "error" are ordinary lines. + +This module is pure: it takes log text and returns a summary. Reading the +logs is the caller's job. +""" + +from __future__ import annotations + +import re + +# One alternative per log format, each anchored on how that format writes +# its level, never on the message text. +_ERROR_LEVEL = re.compile("|".join([ + # A level token as a word: Python logging and loguru (`| ERROR |`, + # `- ERROR -`, `[ERROR]`), Postgres (`ERROR:`, `FATAL:`, `PANIC:`), + # Java/Nest style (`ERROR [main]`). + r"\b(?:ERROR|CRITICAL|FATAL|PANIC)\b", + # Forgejo/Gitea: a one-letter level in brackets. E error, C critical, F fatal. + r"\s\[[ECF]\]\s", + # nginx error log: lowercase level in brackets. + r"\[(?:error|crit|alert|emerg)\]", + # logfmt (Watchtower, Go services) and JSON loggers (Caddy). + r"\blevel=(?:error|fatal|panic)\b", + r"\"level\":\s*\"(?:error|fatal|panic)\"", + # A Python traceback's first line; the frames after it are not counted. + r"^Traceback \(most recent call last\):", +])) + + +def is_error(line: str) -> bool: + return bool(_ERROR_LEVEL.search(line)) + + +def _split_timestamp(line: str) -> tuple[str, str]: + """`docker logs --timestamps` puts an RFC 3339 time and a space in front + of every line. A line without one sorts first.""" + stamp, sep, rest = line.partition(" ") + if sep and len(stamp) >= 20 and stamp[4] == "-" and stamp[10] == "T": + return stamp, rest + return "", line + + +def scan(log: str, keep: int = 5) -> dict: + """Count the error lines in a timestamped log and keep the newest. + + `log` is stdout and stderr concatenated, so lines are put back in time + order first. Returns `count`, `last_at` (the newest error's time, or + None) and `lines`, the newest `keep` errors oldest first, each as + `{"at", "text"}`. + """ + errors = [] + for raw in log.splitlines(): + at, text = _split_timestamp(raw) + if is_error(text): + errors.append((at, text.rstrip())) + errors.sort(key=lambda e: e[0]) + newest = errors[-keep:] if keep else [] + return { + "count": len(errors), + "last_at": (errors[-1][0] or None) if errors else None, + "lines": [{"at": at or None, "text": text} for at, text in newest], + } diff --git a/tests/unit/framework/test_logscan.py b/tests/unit/framework/test_logscan.py new file mode 100644 index 00000000..09bb5be0 --- /dev/null +++ b/tests/unit/framework/test_logscan.py @@ -0,0 +1,79 @@ +"""`stack errors` reports a line only when its log format marks it as an error. + +The lines below are written in the formats the stack's services actually +emit. The ordinary ones are copied from a running instance; an error line is +the same format with its level changed, because the counterpart of every +INFO line is what each service prints when something fails. +""" + +from stack.logscan import is_error, scan + + +# ── What counts as an error ──────────────────────────────────────────────── + +ERROR_LINES = { + "synapse": "2026-09-24 22:26:57,521 - synapse.http.server - 124 - ERROR - GET-42 - Failed handle request", + "loguru bot": "2026-09-25 13:01:35 | ERROR | [archivist-bot] Paperless upload failed", + "loguru curator": "2026-09-25 08:48:40.162 | ERROR | __main__:_run_command:789 - [curator] wiki build failed", + "paperless": "[2026-09-25 15:30:00,167] [ERROR] [paperless.consumer] Error while consuming document", + "postgres": "2026-09-25 13:27:57.951 UTC [28] ERROR: relation \"x\" does not exist", + "postgres fatal": "2026-09-25 13:27:57.951 UTC [28] FATAL: password authentication failed for user \"synapse\"", + "forgejo": "2026/09/25 15:31:07 ...eb/routing/logger.go:102:func1() [E] router: failed to serve", + "tika": "ERROR [main] 13:00:36,716 org.apache.tika.server.core.TikaServerProcess crashed", + "nginx": "2026/09/25 15:31:07 [error] 29#29: *1 open() \"/usr/share/nginx/html/x\" failed", + "watchtower": "time=\"2026-09-25T15:31:07Z\" level=error msg=\"Could not do a head request\"", + "caddy": "{\"level\":\"error\",\"ts\":1727270000.1,\"logger\":\"http.log.error\",\"msg\":\"dial tcp: connection refused\"}", + "python traceback": "Traceback (most recent call last):", +} + +ORDINARY_LINES = { + "synapse warning": "2026-09-24 22:26:57,522 - synapse.config.logger - 378 - WARNING - main - Server version 1.161.0", + "loguru info": "2026-09-25 13:01:35 | INFO | [stacker-bot] Running", + "paperless info": "[2026-09-25 15:30:00,256] [INFO] [celery.app.trace] Task succeeded: 'No new documents were added'", + "forgejo info": "2026/09/25 15:31:07 ...eb/routing/logger.go:102:func1() [I] router: completed GET /family/brain.git 200 OK", + "postgres log": "2026-09-25 13:28:02.511 UTC [28] LOG: checkpoint complete: wrote 45 buffers (0.3%)", + "the word, lowercase": "2026-09-25 13:01:35 | INFO | [archivist-bot] retried: 0 errors", + "a path": "2026/09/25 15:31:07 ...logger.go:102:func1() [I] router: completed GET /api/error_reports 200 OK", + "a traceback frame": ' File "/app/bot.py", line 12, in run', +} + + +def test_every_services_error_format_is_recognised(): + missed = [name for name, line in ERROR_LINES.items() if not is_error(line)] + assert missed == [] + + +def test_ordinary_lines_that_mention_errors_are_not_counted(): + flagged = [name for name, line in ORDINARY_LINES.items() if is_error(line)] + assert flagged == [] + + +# ── Summarising a container's log ────────────────────────────────────────── + +def _stamped(at: str, line: str) -> str: + return f"2026-09-25T{at}.000000000Z {line}" + + +def test_a_quiet_log_reports_nothing(): + log = "\n".join(_stamped("10:00:00", line) for line in ORDINARY_LINES.values()) + assert scan(log) == {"count": 0, "last_at": None, "lines": []} + + +def test_stdout_and_stderr_are_merged_back_into_time_order(): + # docker logs hands both streams back one after the other, so the + # newest error can come first in the text. + stdout = _stamped("12:00:00", ERROR_LINES["loguru bot"]) + stderr = _stamped("09:00:00", ERROR_LINES["python traceback"]) + result = scan(stdout + "\n" + stderr) + assert result["count"] == 2 + assert result["last_at"] == "2026-09-25T12:00:00.000000000Z" + assert [line["text"] for line in result["lines"]] == [ + ERROR_LINES["python traceback"], ERROR_LINES["loguru bot"]] + + +def test_only_the_newest_errors_are_kept_but_all_are_counted(): + log = "\n".join(_stamped(f"10:00:{s:02d}", f"| ERROR | failure {s}") for s in range(20)) + result = scan(log, keep=3) + assert result["count"] == 20 + assert [line["text"] for line in result["lines"]] == [ + "| ERROR | failure 17", "| ERROR | failure 18", "| ERROR | failure 19"] From 4e4810cc6761fdeb3976ba71ffcd4336b3391e22 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:38:16 +0200 Subject: [PATCH 05/19] feat(cli): add stack host for memory, disk and per-stacklet usage `stack host` prints the machine's memory, disk and uptime, and each stacklet's memory and CPU summed over its containers, largest first. `--json` gives the same for scripts. The per-stacklet figures come from `docker stats`, which samples for about two seconds. A stacklet that runs on the host rather than in containers, such as the AI server, has none. --- lib/stack/cli.py | 66 ++++++++++++++++++++++- lib/stack/docker.py | 43 +++++++++++++++ tests/unit/framework/test_docker_stats.py | 17 ++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/unit/framework/test_docker_stats.py diff --git a/lib/stack/cli.py b/lib/stack/cli.py index b40692c9..8c5c2479 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -23,7 +23,9 @@ import json import os import re +import subprocess import sys +import time import tomllib from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict @@ -1299,7 +1301,7 @@ def handle_logs(stck, args): print(output) -# ── errors: what an admin checks first ──────────────────────────────────── +# ── errors, host: what an admin checks first ────────────────────────────── _DURATION = re.compile(r"^\d+[smh]$") @@ -1346,6 +1348,64 @@ def handle_errors(stck, args): print(f" {DIM}more:{RESET} {TEAL}./stack logs {entry['stacklet']}{RESET}\n") +def _uptime_seconds() -> int | None: + """Seconds since boot, from `sysctl kern.boottime` (`{ sec = 1727..., ...}`).""" + try: + r = subprocess.run(["sysctl", "-n", "kern.boottime"], + capture_output=True, text=True, timeout=5) + match = re.search(r"sec = (\d+)", r.stdout) + return int(time.time()) - int(match.group(1)) if match else None + except Exception: + return None + + +def handle_host(stck, args): + """The machine: memory, disk, uptime, and what each stacklet uses. + + Stacklet figures come from `docker stats`, so a stacklet that runs on + the host itself (the AI server) has none: its processes are not + containers. + """ + known = {s["id"] for s in stck.discover()} + stats = docker.container_stats() + per_stacklet: dict[str, dict] = {} + for c in docker.stack_containers(): + if c["stacklet"] not in known or c["name"] not in stats: + continue + entry = per_stacklet.setdefault( + c["stacklet"], {"id": c["stacklet"], "memory_bytes": 0, "cpu_pct": 0.0, "containers": 0}) + entry["memory_bytes"] += stats[c["name"]]["memory_bytes"] + entry["cpu_pct"] = round(entry["cpu_pct"] + stats[c["name"]]["cpu_pct"], 1) + entry["containers"] += 1 + + report = { + **stck._host_stats(), + "uptime_seconds": _uptime_seconds(), + "stacklets": sorted(per_stacklet.values(), key=lambda e: e["memory_bytes"], reverse=True), + } + + if args.json: + json.dump(report, sys.stdout, indent=2) + print() + return + + print(f"\n {BOLD}Host{RESET}") + if "memory_total_gb" in report: + print(f" Memory {report.get('memory_used_gb', '?')} of {report['memory_total_gb']} GB used") + if "disk_total_gb" in report: + print(f" Disk {report['disk_free_gb']} GB free of {report['disk_total_gb']} GB " + f"({report['disk_used_pct']}% used)") + if report["uptime_seconds"] is not None: + days, rest = divmod(report["uptime_seconds"], 86400) + print(f" Up {days} days, {rest // 3600} hours") + if report["stacklets"]: + print(f"\n {BOLD}Stacklets by memory{RESET}") + for e in report["stacklets"]: + print(f" {e['id']:<12}{e['memory_bytes'] / 1024 ** 3:>6.2f} GB" + f" {e['cpu_pct']:>5.1f}% CPU {DIM}{e['containers']} containers{RESET}") + print() + + def handle_restart(stck, args): cli = CLI(stck) @@ -1950,6 +2010,7 @@ def _plugin_help(module_path: str): "setup": handle_setup, "logs": handle_logs, "errors": handle_errors, + "host": handle_host, "version": handle_version, "update": handle_update, } @@ -1972,6 +2033,7 @@ def _plugin_help(module_path: str): ("env ", "Print rendered environment variables"), ("logs ", "Tail container logs"), ("errors [--since 24h]", "Recent error lines across every stacklet"), + ("host", "Memory, disk, uptime, and what each stacklet uses"), ]), ("Setup", [ ("update []", "Move the checkout to a release (says what to restart)"), @@ -2096,6 +2158,8 @@ def main(): p = sub.add_parser("errors") p.add_argument("--since", default="24h", help="How far back to look: 30m, 24h, 168h") p.add_argument("--json", action="store_true", help="Output as JSON") + p = sub.add_parser("host") + p.add_argument("--json", action="store_true", help="Output as JSON") # Stacklet CLI plugins stacklet_cmds = {} diff --git a/lib/stack/docker.py b/lib/stack/docker.py index e002c76d..51e5faeb 100644 --- a/lib/stack/docker.py +++ b/lib/stack/docker.py @@ -409,6 +409,49 @@ def container_logs(name: str, since: str) -> str: return "" +_SIZE_UNITS = {"b": 1, "kb": 1000, "mb": 1000 ** 2, "gb": 1000 ** 3, "tb": 1000 ** 4, + "kib": 1024, "mib": 1024 ** 2, "gib": 1024 ** 3, "tib": 1024 ** 4} + + +def parse_size(text: str) -> int: + """`docker stats` sizes (`512MiB`, `1.2GiB`, `0B`) as bytes; 0 if unreadable.""" + text = text.strip() + digits = text.rstrip("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + unit = text[len(digits):].lower() + try: + return int(float(digits) * _SIZE_UNITS[unit]) + except (ValueError, KeyError): + return 0 + + +def container_stats() -> dict[str, dict]: + """Memory and CPU of every running container, keyed by name. + + `docker stats --no-stream` samples for about two seconds, so this is + for a command someone asked for, not for anything run on every poll. + """ + try: + r = _docker("stats", "--no-stream", "--format", + "{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}", + capture_output=True, text=True, timeout=30) + if r.returncode != 0: + return {} + except Exception: + return {} + stats = {} + for line in r.stdout.strip().splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + name, mem, cpu = parts + try: + cpu_pct = float(cpu.strip().rstrip("%")) + except ValueError: + cpu_pct = 0.0 + stats[name] = {"memory_bytes": parse_size(mem.split("/")[0]), "cpu_pct": cpu_pct} + return stats + + def _parse_env(text: str) -> dict: """Turn `docker inspect`'s KEY=VALUE lines into a dict.""" env = {} diff --git a/tests/unit/framework/test_docker_stats.py b/tests/unit/framework/test_docker_stats.py new file mode 100644 index 00000000..5056567d --- /dev/null +++ b/tests/unit/framework/test_docker_stats.py @@ -0,0 +1,17 @@ +"""`stack host` reads memory from `docker stats`, which prints sizes in its +own units: binary (`MiB`, `GiB`) for memory, decimal (`kB`, `MB`) for I/O.""" + +from stack.docker import parse_size + + +def test_docker_stats_sizes_read_as_bytes(): + assert parse_size("512MiB") == 512 * 1024 ** 2 + assert parse_size("1.5GiB") == int(1.5 * 1024 ** 3) + assert parse_size("7.8kB") == 7800 + assert parse_size("0B") == 0 + + +def test_an_unreadable_size_counts_as_nothing(): + # A container that is starting reports `--` until its first sample. + assert parse_size("--") == 0 + assert parse_size("") == 0 From dbc7d76c4b33d5193c4816dde9a532f02d62b499 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:43:00 +0200 Subject: [PATCH 06/19] feat: show problems, recent errors and memory in the menu bar app The panel now opens with a verdict and a list of what needs the admin: `stack doctor` findings, backups that are missing, failed or overdue, and a disk above 90%. Each item shows its fix command; `up`, `restart` and `down` run in the background, anything else opens in Terminal. Below it: memory per stacklet, the containers that logged errors in the last 24 hours with their newest line, the last backup, and disk, memory and uptime. These come from `stack doctor --json`, `stack errors`, `stack host` and `stack backup status`, read when the panel opens and every ten minutes. The icon warns only on errors, so a standing warning such as "no backup set up" does not keep it lit. Against an older checkout without those commands, their sections are left out. --- apps/macos/README.md | 27 ++- apps/macos/Sources/StackMenu/MenuPanel.swift | 225 ++++++++++++++++-- apps/macos/Sources/StackMenu/Model.swift | 219 ++++++++++++++++- apps/macos/Sources/StackMenu/StackCLI.swift | 98 ++++++-- apps/macos/Sources/StackMenu/StackStore.swift | 93 +++++++- 5 files changed, 581 insertions(+), 81 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index cabf7180..96972e4c 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -1,13 +1,24 @@ # Menu bar app (prototype) -A native macOS menu bar app for the stack on this Mac: what is running, what -is failing, start, stop and restart, disk and memory. - -It is a client of the `stack` CLI and nothing else. It runs `./stack status ---json` in a checkout every minute and when the panel opens, and runs -`./stack up|down|restart ` in the background. Anything interactive or -endless (a first install, logs, doctor) opens in Terminal. It starts no -services and keeps no state, so the CLI stays the one owner of the lifecycle. +A native macOS menu bar app for the stack on this Mac. It answers, in this +order: is everything fine, what needs me, what is running, what broke +recently, is the data backed up, and how full is the machine. + +- **Needs attention**: `stack doctor` findings, backups (missing, failed or + overdue), a disk above 90%. Each item carries its fix command; `up`, + `restart` and `down` run in the background, anything else opens in Terminal. +- **Stacklets**: state, memory, port; start, stop, restart, open, logs. +- **Errors**: containers that logged errors in the last 24 hours, newest first. +- **This Mac**: disk, memory, uptime. + +The icon turns into a warning only for errors: a failing stacklet, a doctor +error, a failed backup. Warnings stay in the panel. + +It is a client of the `stack` CLI and nothing else: `status --json` every +minute, and `doctor --json`, `errors --json`, `host --json` and `backup +status` when the panel opens and every ten minutes. It starts no services and +keeps no state, so the CLI stays the one owner of the lifecycle. Against a +checkout older than those commands, the sections that need them are left out. ## Build and run diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index c51e8831..05d0197f 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -1,6 +1,10 @@ import SwiftUI // ── The panel under the menu bar icon ───────────────────────────────────── +// +// Ordered by the questions an admin opens it with: is everything fine, what +// needs me, what is running, what broke recently, is the data backed up, +// how full is the machine. struct MenuPanel: View { @ObservedObject var store: StackStore @@ -20,49 +24,90 @@ struct MenuPanel: View { failureNotice(failure) } if let status = store.status { + let attention = store.attention + if !attention.isEmpty { + Section("Needs attention") { + ForEach(attention) { AttentionRow(item: $0, store: store) } + } + } if !status.stale.isEmpty { staleNotice(status.stale) } - stackletList(status) - if let host = status.host { - Divider() - HostMetrics(host: host) + Section("Stacklets") { stackletList(status) } + if let errors = store.errors { + Section("Errors, last \(errors.since)") { ErrorsList(report: errors, store: store) } + } + if let line = backupLine(status) { + Section("Backup") { + Text(line).font(.callout).foregroundStyle(.secondary) + } + } + if let figures = store.hostFigures { + Section("This Mac") { HostMetrics(figures: figures) } } } Divider() footer } .padding(12) - .frame(width: 320) - .task { await store.refresh() } + .frame(width: 340) + .task { await store.panelOpened() } } - // ── Sections ───────────────────────────────────────────────────────── + // ── Header: the verdict ────────────────────────────────────────────── private var header: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { - Text("famstack").font(.headline) + HStack(spacing: 6) { + Text("famstack").font(.headline) + if store.status != nil { verdict } + } if let status = store.status { - let running = status.installed.filter { $0.online }.count - Text("\(running) of \(status.installed.count) running · \(status.version)") + Text(subtitle(status)) .font(.caption).foregroundStyle(.secondary) .lineLimit(1).truncationMode(.middle) } } Spacer() - if store.refreshing { + if store.refreshing || store.checking { ProgressView().controlSize(.small) } else { - Button { Task { await store.refresh() } } label: { Image(systemName: "arrow.clockwise") } + Button { + Task { + await store.refresh() + await store.check() + } + } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) - .help("Refresh") + .help("Check again") } } } + private var verdict: some View { + let items = store.attention.filter { $0.level >= .warn } + let failing = store.status?.installed.filter { $0.health.needsAttention }.count ?? 0 + let count = items.count + failing + let worst = failing > 0 || items.contains { $0.level == .error } + return Text(count == 0 ? "All good" : "\(count) to look at") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 6).padding(.vertical, 1) + .background((count == 0 ? Color.green : worst ? .red : .orange).opacity(0.18), in: Capsule()) + } + + private func subtitle(_ status: StackStatus) -> String { + let running = status.installed.filter { $0.online }.count + var parts = ["\(running) of \(status.installed.count) running", status.version] + if let up = store.host?.uptimeSeconds { parts.append("up \(up / 86400) days") } + return parts.joined(separator: " · ") + } + + // ── Stacklets ──────────────────────────────────────────────────────── + private func stackletList(_ status: StackStatus) -> some View { VStack(alignment: .leading, spacing: 2) { ForEach(status.installed) { stacklet in - StackletRow(stacklet: stacklet, busyVerb: store.busy[stacklet.id], store: store) + StackletRow(stacklet: stacklet, busyVerb: store.busy[stacklet.id], + memory: store.memory(of: stacklet.id), store: store) } if !status.available.isEmpty { DisclosureGroup("Not installed (\(status.available.count))", isExpanded: $showAvailable) { @@ -78,11 +123,27 @@ struct MenuPanel: View { } } .font(.callout) - .padding(.top, 6) + .padding(.top, 4) } } } + // ── Backup ─────────────────────────────────────────────────────────── + + /// The healthy case only; anything wrong with backups is in the + /// attention list, which says what to do about it. + private func backupLine(_ status: StackStatus) -> String? { + guard status.isInstalled("backup"), let report = store.backup else { return nil } + let lines = report.targets.compactMap { target -> String? in + guard let run = target.lastRun, run.success, + let ended = run.endedAt.flatMap(parseTimestamp) else { return nil } + return "Last backup to \(target.disk) \(relative(ended))" + } + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + + // ── Notices ────────────────────────────────────────────────────────── + private func staleNotice(_ stale: [String]) -> some View { HStack { Image(systemName: "arrow.up.circle").foregroundStyle(.blue) @@ -123,6 +184,9 @@ struct MenuPanel: View { Button("Checkout…") { store.chooseCheckout() } .help(store.checkout?.path ?? "No checkout chosen") Spacer() + if let checked = store.checkedAt { + Text("checked \(relative(checked))").font(.caption2).foregroundStyle(.tertiary) + } Button("Quit") { NSApp.terminate(nil) } } .buttonStyle(.borderless) @@ -130,11 +194,69 @@ struct MenuPanel: View { } } -// ── One installed stacklet ──────────────────────────────────────────────── +// ── Building blocks ─────────────────────────────────────────────────────── + +struct Section: View { + let title: String + @ViewBuilder let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + content + } + } +} + +struct AttentionRow: View { + let item: Attention + @ObservedObject var store: StackStore + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon).foregroundStyle(tint).frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.callout).lineLimit(2) + Text(item.detail).font(.caption).foregroundStyle(.secondary).lineLimit(3) + if let fix = item.fix { + Button { store.apply(fix: fix) } label: { + Text(fix).font(.caption.monospaced()) + } + .buttonStyle(.link) + .help("Run \(fix)") + } + } + } + .padding(.vertical, 2) + } + + private var icon: String { + switch item.level { + case .error: "xmark.octagon.fill" + case .warn: "exclamationmark.triangle.fill" + case .info: "info.circle" + } + } + + private var tint: Color { + switch item.level { + case .error: .red + case .warn: .orange + case .info: .secondary + } + } +} struct StackletRow: View { let stacklet: Stacklet let busyVerb: String? + let memory: Int? @ObservedObject var store: StackStore @State private var hovering = false @@ -152,20 +274,27 @@ struct StackletRow: View { Text(busyVerb).font(.caption).foregroundStyle(.secondary) ProgressView().controlSize(.small) } else { - Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + if let memory, memory > 0 { + Text(ByteCountFormatter.string(fromByteCount: Int64(memory), countStyle: .memory)) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.tertiary) + .frame(minWidth: 44, alignment: .trailing) actions } } - .padding(.vertical, 4) + .padding(.vertical, 3) .padding(.horizontal, 6) .background(hovering ? Color.primary.opacity(0.08) : .clear, in: RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) .onHover { hovering = $0 } .onTapGesture { if stacklet.online { store.openInBrowser(stacklet) } } + .help(stacklet.online && stacklet.port != nil ? "Open in the browser" : "") } private var detail: String { if stacklet.health == .healthy, let port = stacklet.port { return ":\(port)" } + if stacklet.health == .healthy { return "" } return stacklet.health.label } @@ -192,19 +321,63 @@ struct StackletRow: View { } } -// ── Host figures from `stack status` ────────────────────────────────────── +struct ErrorsList: View { + let report: ErrorsReport + @ObservedObject var store: StackStore + private static let shown = 4 + + var body: some View { + if report.containers.isEmpty { + Label("No errors in \(report.scanned) containers", systemImage: "checkmark.circle") + .font(.callout).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(report.containers.prefix(Self.shown)) { entry in + Button { store.openInTerminal(["logs", entry.stacklet]) } label: { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(entry.container).font(.callout) + Spacer() + Text(summary(entry)).font(.caption).foregroundStyle(.secondary) + } + if let last = entry.lines.last { + Text(last.text).font(.caption2.monospaced()).foregroundStyle(.secondary) + .lineLimit(2).truncationMode(.tail) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open the logs in Terminal") + } + if report.containers.count > Self.shown { + Button("\(report.containers.count - Self.shown) more…") { + store.openInTerminal(["errors"]) + } + .buttonStyle(.link).font(.caption) + } + } + } + } + + private func summary(_ entry: ErrorsReport.ContainerErrors) -> String { + let when = entry.lastAt.flatMap(parseTimestamp).map { relative($0) } ?? "" + return "\(entry.count)× \(when)" + } +} struct HostMetrics: View { - let host: StackStatus.Host + let figures: HostFigures var body: some View { VStack(alignment: .leading, spacing: 6) { - Meter(label: "Disk", - value: host.diskUsedPct / 100, - detail: "\(Int(host.diskFreeGb)) GB free of \(Int(host.diskTotalGb))") - Meter(label: "Memory", - value: host.memoryUsedGb / max(host.memoryTotalGb, 1), - detail: String(format: "%.1f of %.0f GB", host.memoryUsedGb, host.memoryTotalGb)) + if let pct = figures.diskUsedPct, let free = figures.diskFreeGb, let total = figures.diskTotalGb { + Meter(label: "Disk", value: pct / 100, detail: "\(Int(free)) GB free of \(Int(total))") + } + if let used = figures.memoryUsedGb, let total = figures.memoryTotalGb { + Meter(label: "Memory", value: used / max(total, 1), + detail: String(format: "%.1f of %.0f GB", used, total)) + } } } } diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift index d5a3b048..8495fcfc 100644 --- a/apps/macos/Sources/StackMenu/Model.swift +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -1,26 +1,29 @@ import Foundation -// ── What `./stack status --json` returns ────────────────────────────────── +// ── What the CLI returns ────────────────────────────────────────────────── // -// Only the fields the menu reads are declared; the decoder ignores the rest, -// so the CLI can grow its output without breaking the app. +// Only the fields the panel reads are declared; the decoder ignores the rest, +// so the CLI can grow its output without breaking the app. Keys arrive in +// snake_case and are converted. +/// `stack status --json`: polled every minute, cheap (about half a second). struct StackStatus: Decodable { let version: String - let host: Host? + let host: HostFigures? let stacklets: [Stacklet] let stale: [String] - struct Host: Decodable { - let memoryTotalGb: Double - let memoryUsedGb: Double - let diskTotalGb: Double - let diskFreeGb: Double - let diskUsedPct: Double - } - var installed: [Stacklet] { stacklets.filter(\.enabled) } var available: [Stacklet] { stacklets.filter { !$0.enabled } } + func isInstalled(_ id: String) -> Bool { installed.contains { $0.id == id } } +} + +struct HostFigures: Decodable { + let memoryTotalGb: Double? + let memoryUsedGb: Double? + let diskTotalGb: Double? + let diskFreeGb: Double? + let diskUsedPct: Double? } struct Stacklet: Decodable, Identifiable { @@ -74,3 +77,195 @@ enum Health { } } } + +/// `stack doctor --json`. +struct DoctorReport: Decodable { + let summary: String + let findings: [Finding] + + struct Finding: Decodable { + let level: String + let title: String + let detail: String + let fix: String + } +} + +/// `stack errors --json`. +struct ErrorsReport: Decodable { + let since: String + let scanned: Int + let containers: [ContainerErrors] + + struct ContainerErrors: Decodable, Identifiable { + let stacklet: String + let container: String + let count: Int + let lastAt: String? + let lines: [Line] + + var id: String { container } + } + + struct Line: Decodable { + let at: String? + let text: String + } +} + +/// `stack host --json`. +struct HostReport: Decodable { + let memoryTotalGb: Double? + let memoryUsedGb: Double? + let diskTotalGb: Double? + let diskFreeGb: Double? + let diskUsedPct: Double? + let uptimeSeconds: Int? + let stacklets: [Usage] + + struct Usage: Decodable { + let id: String + let memoryBytes: Int + let cpuPct: Double + } + + var figures: HostFigures { + HostFigures(memoryTotalGb: memoryTotalGb, memoryUsedGb: memoryUsedGb, + diskTotalGb: diskTotalGb, diskFreeGb: diskFreeGb, diskUsedPct: diskUsedPct) + } +} + +/// `stack backup status`, which prints JSON whenever its output is piped. +struct BackupReport: Decodable { + let targets: [Target] + + struct Target: Decodable { + let name: String + let disk: String + let schedule: String + let diskMounted: Bool + let cronInstalled: Bool + let canary: String + let lastRun: Run? + } + + struct Run: Decodable { + let success: Bool + let failureReason: String? + let endedAt: String? + } +} + +// ── Needs attention ─────────────────────────────────────────────────────── + +/// One thing the admin should look at, with the command that addresses it. +struct Attention: Identifiable { + enum Level: Int, Comparable { + case info, warn, error + static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue } + } + + let id: String + let level: Level + let title: String + let detail: String + /// A command line as the CLI prints it, `stack up core`, or nil. + let fix: String? + + /// Everything that needs the admin, worst first. Stacklet rows already + /// show which stacklets are failing; this adds what a row cannot: the + /// doctor's diagnoses, backups and the disk. + static func collect(status: StackStatus?, doctor: DoctorReport?, backup: BackupReport?, + disk: HostFigures?, now: Date = Date()) -> [Attention] { + var items: [Attention] = [] + + // Stale code already has its own notice with one button for all of + // it; doctor's per-stacklet `stack restart ` would repeat it. + let staleFixes = Set((status?.stale ?? []).map { "stack restart \($0)" }) + + for (i, finding) in (doctor?.findings ?? []).enumerated() where !staleFixes.contains(finding.fix) { + let level: Level = switch finding.level { + case "error": .error + case "warn": .warn + default: .info + } + items.append(Attention(id: "doctor-\(i)", level: level, title: finding.title, + detail: finding.detail, fix: finding.fix.isEmpty ? nil : finding.fix)) + } + + if let status { + if !status.isInstalled("backup") { + items.append(Attention( + id: "backup-missing", level: .warn, title: "Backups are not set up", + detail: "Nothing copies the data in this Mac's data dir to a second disk.", + fix: "stack up backup")) + } else if let backup { + items += backupItems(backup, now: now) + } + } + + if let pct = disk?.diskUsedPct, pct >= 90 { + let free = disk?.diskFreeGb.map { "\(Int($0)) GB left" } ?? "Little space left" + items.append(Attention( + id: "disk", level: pct >= 95 ? .error : .warn, + title: "Disk is \(Int(pct))% full", + detail: "\(free) on the disk that holds the data dir.", fix: nil)) + } + + return items.sorted { $0.level > $1.level } + } + + /// A scheduled backup older than two days has missed at least one run. + private static let overdue: TimeInterval = 2 * 24 * 3600 + + private static func backupItems(_ report: BackupReport, now: Date) -> [Attention] { + report.targets.flatMap { target -> [Attention] in + var items: [Attention] = [] + let id = "backup-\(target.name)" + if target.canary == "tampered" { + items.append(Attention( + id: id + "-canary", level: .error, title: "Backup tripwire on \(target.disk) changed", + detail: "Something rewrote files on the backup disk. Check it before the next run.", + fix: "stack backup status")) + } + guard let run = target.lastRun else { + items.append(Attention(id: id, level: .error, title: "No backup to \(target.disk) has run yet", + detail: "Schedule: \(target.schedule)", fix: "stack backup sync")) + return items + } + if !run.success { + items.append(Attention(id: id, level: .error, title: "Last backup to \(target.disk) failed", + detail: run.failureReason ?? "No reason recorded.", + fix: "stack backup status")) + } else if let ended = run.endedAt.flatMap(parseTimestamp), now.timeIntervalSince(ended) > overdue { + items.append(Attention(id: id, level: .warn, + title: "Last backup to \(target.disk) was \(relative(ended, now))", + detail: target.diskMounted ? "The disk is connected." : "The disk is not connected.", + fix: "stack backup sync")) + } + return items + } + } +} + +// ── Time ────────────────────────────────────────────────────────────────── + +/// The CLI's timestamps are ISO 8601 in UTC, from whole seconds (backups) to +/// nanoseconds (`docker logs`). Fractions beyond milliseconds are dropped, +/// which the system parser needs and a panel does not miss. +func parseTimestamp(_ text: String) -> Date? { + var s = text + if let dot = s.firstIndex(of: "."), let z = s.firstIndex(of: "Z"), dot < z { + let fraction = s[s.index(after: dot).. String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: now) +} diff --git a/apps/macos/Sources/StackMenu/StackCLI.swift b/apps/macos/Sources/StackMenu/StackCLI.swift index 9920ea05..3e5bcb3f 100644 --- a/apps/macos/Sources/StackMenu/StackCLI.swift +++ b/apps/macos/Sources/StackMenu/StackCLI.swift @@ -12,25 +12,27 @@ struct StackCLI { struct Result { let exitCode: Int32 - let output: String + let stdout: String + let stderr: String var succeeded: Bool { exitCode == 0 } - /// The last lines are where the CLI says what went wrong. + /// The last lines are where the CLI says what went wrong. Both + /// streams, because commands print their progress to either. func tail(_ lines: Int = 6) -> String { - output.split(separator: "\n", omittingEmptySubsequences: true) + (stdout + "\n" + stderr).split(separator: "\n", omittingEmptySubsequences: true) .suffix(lines).joined(separator: "\n") } } enum Failure: LocalizedError { case exited(Result) - case undecodable(String) + case undecodable(command: String, reason: String) var errorDescription: String? { switch self { case .exited(let result): result.tail(3) - case .undecodable(let reason): "Unexpected output from stack status: \(reason)" + case .undecodable(let command, let reason): "Unexpected output from stack \(command): \(reason)" } } } @@ -43,18 +45,39 @@ struct StackCLI { && fm.fileExists(atPath: url.appendingPathComponent("stacklets").path) } - func status() async throws -> StackStatus { - let result = await run(["status", "--json"]) - guard result.succeeded else { throw Failure.exited(result) } + // ── Reading ────────────────────────────────────────────────────────── + + func status() async throws -> StackStatus { try await json(["status", "--json"]) } + func doctor() async throws -> DoctorReport { try await json(["doctor", "--json"]) } + func errors() async throws -> ErrorsReport { try await json(["errors", "--json"]) } + func host() async throws -> HostReport { try await json(["host", "--json"]) } + /// Prints JSON because its output is a pipe; it takes no `--json`. + func backup() async throws -> BackupReport { try await json(["backup", "status"]) } + + /// Runs a command and decodes its JSON output. + /// + /// The exit code alone does not decide: `doctor` exits 1 when it has + /// found an error and still prints its report. Anything before the first + /// `{` line is skipped, such as a one-time notice the CLI prints on the + /// first run after a config change. + private func json(_ arguments: [String]) async throws -> T { + let result = await run(arguments) + let lines = result.stdout.split(separator: "\n", omittingEmptySubsequences: false) + guard let start = lines.firstIndex(where: { $0.hasPrefix("{") }) else { + throw Failure.exited(result) + } + let body = lines[start...].joined(separator: "\n") let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase do { - return try decoder.decode(StackStatus.self, from: Data(result.output.utf8)) + return try decoder.decode(T.self, from: Data(body.utf8)) } catch { - throw Failure.undecodable(String(describing: error)) + throw Failure.undecodable(command: arguments.joined(separator: " "), reason: String(describing: error)) } } + // ── Running ────────────────────────────────────────────────────────── + /// Runs `./stack ` to completion off the main thread. /// /// The working directory is the checkout because the CLI finds its root @@ -71,35 +94,58 @@ struct StackCLI { process.currentDirectoryURL = checkout process.environment = Self.environment() process.standardInput = FileHandle.nullDevice - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe + let out = Pipe(), err = Pipe() + process.standardOutput = out + process.standardError = err do { try process.run() } catch { - continuation.resume(returning: Result(exitCode: -1, output: error.localizedDescription)) + continuation.resume(returning: Result(exitCode: -1, stdout: "", stderr: error.localizedDescription)) return } - let data = pipe.fileHandleForReading.readDataToEndOfFile() + // Both pipes drain at once: a command that fills one while + // the other is being read would otherwise block forever. + let errData = Collected() + let group = DispatchGroup() + group.enter() + DispatchQueue.global().async { + errData.data = err.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + let outData = out.fileHandleForReading.readDataToEndOfFile() + group.wait() process.waitUntilExit() - let text = String(decoding: data, as: UTF8.self) - continuation.resume(returning: Result(exitCode: process.terminationStatus, - output: Self.stripANSI(text))) + continuation.resume(returning: Result( + exitCode: process.terminationStatus, + stdout: Self.stripANSI(String(decoding: outData, as: UTF8.self)), + stderr: Self.stripANSI(String(decoding: errData.data, as: UTF8.self)))) } } } + private final class Collected: @unchecked Sendable { var data = Data() } + /// Opens Terminal on `./stack ` in the checkout. Used for - /// anything interactive or endless: first installs, logs, doctor. - /// - /// A `.command` file opens in Terminal through Launch Services, which - /// needs no Automation permission, unlike scripting Terminal directly. + /// anything interactive or long to read: first installs, logs, doctor. func openInTerminal(_ arguments: [String]) { - let quoted = ([checkout.appendingPathComponent("stack").path] + arguments) + let line = ([checkout.appendingPathComponent("stack").path] + arguments) .map(Self.shellQuote).joined(separator: " ") - let script = "#!/bin/zsh -l\ncd \(Self.shellQuote(checkout.path)) || exit 1\n\(quoted)\n" - let file = FileManager.default.temporaryDirectory - .appendingPathComponent("stack-\(arguments.joined(separator: "-")).command") + openInTerminal(commandLine: line, name: arguments.joined(separator: "-")) + } + + /// Opens Terminal on a command line exactly as the CLI printed it, such + /// as a doctor fix. `stack …` runs this checkout's wrapper. + func openInTerminal(fix: String) { + let line = fix.hasPrefix("stack ") ? "./" + fix : fix + openInTerminal(commandLine: line, name: "fix") + } + + /// A `.command` file opens in Terminal through Launch Services, which + /// needs no Automation permission, unlike scripting Terminal directly. + private func openInTerminal(commandLine: String, name: String) { + let script = "#!/bin/zsh -l\ncd \(Self.shellQuote(checkout.path)) || exit 1\n\(commandLine)\n" + let safeName = name.filter { $0.isLetter || $0.isNumber || $0 == "-" } + let file = FileManager.default.temporaryDirectory.appendingPathComponent("stack-\(safeName).command") do { try script.write(to: file, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift index c69d1a99..4227f6d3 100644 --- a/apps/macos/Sources/StackMenu/StackStore.swift +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -1,7 +1,12 @@ import AppKit import Foundation -// ── App state: the last status, what is running, what failed ────────────── +// ── App state: what the CLI last said, what is running, what failed ─────── +// +// Two refresh rates. `status` is cheap and drives the icon, so it runs every +// minute. The rest (doctor, errors, host, backup) take two to three seconds +// each, so they run when the panel opens, at most once a minute, and every +// ten minutes in the background so the icon also reflects the doctor. @MainActor final class StackStore: ObservableObject { @@ -9,6 +14,14 @@ final class StackStore: ObservableObject { @Published private(set) var status: StackStatus? @Published private(set) var loadError: String? @Published private(set) var refreshing = false + + @Published private(set) var doctor: DoctorReport? + @Published private(set) var errors: ErrorsReport? + @Published private(set) var host: HostReport? + @Published private(set) var backup: BackupReport? + @Published private(set) var checking = false + @Published private(set) var checkedAt: Date? + /// Stacklet id (or "" for the whole stack) to the verb running on it. @Published private(set) var busy: [String: String] = [:] @Published var lastFailure: ActionFailure? @@ -20,15 +33,24 @@ final class StackStore: ObservableObject { } private static let checkoutKey = "checkout" - private static let pollInterval: TimeInterval = 60 - private var timer: Timer? + private static let statusInterval: TimeInterval = 60 + private static let detailInterval: TimeInterval = 600 + private var timers: [Timer] = [] init() { checkout = Self.resolveCheckout() - timer = Timer.scheduledTimer(withTimeInterval: Self.pollInterval, repeats: true) { [weak self] _ in - Task { @MainActor in await self?.refresh() } + timers = [ + Timer.scheduledTimer(withTimeInterval: Self.statusInterval, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.refresh() } + }, + Timer.scheduledTimer(withTimeInterval: Self.detailInterval, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.check() } + }, + ] + Task { + await refresh() + await check() } - Task { await refresh() } } private var cli: StackCLI? { checkout.map(StackCLI.init) } @@ -47,12 +69,49 @@ final class StackStore: ObservableObject { } } - /// What the menu bar icon shows, worst state first. + /// The slower reports, all at once. One failing leaves the others. + func check() async { + guard let cli, !checking else { return } + checking = true + defer { checking = false } + let wantsBackup = status?.isInstalled("backup") ?? false + async let doctor = try? cli.doctor() + async let errors = try? cli.errors() + async let host = try? cli.host() + async let backup = wantsBackup ? try? cli.backup() : nil + (self.doctor, self.errors, self.host, self.backup) = await (doctor, errors, host, backup) + checkedAt = Date() + } + + /// When the panel opens: status always, the rest unless just done. + func panelOpened() async { + await refresh() + if checkedAt.map({ Date().timeIntervalSince($0) > 60 }) ?? true { + await check() + } + } + + // ── Derived ────────────────────────────────────────────────────────── + + var hostFigures: HostFigures? { host?.figures ?? status?.host } + + var attention: [Attention] { + Attention.collect(status: status, doctor: doctor, backup: backup, disk: hostFigures) + } + + func memory(of stacklet: String) -> Int? { + host?.stacklets.first { $0.id == stacklet }?.memoryBytes + } + + /// What the menu bar icon shows, worst state first. Only errors turn it + /// into a warning: a warning that never clears (no backup set up) would + /// teach the admin to stop looking at the icon. var summary: Summary { guard checkout != nil else { return .unconfigured } guard let status, loadError == nil else { return .unreachable } if !busy.isEmpty { return .busy } if status.installed.contains(where: { $0.health.needsAttention }) { return .attention } + if attention.contains(where: { $0.level == .error }) { return .attention } return .healthy } @@ -71,7 +130,7 @@ final class StackStore: ObservableObject { // ── Acting ─────────────────────────────────────────────────────────── - /// Runs a lifecycle verb in the background, then re-reads the status. + /// Runs a lifecycle verb in the background, then re-reads everything. /// One verb per stacklet at a time; the CLI serialises the rest. func perform(_ verb: String, _ stacklet: String? = nil) async { guard let cli else { return } @@ -85,6 +144,18 @@ final class StackStore: ObservableObject { lastFailure = ActionFailure(command: command, message: result.tail()) } await refresh() + await check() + } + + /// A doctor fix: `stack up|restart|down ` runs in the background like + /// the row actions; anything else opens in Terminal, where it can be read. + func apply(fix: String) { + let words = fix.split(separator: " ").map(String.init) + if words.count == 3, words[0] == "stack", ["up", "restart", "down"].contains(words[1]) { + Task { await perform(words[1], words[2]) } + } else { + cli?.openInTerminal(fix: fix) + } } func openInTerminal(_ arguments: [String]) { @@ -114,7 +185,11 @@ final class StackStore: ObservableObject { UserDefaults.standard.set(url.path, forKey: Self.checkoutKey) checkout = url status = nil - Task { await refresh() } + doctor = nil; errors = nil; host = nil; backup = nil; checkedAt = nil + Task { + await refresh() + await check() + } } /// The saved choice, else `~/famstack`, where the README's clone lands From a560e78474b50ff67e4a738b2e63d337a397c6c8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:50:51 +0200 Subject: [PATCH 07/19] fix(cli): print the instance's stack.toml when STACK_DIR is set `stack config` read `stack.toml` from the checkout even when `STACK_DIR` points the CLI at another instance, so it printed the wrong file or none. It now reads the file every other command uses. --- lib/stack/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 8c5c2479..fdcd2400 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -1193,7 +1193,7 @@ def handle_config(stck, args): if action == "admin": _config_admin(stck) return - path = stck.root / "stack.toml" + path = stck.instance_dir / "stack.toml" if not path.exists(): print(" No stack.toml found.") return From b8021e27a5db6e4ef5171df865070fed82c94ad9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:51:40 +0200 Subject: [PATCH 08/19] feat(cli): print the configuration as JSON with credentials hidden `stack config --json` prints both files as written, with the paths to them, for scripts and the menu bar app. Any key named like a credential (ending in `key`, `password`, `secret` or `token`) is replaced by `(hidden)` wherever it appears, so an AI server's API key or a password in users.toml never leaves the file. An empty credential stays empty, so set and unset can still be told apart. --- lib/stack/cli.py | 48 +++++++++++++ tests/unit/framework/test_config_report.py | 82 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/unit/framework/test_config_report.py diff --git a/lib/stack/cli.py b/lib/stack/cli.py index fdcd2400..19391cf1 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -1187,12 +1187,58 @@ def handle_list(stck, args): print_list(stck.list(), stck) +# A key named like a credential. Matched on the name, not the value, so a +# secret added to stack.toml or users.toml later is hidden without a change here. +_SECRET_KEY = re.compile(r"(key|password|passwd|secret|token)$", re.IGNORECASE) +_HIDDEN = "(hidden)" + + +def _redact(value): + """`value` with every credential replaced by a marker. An empty one stays + empty, so the reader can still tell set from unset.""" + if isinstance(value, dict): + return {k: (_HIDDEN if _SECRET_KEY.search(k) and v not in ("", None) else _redact(v)) + for k, v in value.items()} + if isinstance(value, list): + return [_redact(v) for v in value] + return value + + +def _read_toml(path: Path) -> dict: + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return {} + + +def config_report(instance_dir: Path) -> dict: + """stack.toml and users.toml as written, without their credentials. + + The raw files, not resolved values: a key the admin left out is absent + here and its default applies. Both paths are included so a reader can + open the file to change it. + """ + stack_toml = instance_dir / "stack.toml" + users_toml = instance_dir / "users.toml" + return { + "stack_toml": str(stack_toml), + "users_toml": str(users_toml), + "config": _redact(_read_toml(stack_toml)), + "users": _redact(_read_toml(users_toml).get("users", [])), + } + + def handle_config(stck, args): """Config subcommands. Bare 'stack config' prints stack.toml.""" action = getattr(args, "config_action", None) if action == "admin": _config_admin(stck) return + if getattr(args, "json", False): + json.dump(config_report(stck.instance_dir), sys.stdout, indent=2, default=str) + print() + return path = stck.instance_dir / "stack.toml" if not path.exists(): print(" No stack.toml found.") @@ -2120,6 +2166,8 @@ def main(): p.add_argument("--json", action="store_true", help="Output as JSON") sub.add_parser("list") p = sub.add_parser("config") + p.add_argument("--json", action="store_true", + help="stack.toml and users.toml as JSON, credentials hidden") config_sub = p.add_subparsers(dest="config_action") config_sub.add_parser("admin") sub.add_parser("help") diff --git a/tests/unit/framework/test_config_report.py b/tests/unit/framework/test_config_report.py new file mode 100644 index 00000000..d5f017e1 --- /dev/null +++ b/tests/unit/framework/test_config_report.py @@ -0,0 +1,82 @@ +"""`stack config --json` shows an instance's setup without its secrets. + +Anything that reads the report (the menu bar app, a script, an agent) sees +the admin's choices, never a credential: stack.toml can hold an API key for +a remote AI server, and users.toml can hold passwords. +""" + +import json + +from stack.cli import config_report + + +STACK_TOML = """ +[core] +name = "famstack" +domain = "home.example" +language = "de" + +[ai] +provider = "remote" +openai_url = "https://ai.example/v1" +openai_key = "sk-live-4f9a" +whisper_key = "" + +[backup.targets.attic] +disk = "Attic" +token = "tok-8812" +""" + +USERS_TOML = """ +[[users]] +id = "homer" +name = "Homer" +role = "admin" +password = "donuts-4ever" + +[[users]] +id = "lisa" +name = "Lisa" +role = "member" +stacklets = ["photos", "docs"] +""" + + +def _instance(tmp_path, stack: str | None = STACK_TOML, users: str | None = USERS_TOML): + if stack is not None: + (tmp_path / "stack.toml").write_text(stack) + if users is not None: + (tmp_path / "users.toml").write_text(users) + return tmp_path + + +def test_no_secret_value_appears_anywhere_in_the_report(tmp_path): + text = json.dumps(config_report(_instance(tmp_path))) + for secret in ("sk-live-4f9a", "tok-8812", "donuts-4ever"): + assert secret not in text + + +def test_the_admins_choices_are_shown_as_written(tmp_path): + report = config_report(_instance(tmp_path)) + assert report["config"]["core"] == {"name": "famstack", "domain": "home.example", "language": "de"} + assert report["config"]["ai"]["openai_url"] == "https://ai.example/v1" + assert [(u["name"], u["role"]) for u in report["users"]] == [("Homer", "admin"), ("Lisa", "member")] + assert report["users"][1]["stacklets"] == ["photos", "docs"] + + +def test_a_hidden_secret_still_says_whether_it_is_set(tmp_path): + ai = config_report(_instance(tmp_path))["config"]["ai"] + assert ai["openai_key"] != "" and ai["openai_key"] != "sk-live-4f9a" + assert ai["whisper_key"] == "" + + +def test_the_report_names_both_files_so_they_can_be_opened(tmp_path): + report = config_report(_instance(tmp_path)) + assert report["stack_toml"] == str(tmp_path / "stack.toml") + assert report["users_toml"] == str(tmp_path / "users.toml") + + +def test_an_instance_without_config_reports_nothing_rather_than_failing(tmp_path): + report = config_report(_instance(tmp_path, stack=None, users=None)) + assert report["config"] == {} + assert report["users"] == [] From 5dbaf8177be8c2512b4667cce801f51412b11a46 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:53:17 +0200 Subject: [PATCH 09/19] feat: show the stack's setup in the menu bar app A collapsed Setup section lists the family from users.toml, port or domain mode, language and time zone, the AI server and model, the update time and the data dir, read from `stack config --json`. Two buttons open `stack.toml` and `users.toml` in the text editor; doctor then names what still runs with the old setting. A missing backup is no longer reported: the backup stacklet is in beta. A failed or overdue run is still reported once it is installed. --- apps/macos/README.md | 11 +- apps/macos/Sources/StackMenu/MenuPanel.swift | 103 +++++++++++++++++- apps/macos/Sources/StackMenu/Model.swift | 61 +++++++++-- apps/macos/Sources/StackMenu/StackCLI.swift | 14 ++- apps/macos/Sources/StackMenu/StackStore.swift | 9 +- 5 files changed, 178 insertions(+), 20 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index 96972e4c..889289b6 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -4,19 +4,22 @@ A native macOS menu bar app for the stack on this Mac. It answers, in this order: is everything fine, what needs me, what is running, what broke recently, is the data backed up, and how full is the machine. -- **Needs attention**: `stack doctor` findings, backups (missing, failed or - overdue), a disk above 90%. Each item carries its fix command; `up`, +- **Needs attention**: `stack doctor` findings, a backup that failed or is + overdue (when the beta backup stacklet is installed), a disk above 90%. Each item carries its fix command; `up`, `restart` and `down` run in the background, anything else opens in Terminal. - **Stacklets**: state, memory, port; start, stop, restart, open, logs. - **Errors**: containers that logged errors in the last 24 hours, newest first. - **This Mac**: disk, memory, uptime. +- **Setup**: family members, address mode, language, AI, update time, data + dir, from `stack config --json` (credentials hidden), with buttons that open + `stack.toml` and `users.toml` in the text editor. The icon turns into a warning only for errors: a failing stacklet, a doctor error, a failed backup. Warnings stay in the panel. It is a client of the `stack` CLI and nothing else: `status --json` every -minute, and `doctor --json`, `errors --json`, `host --json` and `backup -status` when the panel opens and every ten minutes. It starts no services and +minute, and `doctor --json`, `errors --json`, `host --json`, `config --json` +and `backup status` when the panel opens and every ten minutes. It starts no services and keeps no state, so the CLI stays the one owner of the lifecycle. Against a checkout older than those commands, the sections that need them are left out. diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index 05d0197f..3d65441d 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -4,7 +4,7 @@ import SwiftUI // // Ordered by the questions an admin opens it with: is everything fine, what // needs me, what is running, what broke recently, is the data backed up, -// how full is the machine. +// how full is the machine, and how is it set up. struct MenuPanel: View { @ObservedObject var store: StackStore @@ -43,6 +43,9 @@ struct MenuPanel: View { if let figures = store.hostFigures { Section("This Mac") { HostMetrics(figures: figures) } } + if let config = store.config { + SetupSection(report: config) + } } Divider() footer @@ -223,7 +226,9 @@ struct AttentionRow: View { Image(systemName: icon).foregroundStyle(tint).frame(width: 14) VStack(alignment: .leading, spacing: 2) { Text(item.title).font(.callout).lineLimit(2) + .fixedSize(horizontal: false, vertical: true) Text(item.detail).font(.caption).foregroundStyle(.secondary).lineLimit(3) + .fixedSize(horizontal: false, vertical: true) if let fix = item.fix { Button { store.apply(fix: fix) } label: { Text(fix).font(.caption.monospaced()) @@ -366,6 +371,102 @@ struct ErrorsList: View { } } +/// The choices in stack.toml and users.toml an admin looks for first. +/// Read-only: the buttons open the files, and doctor then lists every +/// container still running with the old setting, with the command to apply it. +struct SetupSection: View { + let report: ConfigReport + @State private var expanded = false + + private var config: JSONValue { report.config } + + var body: some View { + DisclosureGroup(isExpanded: $expanded) { + VStack(alignment: .leading, spacing: 8) { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 5) { + row("Family", family) + row("Address", address) + row("Language", language) + row("AI", ai) + row("Updates", updates) + row("Data", config["core"]?["data_dir"]?.text ?? "default") + } + .font(.callout) + HStack(spacing: 12) { + Button("Edit stack.toml") { StackCLI.openInEditor(report.stackToml) } + Button("Edit users.toml") { StackCLI.openInEditor(report.usersToml) } + .disabled(!FileManager.default.fileExists(atPath: report.usersToml)) + } + .buttonStyle(.link).font(.caption) + Text("A change applies with stack up . Doctor lists what still runs the old setting.") + .font(.caption2).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 4) + } label: { + Text("SETUP").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + } + } + + private func row(_ label: String, _ value: String) -> some View { + GridRow { + Text(label).foregroundStyle(.secondary) + Text(value).lineLimit(3).fixedSize(horizontal: false, vertical: true) + } + } + + // ── What each row says ─────────────────────────────────────────────── + + private var family: String { + let people = report.users.compactMap { user -> String? in + guard let name = user["name"]?.text ?? user["id"]?.text else { return nil } + return user["role"]?.text == "admin" ? "\(name) (admin)" : name + } + if people.isEmpty { return "nobody in users.toml" } + let owner = config["core"]?["stack_owner"]?.text.map { "\($0): " } ?? "" + return owner + people.joined(separator: ", ") + } + + private var address: String { + if let domain = config["core"]?["domain"]?.text { return "Domain mode, *.\(domain)" } + if let host = config["core"]?["host"]?.text { return "Port mode, \(host)" } + return "Port mode" + } + + private var language: String { + let parts = [config["core"]?["language"]?.text, config["core"]?["timezone"]?.text].compactMap { $0 } + return parts.isEmpty ? "default" : parts.joined(separator: ", ") + } + + private var ai: String { + let section = config["ai"] + let model = section?["default"]?.text + let server = section?["openai_url"]?.text.flatMap { URL(string: $0)?.host } + switch section?["provider"]?.text { + case nil, "local": + return model.map { "This Mac, \($0)" } ?? "This Mac" + case let provider?: + return [model, server.map { "at \($0)" }].compactMap { $0 }.joined(separator: " ") + .ifEmpty(provider) + } + } + + /// `[updates] schedule` is a cron expression with seconds. The common + /// daily form reads as a time; anything else is shown as written. + private var updates: String { + guard let cron = config["updates"]?["schedule"]?.text else { return "default" } + let f = cron.split(separator: " ").map(String.init) + if f.count == 6, f[3...].allSatisfy({ $0 == "*" }), let m = Int(f[1]), let h = Int(f[2]) { + return String(format: "Daily at %02d:%02d", h, m) + } + return cron + } +} + +private extension String { + func ifEmpty(_ fallback: String) -> String { isEmpty ? fallback : self } +} + struct HostMetrics: View { let figures: HostFigures diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift index 8495fcfc..3c34d861 100644 --- a/apps/macos/Sources/StackMenu/Model.swift +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -174,7 +174,7 @@ struct Attention: Identifiable { /// Everything that needs the admin, worst first. Stacklet rows already /// show which stacklets are failing; this adds what a row cannot: the - /// doctor's diagnoses, backups and the disk. + /// doctor's diagnoses, a backup that failed and the disk. static func collect(status: StackStatus?, doctor: DoctorReport?, backup: BackupReport?, disk: HostFigures?, now: Date = Date()) -> [Attention] { var items: [Attention] = [] @@ -193,15 +193,10 @@ struct Attention: Identifiable { detail: finding.detail, fix: finding.fix.isEmpty ? nil : finding.fix)) } - if let status { - if !status.isInstalled("backup") { - items.append(Attention( - id: "backup-missing", level: .warn, title: "Backups are not set up", - detail: "Nothing copies the data in this Mac's data dir to a second disk.", - fix: "stack up backup")) - } else if let backup { - items += backupItems(backup, now: now) - } + // Backup is a beta stacklet, so not having it is not a problem to + // report. Once installed, a failed or missed run is. + if let status, status.isInstalled("backup"), let backup { + items += backupItems(backup, now: now) } if let pct = disk?.diskUsedPct, pct >= 90 { @@ -269,3 +264,49 @@ func relative(_ date: Date, _ now: Date = Date()) -> String { formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: now) } + +// ── Configuration ───────────────────────────────────────────────────────── + +/// `stack config --json`: stack.toml and users.toml as written, credentials +/// hidden. The files are free-form TOML, so their content stays generic and +/// the panel picks out what it shows. +struct ConfigReport: Decodable { + let stackToml: String + let usersToml: String + let config: JSONValue + let users: [JSONValue] + + enum CodingKeys: String, CodingKey { + case stackToml = "stack_toml", usersToml = "users_toml", config, users + } +} + +enum JSONValue: Decodable { + case string(String), number(Double), bool(Bool), array([JSONValue]), object([String: JSONValue]), null + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } + else if let v = try? c.decode(Double.self) { self = .number(v) } + else if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode([JSONValue].self) { self = .array(v) } + else { self = .object(try c.decode([String: JSONValue].self)) } + } + + subscript(key: String) -> JSONValue? { + if case .object(let o) = self { return o[key] } + return nil + } + + /// A string value, with an empty one read as absent. + var text: String? { + if case .string(let v) = self, !v.isEmpty { return v } + return nil + } + + var texts: [String] { + if case .array(let a) = self { return a.compactMap(\.text) } + return [] + } +} diff --git a/apps/macos/Sources/StackMenu/StackCLI.swift b/apps/macos/Sources/StackMenu/StackCLI.swift index 3e5bcb3f..ab709822 100644 --- a/apps/macos/Sources/StackMenu/StackCLI.swift +++ b/apps/macos/Sources/StackMenu/StackCLI.swift @@ -53,6 +53,8 @@ struct StackCLI { func host() async throws -> HostReport { try await json(["host", "--json"]) } /// Prints JSON because its output is a pipe; it takes no `--json`. func backup() async throws -> BackupReport { try await json(["backup", "status"]) } + /// Keys are TOML keys, kept exactly as written. + func config() async throws -> ConfigReport { try await json(["config", "--json"], snakeCase: false) } /// Runs a command and decodes its JSON output. /// @@ -60,7 +62,7 @@ struct StackCLI { /// found an error and still prints its report. Anything before the first /// `{` line is skipped, such as a one-time notice the CLI prints on the /// first run after a config change. - private func json(_ arguments: [String]) async throws -> T { + private func json(_ arguments: [String], snakeCase: Bool = true) async throws -> T { let result = await run(arguments) let lines = result.stdout.split(separator: "\n", omittingEmptySubsequences: false) guard let start = lines.firstIndex(where: { $0.hasPrefix("{") }) else { @@ -68,7 +70,7 @@ struct StackCLI { } let body = lines[start...].joined(separator: "\n") let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase + if snakeCase { decoder.keyDecodingStrategy = .convertFromSnakeCase } do { return try decoder.decode(T.self, from: Data(body.utf8)) } catch { @@ -168,6 +170,14 @@ struct StackCLI { return env } + /// Opens a file in the default text editor, as `open -t` does. + static func openInEditor(_ path: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-t", path] + try? process.run() + } + private static func shellQuote(_ s: String) -> String { "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" } diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift index 4227f6d3..2191f77f 100644 --- a/apps/macos/Sources/StackMenu/StackStore.swift +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -4,7 +4,7 @@ import Foundation // ── App state: what the CLI last said, what is running, what failed ─────── // // Two refresh rates. `status` is cheap and drives the icon, so it runs every -// minute. The rest (doctor, errors, host, backup) take two to three seconds +// minute. The rest (doctor, errors, host, backup, config) take two to three seconds // each, so they run when the panel opens, at most once a minute, and every // ten minutes in the background so the icon also reflects the doctor. @@ -19,6 +19,7 @@ final class StackStore: ObservableObject { @Published private(set) var errors: ErrorsReport? @Published private(set) var host: HostReport? @Published private(set) var backup: BackupReport? + @Published private(set) var config: ConfigReport? @Published private(set) var checking = false @Published private(set) var checkedAt: Date? @@ -79,7 +80,9 @@ final class StackStore: ObservableObject { async let errors = try? cli.errors() async let host = try? cli.host() async let backup = wantsBackup ? try? cli.backup() : nil - (self.doctor, self.errors, self.host, self.backup) = await (doctor, errors, host, backup) + async let config = try? cli.config() + (self.doctor, self.errors, self.host, self.backup, self.config) = + await (doctor, errors, host, backup, config) checkedAt = Date() } @@ -185,7 +188,7 @@ final class StackStore: ObservableObject { UserDefaults.standard.set(url.path, forKey: Self.checkoutKey) checkout = url status = nil - doctor = nil; errors = nil; host = nil; backup = nil; checkedAt = nil + doctor = nil; errors = nil; host = nil; backup = nil; config = nil; checkedAt = nil Task { await refresh() await check() From b627b2ef615b9a7fa9e07908583548c34e2f84d7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 15:59:30 +0200 Subject: [PATCH 10/19] feat: split the menu bar panel into overview and setup tabs The panel had grown taller than the screen. Overview keeps what an admin checks often: what needs attention, the stacklets, recent errors, disk and memory. Setup holds what changes rarely: the configuration, the stacklets not installed yet, and the checkout with its version. Sections are cards with more room between rows, stacklet rows show memory with the port moved into their menu, and the panel scrolls once it reaches the height of the screen. `StackMenu --snapshot ` renders both tabs from the real data to PNG files, to check a layout without opening the menu. --- apps/macos/README.md | 14 +- apps/macos/Sources/StackMenu/App.swift | 41 ++ apps/macos/Sources/StackMenu/MenuPanel.swift | 445 +++++++++++-------- apps/macos/Sources/StackMenu/Model.swift | 1 + 4 files changed, 317 insertions(+), 184 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index 889289b6..d5d3ddb6 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -1,8 +1,9 @@ # Menu bar app (prototype) -A native macOS menu bar app for the stack on this Mac. It answers, in this -order: is everything fine, what needs me, what is running, what broke -recently, is the data backed up, and how full is the machine. +A native macOS menu bar app for the stack on this Mac. **Overview** answers, +in this order: is everything fine, what needs me, what is running, what +broke recently, and how full is the machine. **Setup** shows how the stack is +configured, what is not installed yet, and which checkout the app uses. - **Needs attention**: `stack doctor` findings, a backup that failed or is overdue (when the beta backup stacklet is installed), a disk above 90%. Each item carries its fix command; `up`, @@ -32,6 +33,13 @@ apps/macos/build.sh # prints the path of build/famstack.app open apps/macos/build/famstack.app ``` +To check a layout change without opening the menu, render both tabs from the +real data to PNG files (`-overview.png`, `-setup.png`): + +```bash +apps/macos/build/famstack.app/Contents/MacOS/StackMenu --snapshot /tmp/panel +``` + ## Which checkout The app runs `./stack` from the checkout you choose in the panel diff --git a/apps/macos/Sources/StackMenu/App.swift b/apps/macos/Sources/StackMenu/App.swift index 1a9b9b76..0bc585a3 100644 --- a/apps/macos/Sources/StackMenu/App.swift +++ b/apps/macos/Sources/StackMenu/App.swift @@ -21,5 +21,46 @@ struct StackMenuApp: App { final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--snapshot"), i + 1 < args.count { + Task { @MainActor in await Snapshot.write(prefix: args[i + 1]) } + } + } +} + +// ── Snapshots ───────────────────────────────────────────────────────────── +// +// `StackMenu --snapshot /tmp/panel` loads the real data, renders each tab +// into `/tmp/panel-overview.png` and `/tmp/panel-setup.png`, and quits. The +// panel is drawn in an offscreen window of the app's own, so no screen +// recording permission is needed, and a layout change can be checked +// without opening the menu. + +@MainActor +enum Snapshot { + static func write(prefix: String) async { + let store = StackStore() + // The store starts loading on its own; wait for the slow half. + while store.checkedAt == nil { try? await Task.sleep(for: .milliseconds(200)) } + for tab in PanelTab.allCases { + let view = MenuPanel(store: store, tab: tab, scrolls: false) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.colorScheme, .dark) + let url = URL(fileURLWithPath: "\(prefix)-\(tab.rawValue.lowercased()).png") + render(view, to: url) + } + NSApp.terminate(nil) + } + + private static func render(_ view: some View, to url: URL) { + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: .darkAqua) + host.frame.size = host.fittingSize + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + host.layoutSubtreeIfNeeded() + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return } + host.cacheDisplay(in: host.bounds, to: rep) + try? rep.representation(using: .png, properties: [:])?.write(to: url) } } diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index 3d65441d..6c0e6a32 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -2,20 +2,61 @@ import SwiftUI // ── The panel under the menu bar icon ───────────────────────────────────── // -// Ordered by the questions an admin opens it with: is everything fine, what -// needs me, what is running, what broke recently, is the data backed up, -// how full is the machine, and how is it set up. +// Two tabs. Overview is ordered by the questions an admin opens the panel +// with: is everything fine, what needs me, what is running, what broke +// recently, how full is the machine. Setup holds what changes rarely: the +// configuration, the stacklets not installed, which checkout this is. + +enum PanelTab: String, CaseIterable { + case overview = "Overview" + case setup = "Setup" +} struct MenuPanel: View { @ObservedObject var store: StackStore - @State private var showAvailable = false + @State var tab: PanelTab = .overview + /// Off for a snapshot, which renders the whole panel at its full height. + var scrolls = true + @State private var contentHeight: CGFloat = 400 + + private static let width: CGFloat = 360 var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 12) { header + Picker("", selection: $tab) { + ForEach(PanelTab.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + if scrolls { + ScrollView { + content + .background(GeometryReader { geo in + Color.clear.preference(key: HeightKey.self, value: geo.size.height) + }) + } + .frame(height: min(contentHeight, Self.maxContentHeight)) + .onPreferenceChange(HeightKey.self) { contentHeight = $0 } + } else { + content + } + footer + } + .padding(14) + .frame(width: Self.width) + .task { await store.panelOpened() } + } + + /// The panel never grows past the screen; beyond that it scrolls. + private static var maxContentHeight: CGFloat { + (NSScreen.main?.visibleFrame.height ?? 900) - 180 + } + + @ViewBuilder private var content: some View { + VStack(alignment: .leading, spacing: 14) { if store.checkout == nil { - Text("No checkout found. Choose the folder you run ./stack from.") - .font(.callout).foregroundStyle(.secondary) + Notice(text: "No checkout found. Choose the folder you run ./stack from in Setup.", tint: .orange) } if let error = store.loadError { Notice(text: error, tint: .red) @@ -23,44 +64,23 @@ struct MenuPanel: View { if let failure = store.lastFailure { failureNotice(failure) } - if let status = store.status { - let attention = store.attention - if !attention.isEmpty { - Section("Needs attention") { - ForEach(attention) { AttentionRow(item: $0, store: store) } - } - } - if !status.stale.isEmpty { staleNotice(status.stale) } - Section("Stacklets") { stackletList(status) } - if let errors = store.errors { - Section("Errors, last \(errors.since)") { ErrorsList(report: errors, store: store) } - } - if let line = backupLine(status) { - Section("Backup") { - Text(line).font(.callout).foregroundStyle(.secondary) - } - } - if let figures = store.hostFigures { - Section("This Mac") { HostMetrics(figures: figures) } - } - if let config = store.config { - SetupSection(report: config) - } + switch tab { + case .overview: overview + case .setup: setup } - Divider() - footer } - .padding(12) - .frame(width: 340) - .task { await store.panelOpened() } } // ── Header: the verdict ────────────────────────────────────────────── private var header: some View { - HStack(alignment: .firstTextBaseline) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: store.summary.symbol) + .font(.title2) + .foregroundStyle(summaryTint) + .frame(width: 28) VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { + HStack(spacing: 8) { Text("famstack").font(.headline) if store.status != nil { verdict } } @@ -86,52 +106,84 @@ struct MenuPanel: View { } } - private var verdict: some View { + private var summaryTint: Color { + switch store.summary { + case .healthy: .green + case .attention: .orange + case .busy: .blue + case .unreachable, .unconfigured: .secondary + } + } + + private var lookAtCount: (count: Int, worst: Bool) { let items = store.attention.filter { $0.level >= .warn } let failing = store.status?.installed.filter { $0.health.needsAttention }.count ?? 0 - let count = items.count + failing - let worst = failing > 0 || items.contains { $0.level == .error } + return (items.count + failing, failing > 0 || items.contains { $0.level == .error }) + } + + private var verdict: some View { + let (count, worst) = lookAtCount return Text(count == 0 ? "All good" : "\(count) to look at") .font(.caption.weight(.semibold)) - .padding(.horizontal, 6).padding(.vertical, 1) - .background((count == 0 ? Color.green : worst ? .red : .orange).opacity(0.18), in: Capsule()) + .padding(.horizontal, 7).padding(.vertical, 2) + .background((count == 0 ? Color.green : worst ? .red : .orange).opacity(0.2), in: Capsule()) } private func subtitle(_ status: StackStatus) -> String { let running = status.installed.filter { $0.online }.count - var parts = ["\(running) of \(status.installed.count) running", status.version] + var parts = ["\(running) of \(status.installed.count) running"] if let up = store.host?.uptimeSeconds { parts.append("up \(up / 86400) days") } return parts.joined(separator: " · ") } - // ── Stacklets ──────────────────────────────────────────────────────── + // ── Overview ───────────────────────────────────────────────────────── - private func stackletList(_ status: StackStatus) -> some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(status.installed) { stacklet in - StackletRow(stacklet: stacklet, busyVerb: store.busy[stacklet.id], - memory: store.memory(of: stacklet.id), store: store) + @ViewBuilder private var overview: some View { + if let status = store.status { + let attention = store.attention + if !attention.isEmpty || !status.stale.isEmpty { + Card("Needs attention") { + ForEach(attention) { AttentionRow(item: $0, store: store) } + if !status.stale.isEmpty { staleRow(status.stale) } + } } - if !status.available.isEmpty { - DisclosureGroup("Not installed (\(status.available.count))", isExpanded: $showAvailable) { - ForEach(status.available) { stacklet in - HStack { - Text(stacklet.name).foregroundStyle(.secondary) - Spacer() - Button("Install…") { store.openInTerminal(["up", stacklet.id]) } - .buttonStyle(.borderless).font(.caption) - .help("Opens Terminal: the first start asks setup questions") - } - .padding(.vertical, 2) - } + Card("Stacklets", spacing: 2) { + ForEach(status.installed) { stacklet in + StackletRow(stacklet: stacklet, busyVerb: store.busy[stacklet.id], + memory: store.memory(of: stacklet.id), store: store) } - .font(.callout) - .padding(.top, 4) } + if let errors = store.errors { + Card("Errors, last \(errors.since)") { ErrorsList(report: errors, store: store) } + } + if let line = backupLine(status) { + Card("Backup") { Text(line).font(.callout) } + } + if let figures = store.hostFigures { + Card("This Mac") { HostMetrics(figures: figures) } + } + } else if store.checkout != nil { + HStack { Spacer(); ProgressView(); Spacer() }.padding(.vertical, 30) } } - // ── Backup ─────────────────────────────────────────────────────────── + private func staleRow(_ stale: [String]) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.up.circle.fill").foregroundStyle(.blue).frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text("New code is not running yet").font(.callout) + Text(stale.joined(separator: ", ")).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if store.busy[""] != nil { + ProgressView().controlSize(.small) + } else { + Button("Restart") { Task { await store.perform("restart") } } + .controlSize(.small) + } + } + } /// The healthy case only; anything wrong with backups is in the /// attention list, which says what to do about it. @@ -145,23 +197,45 @@ struct MenuPanel: View { return lines.isEmpty ? nil : lines.joined(separator: "\n") } - // ── Notices ────────────────────────────────────────────────────────── + // ── Setup ──────────────────────────────────────────────────────────── - private func staleNotice(_ stale: [String]) -> some View { - HStack { - Image(systemName: "arrow.up.circle").foregroundStyle(.blue) - Text("New code for \(stale.joined(separator: ", "))") - .font(.callout).lineLimit(2) - Spacer() - if store.busy[""] != nil { - ProgressView().controlSize(.small) - } else { - Button("Restart") { Task { await store.perform("restart") } } - .controlSize(.small) + @ViewBuilder private var setup: some View { + if let config = store.config { + Card("Configuration") { SetupGrid(report: config) } + } + if let status = store.status, !status.available.isEmpty { + Card("Not installed", spacing: 2) { + ForEach(status.available) { stacklet in + HStack { + Text(stacklet.name) + Spacer() + Button("Install…") { store.openInTerminal(["up", stacklet.id]) } + .controlSize(.small) + .help("Opens Terminal: the first start asks setup questions") + } + .padding(.vertical, 3) + } + } + } + Card("Checkout") { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(store.checkout?.path ?? "None chosen") + .font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + .foregroundStyle(store.checkout == nil ? .secondary : .primary) + if let version = store.status?.version { + Text(version).font(.caption.monospaced()).foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + Spacer() + Button("Change…") { store.chooseCheckout() }.controlSize(.small) } } } + // ── Notices and footer ─────────────────────────────────────────────── + private func failureNotice(_ failure: StackStore.ActionFailure) -> some View { VStack(alignment: .leading, spacing: 6) { Text("stack \(failure.command.joined(separator: " ")) failed").font(.callout.bold()) @@ -175,20 +249,18 @@ struct MenuPanel: View { } .controlSize(.small) } - .padding(8) + .padding(10) .frame(maxWidth: .infinity, alignment: .leading) - .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) } private var footer: some View { - HStack { + HStack(spacing: 14) { Button("Doctor") { store.openInTerminal(["doctor"]) } .disabled(store.checkout == nil) - Button("Checkout…") { store.chooseCheckout() } - .help(store.checkout?.path ?? "No checkout chosen") Spacer() if let checked = store.checkedAt { - Text("checked \(relative(checked))").font(.caption2).foregroundStyle(.tertiary) + Text("checked \(relative(checked))").font(.caption).foregroundStyle(.tertiary) } Button("Quit") { NSApp.terminate(nil) } } @@ -197,22 +269,34 @@ struct MenuPanel: View { } } +private struct HeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + // ── Building blocks ─────────────────────────────────────────────────────── -struct Section: View { +/// A titled group on a faint rounded background, as in Control Center. +struct Card: View { let title: String + let spacing: CGFloat @ViewBuilder let content: Content - init(_ title: String, @ViewBuilder content: () -> Content) { + init(_ title: String, spacing: CGFloat = 10, @ViewBuilder content: () -> Content) { self.title = title + self.spacing = spacing self.content = content() } var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title.uppercased()) - .font(.caption2.weight(.semibold)).foregroundStyle(.secondary) - content + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: spacing) { content } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) } } } @@ -222,10 +306,10 @@ struct AttentionRow: View { @ObservedObject var store: StackStore var body: some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: icon).foregroundStyle(tint).frame(width: 14) - VStack(alignment: .leading, spacing: 2) { - Text(item.title).font(.callout).lineLimit(2) + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon).foregroundStyle(tint).frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text(item.title).font(.callout) .fixedSize(horizontal: false, vertical: true) Text(item.detail).font(.caption).foregroundStyle(.secondary).lineLimit(3) .fixedSize(horizontal: false, vertical: true) @@ -237,15 +321,15 @@ struct AttentionRow: View { .help("Run \(fix)") } } + Spacer(minLength: 0) } - .padding(.vertical, 2) } private var icon: String { switch item.level { case .error: "xmark.octagon.fill" case .warn: "exclamationmark.triangle.fill" - case .info: "info.circle" + case .info: "info.circle.fill" } } @@ -266,7 +350,7 @@ struct StackletRow: View { @State private var hovering = false var body: some View { - HStack(spacing: 8) { + HStack(spacing: 10) { Circle().fill(stacklet.health.color).frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 1) { Text(stacklet.name) @@ -279,34 +363,33 @@ struct StackletRow: View { Text(busyVerb).font(.caption).foregroundStyle(.secondary) ProgressView().controlSize(.small) } else { - if let memory, memory > 0 { - Text(ByteCountFormatter.string(fromByteCount: Int64(memory), countStyle: .memory)) - .font(.caption.monospacedDigit()).foregroundStyle(.secondary) - } - Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.tertiary) - .frame(minWidth: 44, alignment: .trailing) + Text(detail) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) actions } } - .padding(.vertical, 3) + .padding(.vertical, 5) .padding(.horizontal, 6) - .background(hovering ? Color.primary.opacity(0.08) : .clear, in: RoundedRectangle(cornerRadius: 5)) + .background(hovering ? Color.primary.opacity(0.08) : .clear, in: RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) .onHover { hovering = $0 } .onTapGesture { if stacklet.online { store.openInBrowser(stacklet) } } .help(stacklet.online && stacklet.port != nil ? "Open in the browser" : "") } + /// Memory while it runs, otherwise the state. The port is one hover away + /// in the menu; the memory is what an admin compares across rows. private var detail: String { - if stacklet.health == .healthy, let port = stacklet.port { return ":\(port)" } - if stacklet.health == .healthy { return "" } - return stacklet.health.label + if stacklet.health == .healthy, let memory, memory > 0 { + return ByteCountFormatter.string(fromByteCount: Int64(memory), countStyle: .memory) + } + return stacklet.health == .healthy ? "" : stacklet.health.label } private var actions: some View { Menu { - if stacklet.port != nil && stacklet.online { - Button("Open in Browser") { store.openInBrowser(stacklet) } + if let port = stacklet.port, stacklet.online { + Button("Open in Browser (:\(port))") { store.openInBrowser(stacklet) } Divider() } if stacklet.health == .down { @@ -336,31 +419,30 @@ struct ErrorsList: View { Label("No errors in \(report.scanned) containers", systemImage: "checkmark.circle") .font(.callout).foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(report.containers.prefix(Self.shown)) { entry in - Button { store.openInTerminal(["logs", entry.stacklet]) } label: { - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(entry.container).font(.callout) - Spacer() - Text(summary(entry)).font(.caption).foregroundStyle(.secondary) - } - if let last = entry.lines.last { - Text(last.text).font(.caption2.monospaced()).foregroundStyle(.secondary) - .lineLimit(2).truncationMode(.tail) - } + ForEach(report.containers.prefix(Self.shown)) { entry in + Button { store.openInTerminal(["logs", entry.stacklet]) } label: { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(entry.container).font(.callout) + Spacer() + Text(summary(entry)).font(.caption).foregroundStyle(.secondary) + } + if let last = entry.lines.last { + Text(last.text).font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(2).truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) } - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .help("Open the logs in Terminal") + .contentShape(Rectangle()) } - if report.containers.count > Self.shown { - Button("\(report.containers.count - Self.shown) more…") { - store.openInTerminal(["errors"]) - } - .buttonStyle(.link).font(.caption) + .buttonStyle(.plain) + .help("Open the logs in Terminal") + } + if report.containers.count > Self.shown { + Button("\(report.containers.count - Self.shown) more…") { + store.openInTerminal(["errors"]) } + .buttonStyle(.link).font(.caption) } } } @@ -374,44 +456,37 @@ struct ErrorsList: View { /// The choices in stack.toml and users.toml an admin looks for first. /// Read-only: the buttons open the files, and doctor then lists every /// container still running with the old setting, with the command to apply it. -struct SetupSection: View { +struct SetupGrid: View { let report: ConfigReport - @State private var expanded = false private var config: JSONValue { report.config } var body: some View { - DisclosureGroup(isExpanded: $expanded) { - VStack(alignment: .leading, spacing: 8) { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 5) { - row("Family", family) - row("Address", address) - row("Language", language) - row("AI", ai) - row("Updates", updates) - row("Data", config["core"]?["data_dir"]?.text ?? "default") - } - .font(.callout) - HStack(spacing: 12) { - Button("Edit stack.toml") { StackCLI.openInEditor(report.stackToml) } - Button("Edit users.toml") { StackCLI.openInEditor(report.usersToml) } - .disabled(!FileManager.default.fileExists(atPath: report.usersToml)) - } - .buttonStyle(.link).font(.caption) - Text("A change applies with stack up . Doctor lists what still runs the old setting.") - .font(.caption2).foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.top, 4) - } label: { - Text("SETUP").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 8) { + row("Family", family) + row("Address", address) + row("Language", language) + aiRow + row("Updates", updates) + row("Data", config["core"]?["data_dir"]?.text ?? "default") } + .font(.callout) + Divider() + HStack(spacing: 14) { + Button("Edit stack.toml") { StackCLI.openInEditor(report.stackToml) } + Button("Edit users.toml") { StackCLI.openInEditor(report.usersToml) } + .disabled(!FileManager.default.fileExists(atPath: report.usersToml)) + } + .buttonStyle(.link).font(.callout) + Text("A change applies with stack up . Doctor lists what still runs the old setting.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } private func row(_ label: String, _ value: String) -> some View { GridRow { - Text(label).foregroundStyle(.secondary) - Text(value).lineLimit(3).fixedSize(horizontal: false, vertical: true) + Text(label).foregroundStyle(.secondary).gridColumnAlignment(.trailing) + Text(value).fixedSize(horizontal: false, vertical: true) } } @@ -438,16 +513,20 @@ struct SetupSection: View { return parts.isEmpty ? "default" : parts.joined(separator: ", ") } - private var ai: String { + /// The model on one line, where it is, below it. Model ids are long and + /// hyphenated, so the line is shortened in the middle rather than wrapped. + private var aiRow: some View { let section = config["ai"] let model = section?["default"]?.text - let server = section?["openai_url"]?.text.flatMap { URL(string: $0)?.host } - switch section?["provider"]?.text { - case nil, "local": - return model.map { "This Mac, \($0)" } ?? "This Mac" - case let provider?: - return [model, server.map { "at \($0)" }].compactMap { $0 }.joined(separator: " ") - .ifEmpty(provider) + let provider = section?["provider"]?.text + let local = provider == nil || provider == "local" + let place = local ? "This Mac" : section?["openai_url"]?.text.flatMap { URL(string: $0)?.host } ?? provider + return GridRow { + Text("AI").foregroundStyle(.secondary).gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 1) { + Text(model ?? "default model").lineLimit(1).truncationMode(.middle).help(model ?? "") + if let place { Text(place).font(.caption).foregroundStyle(.secondary) } + } } } @@ -463,21 +542,17 @@ struct SetupSection: View { } } -private extension String { - func ifEmpty(_ fallback: String) -> String { isEmpty ? fallback : self } -} - struct HostMetrics: View { let figures: HostFigures var body: some View { - VStack(alignment: .leading, spacing: 6) { - if let pct = figures.diskUsedPct, let free = figures.diskFreeGb, let total = figures.diskTotalGb { - Meter(label: "Disk", value: pct / 100, detail: "\(Int(free)) GB free of \(Int(total))") + HStack(alignment: .top, spacing: 16) { + if let pct = figures.diskUsedPct, let free = figures.diskFreeGb { + Meter(label: "Disk", value: pct / 100, detail: "\(Int(free)) GB free") } if let used = figures.memoryUsedGb, let total = figures.memoryTotalGb { Meter(label: "Memory", value: used / max(total, 1), - detail: String(format: "%.1f of %.0f GB", used, total)) + detail: String(format: "%.0f of %.0f GB", used, total)) } } } @@ -489,16 +564,24 @@ struct Meter: View { let detail: String var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(label).font(.caption) + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline) { + Text(label).font(.callout) Spacer() Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.secondary) } - ProgressView(value: min(max(value, 0), 1)) - .tint(value > 0.9 ? .red : value > 0.75 ? .orange : .accentColor) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.1)) + Capsule().fill(tint).frame(width: geo.size.width * min(max(value, 0), 1)) + } + } + .frame(height: 5) } + .frame(maxWidth: .infinity) } + + private var tint: Color { value > 0.9 ? .red : value > 0.75 ? .orange : .accentColor } } struct Notice: View { @@ -507,10 +590,10 @@ struct Notice: View { var body: some View { Text(text) - .font(.caption) - .padding(8) + .font(.callout) + .padding(10) .frame(maxWidth: .infinity, alignment: .leading) - .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) } } diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift index 3c34d861..fdfc28c3 100644 --- a/apps/macos/Sources/StackMenu/Model.swift +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -260,6 +260,7 @@ func parseTimestamp(_ text: String) -> Date? { } func relative(_ date: Date, _ now: Date = Date()) -> String { + if abs(now.timeIntervalSince(date)) < 60 { return "just now" } let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: now) From 504e76167e984ac5e42f9d1911cf9d2fa9f9c416 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 17:54:34 +0200 Subject: [PATCH 11/19] feat: use the famstack mark as the menu bar and app icon The menu bar shows the "a" with its two dots in monochrome, so macOS tints it like every other menu bar icon. State stays monochrome: a badge dot when something needs attention, hollow dots while an action runs, a dimmed mark when the CLI does not answer. The app icon is the dark tile of the website's favicon, rendered at build time from the same drawing. The outline of the "a" is generated once from Inter Bold (SIL Open Font License) by `apps/macos/script/logo-outline.py`, so the app ships no font. `--snapshot` also renders the icon states, and neither tool mode puts a second icon in the menu bar any more. --- apps/macos/README.md | 10 +- apps/macos/Sources/StackMenu/App.swift | 44 +++++- apps/macos/Sources/StackMenu/Logo.swift | 129 ++++++++++++++++++ .../macos/Sources/StackMenu/LogoOutline.swift | 61 +++++++++ apps/macos/Sources/StackMenu/MenuPanel.swift | 14 +- apps/macos/Sources/StackMenu/StackStore.swift | 10 +- apps/macos/build.sh | 7 + apps/macos/script/logo-outline.py | 73 ++++++++++ 8 files changed, 326 insertions(+), 22 deletions(-) create mode 100644 apps/macos/Sources/StackMenu/Logo.swift create mode 100644 apps/macos/Sources/StackMenu/LogoOutline.swift create mode 100644 apps/macos/script/logo-outline.py diff --git a/apps/macos/README.md b/apps/macos/README.md index d5d3ddb6..da217e61 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -15,8 +15,14 @@ configured, what is not installed yet, and which checkout the app uses. dir, from `stack config --json` (credentials hidden), with buttons that open `stack.toml` and `users.toml` in the text editor. -The icon turns into a warning only for errors: a failing stacklet, a doctor -error, a failed backup. Warnings stay in the panel. +The menu bar shows the famstack mark in monochrome, as macOS expects. It +gets a badge dot only for errors: a failing stacklet, a doctor error, a +failed backup. Warnings stay in the panel. Its dots turn hollow while an +action runs, and the mark dims when the CLI does not answer. + +The mark is drawn from `Sources/StackMenu/LogoOutline.swift`, generated +once from Inter Bold by `script/logo-outline.py`; the app icon is rendered +from the same drawing at build time. It is a client of the `stack` CLI and nothing else: `status --json` every minute, and `doctor --json`, `errors --json`, `host --json`, `config --json` diff --git a/apps/macos/Sources/StackMenu/App.swift b/apps/macos/Sources/StackMenu/App.swift index 0bc585a3..cea6e78e 100644 --- a/apps/macos/Sources/StackMenu/App.swift +++ b/apps/macos/Sources/StackMenu/App.swift @@ -6,11 +6,29 @@ struct StackMenuApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @StateObject private var store = StackStore() + /// The tool modes render and quit. They must not put a second icon in + /// the menu bar next to the running app's. + private static let isTool = CommandLine.arguments.contains("--snapshot") + + init() { + // Used by build.sh to make the bundle's icon from the same drawing. + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--icon"), i + 1 < args.count { + do { + try Logo.writeIconset(to: URL(fileURLWithPath: args[i + 1])) + exit(0) + } catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(1) + } + } + } + var body: some Scene { - MenuBarExtra { + MenuBarExtra(isInserted: .constant(!Self.isTool)) { MenuPanel(store: store) } label: { - Image(systemName: store.summary.symbol) + Image(nsImage: Logo.menuBarImage(store.summary.logo)) } .menuBarExtraStyle(.window) } @@ -42,6 +60,7 @@ enum Snapshot { let store = StackStore() // The store starts loading on its own; wait for the slow half. while store.checkedAt == nil { try? await Task.sleep(for: .milliseconds(200)) } + render(menuBarStates, to: URL(fileURLWithPath: "\(prefix)-menubar.png")) for tab in PanelTab.allCases { let view = MenuPanel(store: store, tab: tab, scrolls: false) .background(Color(nsColor: .windowBackgroundColor)) @@ -52,6 +71,27 @@ enum Snapshot { NSApp.terminate(nil) } + /// Every icon state on a light and a dark bar, tinted as macOS tints + /// a template image. + private static var menuBarStates: some View { + let states: [Logo.State] = [.normal, .attention, .busy, .unreachable] + return VStack(spacing: 0) { + ForEach([Color.white, Color(white: 0.16)], id: \.self) { bar in + HStack(spacing: 18) { + ForEach(states.indices, id: \.self) { i in + Image(nsImage: Logo.menuBarImage(states[i])) + .renderingMode(.template) + .foregroundStyle(bar == .white ? Color.black : Color.white) + } + } + .padding(.horizontal, 14).padding(.vertical, 5) + .background(bar) + } + } + .scaleEffect(3, anchor: .topLeading) + .frame(width: 3 * 150, height: 3 * 56, alignment: .topLeading) + } + private static func render(_ view: some View, to url: URL) { let host = NSHostingView(rootView: view) host.appearance = NSAppearance(named: .darkAqua) diff --git a/apps/macos/Sources/StackMenu/Logo.swift b/apps/macos/Sources/StackMenu/Logo.swift new file mode 100644 index 00000000..de096961 --- /dev/null +++ b/apps/macos/Sources/StackMenu/Logo.swift @@ -0,0 +1,129 @@ +import AppKit + +// ── The famstack mark ───────────────────────────────────────────────────── +// +// An "a" with two dots below it, on favicon.svg's 32-unit grid (y down). Two +// renderings: a monochrome template for the menu bar, which macOS tints to +// match it, and the coloured tile for the app icon. + +enum Logo { + private static let dotRadius: CGFloat = 2.8 + private static let dotCentres = [CGPoint(x: 11.5, y: 28), CGPoint(x: 20.5, y: 28)] + + private static let orange = NSColor(srgbRed: 0xF0 / 255, green: 0x7D / 255, blue: 0x45 / 255, alpha: 1) + private static let teal = NSColor(srgbRed: 0x3D / 255, green: 0x8F / 255, blue: 0xA0 / 255, alpha: 1) + private static let tile = NSColor(srgbRed: 0x16 / 255, green: 0x1F / 255, blue: 0x24 / 255, alpha: 1) + + /// The mark's own extent on the grid: the letter and both dots. + private static var markBounds: CGRect { + dotCentres.reduce(LogoOutline.letterA.boundingBoxOfPath) { + $0.union(CGRect(x: $1.x - dotRadius, y: $1.y - dotRadius, width: 2 * dotRadius, height: 2 * dotRadius)) + } + } + + // ── Menu bar ───────────────────────────────────────────────────────── + + enum State { case normal, attention, busy, unreachable } + + /// The mark as a template image. The state stays monochrome: a badge dot + /// when something needs attention, hollow dots while an action runs, + /// dimmed when the CLI does not answer. + static func menuBarImage(_ state: State) -> NSImage { + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size, flipped: true) { rect in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + let mark = markBounds + let scale = (rect.height - 2) / mark.height + ctx.saveGState() + ctx.translateBy(x: (rect.width - mark.width * scale) / 2 - mark.minX * scale, + y: 1 - mark.minY * scale) + ctx.scaleBy(x: scale, y: scale) + ctx.setAlpha(state == .unreachable ? 0.35 : 1) + ctx.setFillColor(NSColor.black.cgColor) + ctx.setStrokeColor(NSColor.black.cgColor) + ctx.addPath(LogoOutline.letterA) + ctx.fillPath() + for centre in dotCentres { + if state == .busy { + ctx.setLineWidth(1.3) + ctx.strokeEllipse(in: dotRect(centre, inset: 0.65)) + } else { + ctx.fillEllipse(in: dotRect(centre)) + } + } + ctx.restoreGState() + if state == .attention { + // A gap around the badge keeps it apart from the letter. + let badge = CGRect(x: rect.maxX - 5.5, y: 0.5, width: 5, height: 5) + ctx.setBlendMode(.clear) + ctx.fillEllipse(in: badge.insetBy(dx: -1.2, dy: -1.2)) + ctx.setBlendMode(.normal) + ctx.setFillColor(NSColor.black.cgColor) + ctx.fillEllipse(in: badge) + } + return true + } + image.isTemplate = true + return image + } + + private static func dotRect(_ centre: CGPoint, inset: CGFloat = 0) -> CGRect { + CGRect(x: centre.x - dotRadius, y: centre.y - dotRadius, width: 2 * dotRadius, height: 2 * dotRadius) + .insetBy(dx: inset, dy: inset) + } + + // ── App icon ───────────────────────────────────────────────────────── + + /// The favicon's tile as a macOS app icon: the body takes 824 of 1024 + /// points with Apple's corner radius, the mark keeps its place on it. + static func drawAppIcon(in ctx: CGContext, side: CGFloat) { + let unit = side / 1024 + let body = CGRect(x: 100 * unit, y: 100 * unit, width: 824 * unit, height: 824 * unit) + ctx.addPath(CGPath(roundedRect: body, cornerWidth: 185 * unit, cornerHeight: 185 * unit, transform: nil)) + ctx.setFillColor(tile.cgColor) + ctx.fillPath() + + ctx.saveGState() + ctx.translateBy(x: body.minX, y: body.minY) + ctx.scaleBy(x: body.width / 32, y: body.height / 32) + ctx.addPath(LogoOutline.letterA) + ctx.setFillColor(orange.cgColor) + ctx.fillPath() + ctx.setFillColor(teal.cgColor) + for centre in dotCentres { ctx.fillEllipse(in: dotRect(centre)) } + ctx.restoreGState() + } + + /// The app icon as an image, for the panel header. + static func appIcon(side: CGFloat) -> NSImage { + NSImage(size: NSSize(width: side, height: side), flipped: true) { rect in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + drawAppIcon(in: ctx, side: rect.width) + return true + } + } + + /// Writes an `.iconset` folder; `iconutil -c icns` turns it into the + /// bundle's icon (build.sh does). + static func writeIconset(to folder: URL) throws { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + for points in [16, 32, 128, 256, 512] { + for scale in [1, 2] { + let pixels = points * scale + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels, bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: rep) else { continue } + // Flip to the grid's y-down orientation. + let ctx = context.cgContext + ctx.translateBy(x: 0, y: CGFloat(pixels)) + ctx.scaleBy(x: 1, y: -1) + drawAppIcon(in: ctx, side: CGFloat(pixels)) + context.flushGraphics() + let name = scale == 1 ? "icon_\(points)x\(points).png" : "icon_\(points)x\(points)@2x.png" + try rep.representation(using: .png, properties: [:])?.write(to: folder.appendingPathComponent(name)) + } + } + } +} diff --git a/apps/macos/Sources/StackMenu/LogoOutline.swift b/apps/macos/Sources/StackMenu/LogoOutline.swift new file mode 100644 index 00000000..0eab7a4b --- /dev/null +++ b/apps/macos/Sources/StackMenu/LogoOutline.swift @@ -0,0 +1,61 @@ +import CoreGraphics + +// Generated by script/logo-outline.py from Inter Bold (SIL Open Font License +// 1.1). The "a" of the famstack mark on favicon.svg's 32-unit grid, y down. + +enum LogoOutline { + static let letterA: CGPath = { + let path = CGMutablePath() + path.move(to: CGPoint(x: 13.891, y: 22.296)) + path.addQuadCurve(to: CGPoint(x: 11.281, y: 21.783), control: CGPoint(x: 12.434, y: 22.296)) + path.addQuadCurve(to: CGPoint(x: 9.468, y: 20.253), control: CGPoint(x: 10.128, y: 21.269)) + path.addQuadCurve(to: CGPoint(x: 8.807, y: 17.726), control: CGPoint(x: 8.807, y: 19.237)) + path.addQuadCurve(to: CGPoint(x: 9.275, y: 15.586), control: CGPoint(x: 8.807, y: 16.443)) + path.addQuadCurve(to: CGPoint(x: 10.556, y: 14.205), control: CGPoint(x: 9.743, y: 14.729)) + path.addQuadCurve(to: CGPoint(x: 12.403, y: 13.409), control: CGPoint(x: 11.369, y: 13.682)) + path.addQuadCurve(to: CGPoint(x: 14.570, y: 13.029), control: CGPoint(x: 13.437, y: 13.136)) + path.addQuadCurve(to: CGPoint(x: 16.706, y: 12.778), control: CGPoint(x: 15.893, y: 12.895)) + path.addQuadCurve(to: CGPoint(x: 17.900, y: 12.409), control: CGPoint(x: 17.520, y: 12.660)) + path.addQuadCurve(to: CGPoint(x: 18.280, y: 11.664), control: CGPoint(x: 18.280, y: 12.158)) + path.addLine(to: CGPoint(x: 18.280, y: 11.597)) + path.addQuadCurve(to: CGPoint(x: 18.002, y: 10.489), control: CGPoint(x: 18.280, y: 10.942)) + path.addQuadCurve(to: CGPoint(x: 17.190, y: 9.796), control: CGPoint(x: 17.724, y: 10.037)) + path.addQuadCurve(to: CGPoint(x: 15.878, y: 9.555), control: CGPoint(x: 16.656, y: 9.555)) + path.addQuadCurve(to: CGPoint(x: 14.507, y: 9.793), control: CGPoint(x: 15.094, y: 9.555)) + path.addQuadCurve(to: CGPoint(x: 13.545, y: 10.430), control: CGPoint(x: 13.919, y: 10.032)) + path.addQuadCurve(to: CGPoint(x: 13.005, y: 11.324), control: CGPoint(x: 13.170, y: 10.828)) + path.addLine(to: CGPoint(x: 9.245, y: 10.700)) + path.addQuadCurve(to: CGPoint(x: 10.550, y: 8.435), control: CGPoint(x: 9.633, y: 9.365)) + path.addQuadCurve(to: CGPoint(x: 12.829, y: 7.014), control: CGPoint(x: 11.467, y: 7.505)) + path.addQuadCurve(to: CGPoint(x: 15.891, y: 6.523), control: CGPoint(x: 14.191, y: 6.523)) + path.addQuadCurve(to: CGPoint(x: 18.283, y: 6.815), control: CGPoint(x: 17.121, y: 6.523)) + path.addQuadCurve(to: CGPoint(x: 20.369, y: 7.727), control: CGPoint(x: 19.446, y: 7.107)) + path.addQuadCurve(to: CGPoint(x: 21.836, y: 9.333), control: CGPoint(x: 21.293, y: 8.348)) + path.addQuadCurve(to: CGPoint(x: 22.380, y: 11.710), control: CGPoint(x: 22.380, y: 10.317)) + path.addLine(to: CGPoint(x: 22.380, y: 22.000)) + path.addLine(to: CGPoint(x: 18.490, y: 22.000)) + path.addLine(to: CGPoint(x: 18.490, y: 19.878)) + path.addLine(to: CGPoint(x: 18.356, y: 19.878)) + path.addQuadCurve(to: CGPoint(x: 17.371, y: 21.133), control: CGPoint(x: 17.985, y: 20.588)) + path.addQuadCurve(to: CGPoint(x: 15.891, y: 21.987), control: CGPoint(x: 16.756, y: 21.678)) + path.addQuadCurve(to: CGPoint(x: 13.891, y: 22.296), control: CGPoint(x: 15.027, y: 22.296)) + path.closeSubpath() + path.move(to: CGPoint(x: 15.045, y: 19.421)) + path.addQuadCurve(to: CGPoint(x: 16.747, y: 19.036), control: CGPoint(x: 16.014, y: 19.421)) + path.addQuadCurve(to: CGPoint(x: 17.895, y: 17.995), control: CGPoint(x: 17.480, y: 18.650)) + path.addQuadCurve(to: CGPoint(x: 18.309, y: 16.536), control: CGPoint(x: 18.309, y: 17.340)) + path.addLine(to: CGPoint(x: 18.309, y: 14.882)) + path.addQuadCurve(to: CGPoint(x: 17.756, y: 15.118), control: CGPoint(x: 18.122, y: 15.008)) + path.addQuadCurve(to: CGPoint(x: 16.941, y: 15.316), control: CGPoint(x: 17.391, y: 15.228)) + path.addQuadCurve(to: CGPoint(x: 16.052, y: 15.467), control: CGPoint(x: 16.491, y: 15.403)) + path.addQuadCurve(to: CGPoint(x: 15.273, y: 15.579), control: CGPoint(x: 15.613, y: 15.531)) + path.addQuadCurve(to: CGPoint(x: 13.944, y: 15.926), control: CGPoint(x: 14.517, y: 15.681)) + path.addQuadCurve(to: CGPoint(x: 13.053, y: 16.580), control: CGPoint(x: 13.371, y: 16.170)) + path.addQuadCurve(to: CGPoint(x: 12.736, y: 17.606), control: CGPoint(x: 12.736, y: 16.989)) + path.addQuadCurve(to: CGPoint(x: 13.036, y: 18.603), control: CGPoint(x: 12.736, y: 18.199)) + path.addQuadCurve(to: CGPoint(x: 13.854, y: 19.214), control: CGPoint(x: 13.335, y: 19.007)) + path.addQuadCurve(to: CGPoint(x: 15.045, y: 19.421), control: CGPoint(x: 14.373, y: 19.421)) + path.closeSubpath() + return path + }() +} diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index 6c0e6a32..3f825996 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -75,10 +75,7 @@ struct MenuPanel: View { private var header: some View { HStack(alignment: .center, spacing: 10) { - Image(systemName: store.summary.symbol) - .font(.title2) - .foregroundStyle(summaryTint) - .frame(width: 28) + Image(nsImage: Logo.appIcon(side: 34)) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { Text("famstack").font(.headline) @@ -106,15 +103,6 @@ struct MenuPanel: View { } } - private var summaryTint: Color { - switch store.summary { - case .healthy: .green - case .attention: .orange - case .busy: .blue - case .unreachable, .unconfigured: .secondary - } - } - private var lookAtCount: (count: Int, worst: Bool) { let items = store.attention.filter { $0.level >= .warn } let failing = store.status?.installed.filter { $0.health.needsAttention }.count ?? 0 diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift index 2191f77f..fb1407ee 100644 --- a/apps/macos/Sources/StackMenu/StackStore.swift +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -121,12 +121,12 @@ final class StackStore: ObservableObject { enum Summary { case healthy, attention, busy, unreachable, unconfigured - var symbol: String { + var logo: Logo.State { switch self { - case .healthy: "house.fill" - case .attention: "exclamationmark.triangle.fill" - case .busy: "arrow.triangle.2.circlepath" - case .unreachable, .unconfigured: "house" + case .healthy: .normal + case .attention: .attention + case .busy: .busy + case .unreachable, .unconfigured: .unreachable } } } diff --git a/apps/macos/build.sh b/apps/macos/build.sh index 12efd2db..bec4b469 100755 --- a/apps/macos/build.sh +++ b/apps/macos/build.sh @@ -20,6 +20,12 @@ rm -rf "$app" mkdir -p "$app/Contents/MacOS" cp ".build/$config/StackMenu" "$app/Contents/MacOS/StackMenu" +# The icon comes from the same drawing as the menu bar mark. +mkdir -p "$app/Contents/Resources" +rm -rf build/AppIcon.iconset +".build/$config/StackMenu" --icon build/AppIcon.iconset +iconutil -c icns -o "$app/Contents/Resources/AppIcon.icns" build/AppIcon.iconset + cat > "$app/Contents/Info.plist" <<'PLIST' @@ -28,6 +34,7 @@ cat > "$app/Contents/Info.plist" <<'PLIST' CFBundleIdentifier dev.famstack.menubar CFBundleName famstack CFBundleExecutable StackMenu + CFBundleIconFile AppIcon CFBundlePackageType APPL CFBundleShortVersionString 0.1.0 LSMinimumSystemVersion 14.0 diff --git a/apps/macos/script/logo-outline.py b/apps/macos/script/logo-outline.py new file mode 100644 index 00000000..71bd9c21 --- /dev/null +++ b/apps/macos/script/logo-outline.py @@ -0,0 +1,73 @@ +"""Write Sources/StackMenu/LogoOutline.swift: the outline of the famstack "a". + +The mark is an "a" set in Inter Bold with two dots below it, laid out as in +famstack.dev's favicon.svg on a 32-unit grid: font size 28, centred at x 16, +baseline at y 22 (y grows downwards, as in the SVG). The app draws the +outline instead of shipping the font. Inter is under the SIL Open Font +License 1.1. + + uvx --with brotli --from fonttools python apps/macos/script/logo-outline.py \ + ../website/node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2 +""" + +import sys +from pathlib import Path + +from fontTools.pens.basePen import BasePen +from fontTools.ttLib import TTFont +from fontTools.varLib.instancer import instantiateVariableFont + +FONT_SIZE, CENTER_X, BASELINE = 28, 16, 22 +OUT = Path(__file__).resolve().parent.parent / "Sources/StackMenu/LogoOutline.swift" + + +class SwiftPen(BasePen): + """Emits CGMutablePath calls in favicon grid coordinates.""" + + def __init__(self, glyphset, scale, x0): + super().__init__(glyphset) + self.scale, self.x0, self.lines = scale, x0, [] + + def pt(self, p): + x = self.x0 + p[0] * self.scale + y = BASELINE - p[1] * self.scale + return f"CGPoint(x: {x:.3f}, y: {y:.3f})" + + def _moveTo(self, p): + self.lines.append(f" path.move(to: {self.pt(p)})") + + def _lineTo(self, p): + self.lines.append(f" path.addLine(to: {self.pt(p)})") + + def _qCurveToOne(self, c, p): + self.lines.append(f" path.addQuadCurve(to: {self.pt(p)}, control: {self.pt(c)})") + + def _curveToOne(self, c1, c2, p): + self.lines.append(f" path.addCurve(to: {self.pt(p)}, control1: {self.pt(c1)}, control2: {self.pt(c2)})") + + def _closePath(self): + self.lines.append(" path.closeSubpath()") + + +font = instantiateVariableFont(TTFont(sys.argv[1]), {"wght": 700}) +glyphs = font.getGlyphSet() +name = font.getBestCmap()[ord("a")] +scale = FONT_SIZE / font["head"].unitsPerEm +x0 = CENTER_X - glyphs[name].width * scale / 2 +pen = SwiftPen(glyphs, scale, x0) +glyphs[name].draw(pen) + +OUT.write_text(f"""import CoreGraphics + +// Generated by script/logo-outline.py from Inter Bold (SIL Open Font License +// 1.1). The "a" of the famstack mark on favicon.svg's 32-unit grid, y down. + +enum LogoOutline {{ + static let letterA: CGPath = {{ + let path = CGMutablePath() +{chr(10).join(pen.lines)} + return path + }}() +}} +""") +print(f"{OUT}: {len(pen.lines)} path elements") From 4bbfc9367060cfbf70d2b99caa6cdeaa0b7a1c03 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 19:36:08 +0200 Subject: [PATCH 12/19] feat(cli): include each stacklet's address in list and status output `stack list --json` and `stack status --json` now give every stacklet with a web port a `url`: `http://:` in port mode, `://.` in domain mode. It is the address the stack already hands to its stacklets as `{url}`, so a client on another machine can open a stacklet without rebuilding that rule. --- lib/stack/stack.py | 4 ++ tests/unit/framework/test_list_urls.py | 52 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/unit/framework/test_list_urls.py diff --git a/lib/stack/stack.py b/lib/stack/stack.py index ed242e9a..3ed586b8 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -751,6 +751,10 @@ def list(self) -> dict: s["online"] = docker_state == "running" s["degraded"] = False s["health_issues"] = [] + # The address a person opens, for clients that are not on this + # Mac and cannot use localhost. + if s.get("port"): + s["url"] = self._public_url(sid, s["port"]) # A stacklet whose manifest renders a `remote` address is # served by another machine. Its row reports that server, diff --git a/tests/unit/framework/test_list_urls.py b/tests/unit/framework/test_list_urls.py new file mode 100644 index 00000000..92ed9860 --- /dev/null +++ b/tests/unit/framework/test_list_urls.py @@ -0,0 +1,52 @@ +"""`stack list` names the address a person opens for each stacklet. + +A client on another machine (the menu bar app over SSH, a script) cannot +use `localhost`, and should not rebuild the address rule itself: the +stack already knows it, because it hands the same URL to the stacklets as +`{url}`. In port mode that is this Mac's LAN address and the port; in +domain mode the stacklet's name under the domain. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import stack.docker +from stack import Stack + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.fixture +def instance(tmp_path, monkeypatch): + def make(core: str) -> Stack: + (tmp_path / "stack.toml").write_text( + f'[core]\ndata_dir = "{tmp_path / "data"}"\n{core}\n' + '\n[messages]\nserver_name = "simpson"\n') + (tmp_path / ".stack").mkdir(exist_ok=True) + return Stack(REPO_ROOT, tmp_path / "data", instance_dir=tmp_path) + # No Docker in the unit lane: every stacklet reads as not running. + monkeypatch.setattr(stack.docker, "project_states", lambda: {}) + return make + + +def _rows(stck: Stack) -> dict: + return {s["id"]: s for s in stck.list()["stacklets"]} + + +def test_port_mode_names_this_macs_address_and_the_port(instance): + rows = _rows(instance('host = "server.lan"')) + assert rows["photos"]["url"] == "http://server.lan:42010" + assert rows["docs"]["url"] == "http://server.lan:42020" + + +def test_domain_mode_names_the_stacklet_under_the_domain(instance): + rows = _rows(instance('domain = "home.example"')) + assert rows["photos"]["url"] == "http://photos.home.example" + + +def test_a_stacklet_without_a_web_port_has_no_address(instance): + rows = _rows(instance('host = "server.lan"')) + assert "url" not in rows["core"] From 42b2de70be841b52f457cfb99315e898c1a7cb4d Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 19:39:38 +0200 Subject: [PATCH 13/19] feat: connect the menu bar app to a stack on another Mac over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A switch in Setup chooses between a checkout on this Mac and one on another Mac, and both are remembered. Remotely the app runs `ssh 'zsh -lc "cd && ./stack …"'` with the admin's SSH key and never prompts, so a server with no screen can be watched from a laptop. Logs, doctor and editing open Terminal with an SSH session to that Mac, and stacklets open at the address the stack names for them. Every CLI call now stops after a minute, lifecycle actions after fifteen, so an unreachable or hanging machine shows an error instead of a panel that never updates. Answers that arrive after a switch of machine are dropped. --- apps/macos/README.md | 22 ++- apps/macos/Sources/StackMenu/MenuPanel.swift | 60 +++++-- apps/macos/Sources/StackMenu/Model.swift | 3 + apps/macos/Sources/StackMenu/StackCLI.swift | 159 ++++++++++++++---- apps/macos/Sources/StackMenu/StackStore.swift | 134 +++++++++++++-- 5 files changed, 303 insertions(+), 75 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index da217e61..71e67478 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -46,11 +46,25 @@ real data to PNG files (`-overview.png`, `-setup.png`): apps/macos/build/famstack.app/Contents/MacOS/StackMenu --snapshot /tmp/panel ``` -## Which checkout +## Which machine -The app runs `./stack` from the checkout you choose in the panel -(**Checkout…**), and remembers it. Without a choice it tries `~/famstack`. -To set it from a shell: +**Setup → Connection** switches between a checkout on this Mac and one on +another Mac, and remembers both. + +- **This Mac**: the folder you run `./stack` from, chosen with **Change…**. + Without a choice the app tries `~/famstack`. +- **Remote**: an SSH host (an alias from `~/.ssh/config` or `user@host`) and + the checkout path on it, `~/famstack` unless you say otherwise. The app + runs `ssh 'zsh -lc "cd && ./stack …"'` with your SSH key + and never asks for a password, so the key must work without one (or be in + the agent). The server needs **Remote Login** turned on. Logs, doctor and + editing open Terminal with an SSH session to that Mac; stacklets open at the + address the stack names for them, its LAN address in port mode. + +Every call gives up after a minute, lifecycle actions after fifteen, so an +unreachable machine shows an error instead of a panel that never updates. + +To set the local checkout from a shell: ```bash defaults write dev.famstack.menubar checkout /path/to/famstack diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index 3f825996..388ca55f 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -55,8 +55,10 @@ struct MenuPanel: View { @ViewBuilder private var content: some View { VStack(alignment: .leading, spacing: 14) { - if store.checkout == nil { - Notice(text: "No checkout found. Choose the folder you run ./stack from in Setup.", tint: .orange) + if store.connection == nil { + Notice(text: store.mode == .local + ? "No checkout found. Choose the folder you run ./stack from in Setup." + : "No remote host yet. Set one in Setup.", tint: .orange) } if let error = store.loadError { Notice(text: error, tint: .red) @@ -121,6 +123,7 @@ struct MenuPanel: View { let running = status.installed.filter { $0.online }.count var parts = ["\(running) of \(status.installed.count) running"] if let up = store.host?.uptimeSeconds { parts.append("up \(up / 86400) days") } + if let connection = store.connection, connection.isRemote { parts.append("on \(connection.label)") } return parts.joined(separator: " · ") } @@ -148,9 +151,11 @@ struct MenuPanel: View { Card("Backup") { Text(line).font(.callout) } } if let figures = store.hostFigures { - Card("This Mac") { HostMetrics(figures: figures) } + Card(store.connection?.isRemote == true ? store.remoteHost : "This Mac") { + HostMetrics(figures: figures) + } } - } else if store.checkout != nil { + } else if store.connection != nil { HStack { Spacer(); ProgressView(); Spacer() }.padding(.vertical, 30) } } @@ -189,7 +194,7 @@ struct MenuPanel: View { @ViewBuilder private var setup: some View { if let config = store.config { - Card("Configuration") { SetupGrid(report: config) } + Card("Configuration") { SetupGrid(report: config, store: store) } } if let status = store.status, !status.available.isEmpty { Card("Not installed", spacing: 2) { @@ -205,19 +210,38 @@ struct MenuPanel: View { } } } - Card("Checkout") { + Card("Connection") { + Picker("", selection: Binding(get: { store.mode }, set: { store.setMode($0) })) { + Text("This Mac").tag(StackStore.Mode.local) + Text("Remote").tag(StackStore.Mode.remote) + } + .pickerStyle(.segmented) + .labelsHidden() HStack { VStack(alignment: .leading, spacing: 3) { - Text(store.checkout?.path ?? "None chosen") - .font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) - .foregroundStyle(store.checkout == nil ? .secondary : .primary) + switch store.mode { + case .local: + Text(store.checkout?.path ?? "No checkout chosen") + .font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + .foregroundStyle(store.checkout == nil ? .secondary : .primary) + case .remote: + Text(store.remoteHost.isEmpty ? "No host set" : store.remoteHost) + .font(.callout.monospaced()).lineLimit(1) + .foregroundStyle(store.remoteHost.isEmpty ? .secondary : .primary) + Text(store.remoteCheckout) + .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + .truncationMode(.middle) + } if let version = store.status?.version { Text(version).font(.caption.monospaced()).foregroundStyle(.secondary) .textSelection(.enabled) } } Spacer() - Button("Change…") { store.chooseCheckout() }.controlSize(.small) + Button("Change…") { + store.mode == .local ? store.chooseCheckout() : store.editRemote() + } + .controlSize(.small) } } } @@ -245,7 +269,7 @@ struct MenuPanel: View { private var footer: some View { HStack(spacing: 14) { Button("Doctor") { store.openInTerminal(["doctor"]) } - .disabled(store.checkout == nil) + .disabled(store.connection == nil) Spacer() if let checked = store.checkedAt { Text("checked \(relative(checked))").font(.caption).foregroundStyle(.tertiary) @@ -361,8 +385,8 @@ struct StackletRow: View { .background(hovering ? Color.primary.opacity(0.08) : .clear, in: RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) .onHover { hovering = $0 } - .onTapGesture { if stacklet.online { store.openInBrowser(stacklet) } } - .help(stacklet.online && stacklet.port != nil ? "Open in the browser" : "") + .onTapGesture { if store.canOpen(stacklet) { store.openInBrowser(stacklet) } } + .help(store.canOpen(stacklet) ? "Open \(stacklet.url ?? "in the browser")" : "") } /// Memory while it runs, otherwise the state. The port is one hover away @@ -376,8 +400,8 @@ struct StackletRow: View { private var actions: some View { Menu { - if let port = stacklet.port, stacklet.online { - Button("Open in Browser (:\(port))") { store.openInBrowser(stacklet) } + if store.canOpen(stacklet) { + Button("Open in Browser") { store.openInBrowser(stacklet) } Divider() } if stacklet.health == .down { @@ -446,6 +470,7 @@ struct ErrorsList: View { /// container still running with the old setting, with the command to apply it. struct SetupGrid: View { let report: ConfigReport + @ObservedObject var store: StackStore private var config: JSONValue { report.config } @@ -461,9 +486,8 @@ struct SetupGrid: View { .font(.callout) Divider() HStack(spacing: 14) { - Button("Edit stack.toml") { StackCLI.openInEditor(report.stackToml) } - Button("Edit users.toml") { StackCLI.openInEditor(report.usersToml) } - .disabled(!FileManager.default.fileExists(atPath: report.usersToml)) + Button("Edit stack.toml") { store.edit(report.stackToml) } + Button("Edit users.toml") { store.edit(report.usersToml) } } .buttonStyle(.link).font(.callout) Text("A change applies with stack up . Doctor lists what still runs the old setting.") diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift index fdfc28c3..31b6e4a5 100644 --- a/apps/macos/Sources/StackMenu/Model.swift +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -37,6 +37,9 @@ struct Stacklet: Decodable, Identifiable { let degraded: Bool let stale: Bool let remote: String? + /// Where a person opens it; absent without a web port or from a CLI + /// older than the field. + let url: String? let healthIssues: [String] /// One state per row, derived in the same order `stack list` reads the diff --git a/apps/macos/Sources/StackMenu/StackCLI.swift b/apps/macos/Sources/StackMenu/StackCLI.swift index ab709822..02fc9522 100644 --- a/apps/macos/Sources/StackMenu/StackCLI.swift +++ b/apps/macos/Sources/StackMenu/StackCLI.swift @@ -6,9 +6,33 @@ import Foundation // The app holds no state about the stack and starts no services itself. It // runs `./stack` in a checkout and renders what comes back, so the CLI stays // the one owner of the lifecycle, as it is for every other client. +// +// The checkout is on this Mac or on another one reached over SSH. Every +// command goes through `invocation`, so reading, acting, Terminal links and +// editing all reach the same machine the panel shows. + +enum Connection: Equatable { + case local(checkout: URL) + /// `host` is anything `ssh` accepts: an alias from ~/.ssh/config or + /// `user@host`. `checkout` is a path on that machine; `~/` is expanded + /// there. + case remote(host: String, checkout: String) + + var label: String { + switch self { + case .local: "This Mac" + case .remote(let host, _): host + } + } + + var isRemote: Bool { + if case .remote = self { return true } + return false + } +} struct StackCLI { - let checkout: URL + let connection: Connection struct Result { let exitCode: Int32 @@ -37,8 +61,13 @@ struct StackCLI { } } - /// A checkout is a directory holding the `stack` wrapper and `stacklets/`, - /// the same marker the CLI itself walks up to. + /// Reading calls give up after a minute; a lifecycle action may pull + /// images and gets much longer. + static let readTimeout: TimeInterval = 60 + static let actionTimeout: TimeInterval = 15 * 60 + + /// A local checkout is a directory holding the `stack` wrapper and + /// `stacklets/`, the same marker the CLI itself walks up to. static func isCheckout(_ url: URL) -> Bool { let fm = FileManager.default return fm.isExecutableFile(atPath: url.appendingPathComponent("stack").path) @@ -63,7 +92,7 @@ struct StackCLI { /// `{` line is skipped, such as a one-time notice the CLI prints on the /// first run after a config change. private func json(_ arguments: [String], snakeCase: Bool = true) async throws -> T { - let result = await run(arguments) + let result = await run(arguments, timeout: Self.readTimeout) let lines = result.stdout.split(separator: "\n", omittingEmptySubsequences: false) guard let start = lines.firstIndex(where: { $0.hasPrefix("{") }) else { throw Failure.exited(result) @@ -80,20 +109,35 @@ struct StackCLI { // ── Running ────────────────────────────────────────────────────────── - /// Runs `./stack ` to completion off the main thread. + /// What to execute for `./stack `. /// - /// The working directory is the checkout because the CLI finds its root - /// by walking up from the current directory, not from where the script - /// lives. Stdin is empty so a hook that prompts fails instead of waiting - /// on an answer nobody can give. - func run(_ arguments: [String]) async -> Result { - let checkout = self.checkout + /// Locally the working directory is the checkout, because the CLI finds + /// its root by walking up from the current directory, not from where the + /// script lives. Remotely a login shell runs it, which gives the remote + /// Python and docker their PATH, as it does for a person logging in. + private func invocation(_ arguments: [String]) -> (executable: URL, arguments: [String], directory: URL?) { + switch connection { + case .local(let checkout): + return (checkout.appendingPathComponent("stack"), arguments, checkout) + case .remote(let host, let checkout): + let line = "cd \(Self.remotePath(checkout)) && ./stack " + arguments.map(Self.shellQuote).joined(separator: " ") + return (URL(fileURLWithPath: "/usr/bin/ssh"), + Self.sshOptions(batch: true) + [host, "zsh -lc " + Self.shellQuote(line)], nil) + } + } + + /// Runs `./stack ` to completion off the main thread, and + /// stops it after `timeout`. Stdin is empty so a hook that prompts fails + /// instead of waiting on an answer nobody can give. + func run(_ arguments: [String], timeout: TimeInterval = actionTimeout) async -> Result { + let (executable, argv, directory) = invocation(arguments) + let description = "stack " + arguments.joined(separator: " ") return await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let process = Process() - process.executableURL = checkout.appendingPathComponent("stack") - process.arguments = arguments - process.currentDirectoryURL = checkout + process.executableURL = executable + process.arguments = argv + if let directory { process.currentDirectoryURL = directory } process.environment = Self.environment() process.standardInput = FileHandle.nullDevice let out = Pipe(), err = Pipe() @@ -105,6 +149,13 @@ struct StackCLI { continuation.resume(returning: Result(exitCode: -1, stdout: "", stderr: error.localizedDescription)) return } + let timedOut = Flag() + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + if process.isRunning { + timedOut.value = true + process.terminate() + } + } // Both pipes drain at once: a command that fills one while // the other is being read would otherwise block forever. let errData = Collected() @@ -117,35 +168,67 @@ struct StackCLI { let outData = out.fileHandleForReading.readDataToEndOfFile() group.wait() process.waitUntilExit() + var stderr = Self.stripANSI(String(decoding: errData.data, as: UTF8.self)) + if timedOut.value { stderr += "\n\(description) did not finish within \(Int(timeout)) seconds." } continuation.resume(returning: Result( exitCode: process.terminationStatus, stdout: Self.stripANSI(String(decoding: outData, as: UTF8.self)), - stderr: Self.stripANSI(String(decoding: errData.data, as: UTF8.self)))) + stderr: stderr)) } } } private final class Collected: @unchecked Sendable { var data = Data() } + private final class Flag: @unchecked Sendable { var value = false } + + // ── Terminal ───────────────────────────────────────────────────────── - /// Opens Terminal on `./stack ` in the checkout. Used for - /// anything interactive or long to read: first installs, logs, doctor. + /// Opens Terminal on `./stack ` on the connected machine. + /// Used for anything interactive or long to read: installs, logs, doctor. func openInTerminal(_ arguments: [String]) { - let line = ([checkout.appendingPathComponent("stack").path] + arguments) - .map(Self.shellQuote).joined(separator: " ") - openInTerminal(commandLine: line, name: arguments.joined(separator: "-")) + openInTerminal(line: "./stack " + arguments.map(Self.shellQuote).joined(separator: " "), + name: arguments.joined(separator: "-")) } /// Opens Terminal on a command line exactly as the CLI printed it, such - /// as a doctor fix. `stack …` runs this checkout's wrapper. + /// as a doctor fix. `stack …` runs the checkout's wrapper. func openInTerminal(fix: String) { - let line = fix.hasPrefix("stack ") ? "./" + fix : fix - openInTerminal(commandLine: line, name: "fix") + openInTerminal(line: fix.hasPrefix("stack ") ? "./" + fix : fix, name: "fix") + } + + /// Opens a file of the instance in a text editor: TextEdit (or the + /// default editor) for a local file, the remote `$EDITOR` in Terminal + /// for a remote one. + func edit(_ path: String) { + switch connection { + case .local: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-t", path] + try? process.run() + case .remote: + openInTerminal(line: "${EDITOR:-nano} " + Self.shellQuote(path), name: "edit") + } } - /// A `.command` file opens in Terminal through Launch Services, which - /// needs no Automation permission, unlike scripting Terminal directly. - private func openInTerminal(commandLine: String, name: String) { - let script = "#!/bin/zsh -l\ncd \(Self.shellQuote(checkout.path)) || exit 1\n\(commandLine)\n" + /// Writes `line`, run in the checkout, to a `.command` file and opens + /// it. Launch Services opens it in Terminal without the Automation + /// permission that scripting Terminal would need. + private func openInTerminal(line: String, name: String) { + let body: String + switch connection { + case .local(let checkout): + // Terminal does not inherit this app's environment. An app + // pointed at another instance with STACK_DIR passes it on. + let stackDir = ProcessInfo.processInfo.environment["STACK_DIR"] + .map { "export STACK_DIR=\(Self.shellQuote($0))\n" } ?? "" + body = "\(stackDir)cd \(Self.shellQuote(checkout.path)) || exit 1\n\(line)\n" + case .remote(let host, let checkout): + let remote = "cd \(Self.remotePath(checkout)) && \(line)" + let ssh = (["ssh", "-t"] + Self.sshOptions(batch: false) + [host]).map(Self.shellQuote).joined(separator: " ") + body = "\(ssh) \(Self.shellQuote("zsh -lc " + Self.shellQuote(remote)))\n" + } + let script = "#!/bin/zsh -l\n" + body let safeName = name.filter { $0.isLetter || $0.isNumber || $0 == "-" } let file = FileManager.default.temporaryDirectory.appendingPathComponent("stack-\(safeName).command") do { @@ -159,6 +242,20 @@ struct StackCLI { // ── Helpers ────────────────────────────────────────────────────────── + /// `BatchMode` makes ssh fail instead of asking for a password or a + /// host key nobody can type into; Terminal sessions may ask. + private static func sshOptions(batch: Bool) -> [String] { + (batch ? ["-o", "BatchMode=yes"] : []) + ["-o", "ConnectTimeout=8", "-o", "LogLevel=ERROR"] + } + + /// A remote path quoted for the remote shell, with a leading `~/` left + /// outside the quotes so that shell expands it. + private static func remotePath(_ path: String) -> String { + if path == "~" { return "~" } + if path.hasPrefix("~/") { return "~/" + shellQuote(String(path.dropFirst(2))) } + return shellQuote(path) + } + /// An app started from Finder or at login gets launchd's minimal PATH, /// in which the wrapper finds only Apple's Python 3.9 and no docker. /// Prepend where Homebrew and OrbStack install them. @@ -170,14 +267,6 @@ struct StackCLI { return env } - /// Opens a file in the default text editor, as `open -t` does. - static func openInEditor(_ path: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = ["-t", path] - try? process.run() - } - private static func shellQuote(_ s: String) -> String { "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" } diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift index fb1407ee..04cca594 100644 --- a/apps/macos/Sources/StackMenu/StackStore.swift +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -4,13 +4,21 @@ import Foundation // ── App state: what the CLI last said, what is running, what failed ─────── // // Two refresh rates. `status` is cheap and drives the icon, so it runs every -// minute. The rest (doctor, errors, host, backup, config) take two to three seconds -// each, so they run when the panel opens, at most once a minute, and every -// ten minutes in the background so the icon also reflects the doctor. +// minute. The rest (doctor, errors, host, backup, config) take two to three +// seconds each, so they run when the panel opens, at most once a minute, and +// every ten minutes in the background so the icon also reflects the doctor. +// +// The stack is on this Mac or on another one over SSH. Both are remembered and +// a switch picks one, so going back and forth needs no retyping. @MainActor final class StackStore: ObservableObject { + enum Mode: String { case local, remote } + + @Published private(set) var mode: Mode @Published private(set) var checkout: URL? + @Published private(set) var remoteHost: String + @Published private(set) var remoteCheckout: String @Published private(set) var status: StackStatus? @Published private(set) var loadError: String? @Published private(set) var refreshing = false @@ -34,12 +42,23 @@ final class StackStore: ObservableObject { } private static let checkoutKey = "checkout" + private static let modeKey = "mode" + private static let remoteHostKey = "remoteHost" + private static let remoteCheckoutKey = "remoteCheckout" + private static let defaultRemoteCheckout = "~/famstack" private static let statusInterval: TimeInterval = 60 private static let detailInterval: TimeInterval = 600 private var timers: [Timer] = [] + /// Bumped on every switch of machine, so an answer that arrives after + /// the switch is dropped instead of shown for the wrong machine. + private var generation = 0 init() { + let defaults = UserDefaults.standard + mode = Mode(rawValue: defaults.string(forKey: Self.modeKey) ?? "") ?? .local checkout = Self.resolveCheckout() + remoteHost = defaults.string(forKey: Self.remoteHostKey) ?? "" + remoteCheckout = defaults.string(forKey: Self.remoteCheckoutKey) ?? Self.defaultRemoteCheckout timers = [ Timer.scheduledTimer(withTimeInterval: Self.statusInterval, repeats: true) { [weak self] _ in Task { @MainActor in await self?.refresh() } @@ -54,18 +73,30 @@ final class StackStore: ObservableObject { } } - private var cli: StackCLI? { checkout.map(StackCLI.init) } + /// The machine the panel shows, or nil until one is set up. + var connection: Connection? { + switch mode { + case .local: checkout.map { .local(checkout: $0) } + case .remote: remoteHost.isEmpty ? nil : .remote(host: remoteHost, checkout: remoteCheckout) + } + } + + private var cli: StackCLI? { connection.map(StackCLI.init) } // ── Reading ────────────────────────────────────────────────────────── func refresh() async { guard let cli, !refreshing else { return } + let started = generation refreshing = true defer { refreshing = false } do { - status = try await cli.status() + let fresh = try await cli.status() + guard started == generation else { return } + status = fresh loadError = nil } catch { + guard started == generation else { return } loadError = error.localizedDescription } } @@ -73,6 +104,7 @@ final class StackStore: ObservableObject { /// The slower reports, all at once. One failing leaves the others. func check() async { guard let cli, !checking else { return } + let started = generation checking = true defer { checking = false } let wantsBackup = status?.isInstalled("backup") ?? false @@ -81,8 +113,9 @@ final class StackStore: ObservableObject { async let host = try? cli.host() async let backup = wantsBackup ? try? cli.backup() : nil async let config = try? cli.config() - (self.doctor, self.errors, self.host, self.backup, self.config) = - await (doctor, errors, host, backup, config) + let reports = await (doctor, errors, host, backup, config) + guard started == generation else { return } + (self.doctor, self.errors, self.host, self.backup, self.config) = reports checkedAt = Date() } @@ -110,7 +143,7 @@ final class StackStore: ObservableObject { /// into a warning: a warning that never clears (no backup set up) would /// teach the admin to stop looking at the icon. var summary: Summary { - guard checkout != nil else { return .unconfigured } + guard connection != nil else { return .unconfigured } guard let status, loadError == nil else { return .unreachable } if !busy.isEmpty { return .busy } if status.installed.contains(where: { $0.health.needsAttention }) { return .attention } @@ -165,14 +198,84 @@ final class StackStore: ObservableObject { cli?.openInTerminal(arguments) } + func edit(_ path: String) { + cli?.edit(path) + } + + /// The address the CLI names for the stacklet. A checkout older than + /// that field names none; on this Mac, localhost then reaches every UI, + /// because ports bind here in both port and domain mode. func openInBrowser(_ stacklet: Stacklet) { - // Ports bind on this Mac in both port and domain mode (127.0.0.1 in - // the latter), so localhost reaches every UI from the server itself. - guard let port = stacklet.port, let url = URL(string: "http://localhost:\(port)") else { return } + var address = stacklet.url + if address == nil, mode == .local, let port = stacklet.port { address = "http://localhost:\(port)" } + guard let address, let url = URL(string: address) else { return } NSWorkspace.shared.open(url) } - // ── Which checkout ─────────────────────────────────────────────────── + func canOpen(_ stacklet: Stacklet) -> Bool { + stacklet.online && (stacklet.url != nil || (mode == .local && stacklet.port != nil)) + } + + // ── Which machine and checkout ─────────────────────────────────────── + + func setMode(_ new: Mode) { + guard new != mode else { return } + mode = new + UserDefaults.standard.set(new.rawValue, forKey: Self.modeKey) + if new == .remote && remoteHost.isEmpty { + editRemote() + } + reconnect() + } + + /// Asks for the SSH host and the checkout path on it. + func editRemote() { + let host = NSTextField(string: remoteHost) + host.placeholderString = "ssh alias or user@host" + let path = NSTextField(string: remoteCheckout) + path.placeholderString = Self.defaultRemoteCheckout + for field in [host, path] { field.frame.size.width = 280 } + let fields = NSStackView(views: [label("SSH host"), host, label("Checkout on that machine"), path]) + fields.orientation = .vertical + fields.alignment = .leading + fields.spacing = 6 + fields.frame = NSRect(x: 0, y: 0, width: 280, height: 110) + + let alert = NSAlert() + alert.messageText = "Connect to a stack on another Mac" + alert.informativeText = "The app runs ./stack there over SSH with your key. The server needs Remote Login turned on." + alert.accessoryView = fields + alert.addButton(withTitle: "Connect") + alert.addButton(withTitle: "Cancel") + NSApp.activate(ignoringOtherApps: true) + alert.window.initialFirstResponder = host + guard alert.runModal() == .alertFirstButtonReturn else { return } + + remoteHost = host.stringValue.trimmingCharacters(in: .whitespaces) + let typed = path.stringValue.trimmingCharacters(in: .whitespaces) + remoteCheckout = typed.isEmpty ? Self.defaultRemoteCheckout : typed + UserDefaults.standard.set(remoteHost, forKey: Self.remoteHostKey) + UserDefaults.standard.set(remoteCheckout, forKey: Self.remoteCheckoutKey) + if mode == .remote { reconnect() } + } + + private func label(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + label.textColor = .secondaryLabelColor + return label + } + + /// Forgets everything read from the previous machine and reads again. + private func reconnect() { + generation += 1 + status = nil; loadError = nil; lastFailure = nil + doctor = nil; errors = nil; host = nil; backup = nil; config = nil; checkedAt = nil + Task { + await refresh() + await check() + } + } func chooseCheckout() { let panel = NSOpenPanel() @@ -187,12 +290,7 @@ final class StackStore: ObservableObject { } UserDefaults.standard.set(url.path, forKey: Self.checkoutKey) checkout = url - status = nil - doctor = nil; errors = nil; host = nil; backup = nil; config = nil; checkedAt = nil - Task { - await refresh() - await check() - } + if mode == .local { reconnect() } } /// The saved choice, else `~/famstack`, where the README's clone lands From 6a4bc2b2cdee0869e2509eae79d0f6413faf56d5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 14:27:18 +0200 Subject: [PATCH 14/19] feat: say what failed and stop hung commands in the menu bar app A failed read or action now says what happened and what to do instead of showing raw output: - the CLI's own error reply, or its last lines, for a failed command - one message for an SSH failure (ssh exits 255) with ssh's own line and a button that tests the connection in Terminal - a missing checkout on the remote Mac, a checkout older than the app (argparse exits 2), a command that did not answer in time - a success confirms itself for a few seconds A command is done when its process exits, not when its pipes close, so a docker child holding them no longer hangs a read. Reads stop after a minute (SIGKILL if SIGTERM is ignored); an action past fifteen minutes keeps running and reports when it ends. One action runs at a time and shows its elapsed time. Reads asked for during another read run after it, the panel marks an old state as old when the machine stops answering, and it reads again after the Mac wakes. A report without JSON never shows its stdout: an older CLI that ignores `--json` can print the whole stack.toml there. The SSH host is checked before it becomes an argument, and cancelling the remote dialog leaves the switch on This Mac. --- apps/macos/Sources/StackMenu/App.swift | 9 +- apps/macos/Sources/StackMenu/MenuPanel.swift | 193 ++++++-- apps/macos/Sources/StackMenu/Model.swift | 27 +- apps/macos/Sources/StackMenu/StackCLI.swift | 411 +++++++++++++----- apps/macos/Sources/StackMenu/StackStore.swift | 302 ++++++++++--- 5 files changed, 732 insertions(+), 210 deletions(-) diff --git a/apps/macos/Sources/StackMenu/App.swift b/apps/macos/Sources/StackMenu/App.swift index cea6e78e..642d8d30 100644 --- a/apps/macos/Sources/StackMenu/App.swift +++ b/apps/macos/Sources/StackMenu/App.swift @@ -58,8 +58,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { enum Snapshot { static func write(prefix: String) async { let store = StackStore() - // The store starts loading on its own; wait for the slow half. - while store.checkedAt == nil { try? await Task.sleep(for: .milliseconds(200)) } + // The store starts loading on its own; wait for the slow half, but + // not past the read timeout: a machine that does not answer renders + // as the panel shows it then. + let deadline = Date().addingTimeInterval(StackCLI.readTimeout + 10) + while store.checkedAt == nil && store.connection != nil && Date() < deadline { + try? await Task.sleep(for: .milliseconds(200)) + } render(menuBarStates, to: URL(fileURLWithPath: "\(prefix)-menubar.png")) for tab in PanelTab.allCases { let view = MenuPanel(store: store, tab: tab, scrolls: false) diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index 388ca55f..16d24731 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -60,11 +60,11 @@ struct MenuPanel: View { ? "No checkout found. Choose the folder you run ./stack from in Setup." : "No remote host yet. Set one in Setup.", tint: .orange) } - if let error = store.loadError { - Notice(text: error, tint: .red) + if let banner = store.banner { + BannerView(banner: banner, store: store) } - if let failure = store.lastFailure { - failureNotice(failure) + if let problem = store.loadProblem { + ProblemNotice(problem: problem, since: store.status == nil ? nil : store.statusAt, store: store) } switch tab { case .overview: overview @@ -140,8 +140,7 @@ struct MenuPanel: View { } Card("Stacklets", spacing: 2) { ForEach(status.installed) { stacklet in - StackletRow(stacklet: stacklet, busyVerb: store.busy[stacklet.id], - memory: store.memory(of: stacklet.id), store: store) + StackletRow(stacklet: stacklet, memory: store.memory(of: stacklet.id), store: store) } } if let errors = store.errors { @@ -155,7 +154,10 @@ struct MenuPanel: View { HostMetrics(figures: figures) } } - } else if store.connection != nil { + if !store.reportProblems.isEmpty { + ReportProblems(problems: store.reportProblems) + } + } else if store.connection != nil && store.loadProblem == nil { HStack { Spacer(); ProgressView(); Spacer() }.padding(.vertical, 30) } } @@ -169,11 +171,12 @@ struct MenuPanel: View { .fixedSize(horizontal: false, vertical: true) } Spacer() - if store.busy[""] != nil { + if store.running?.key == "" { ProgressView().controlSize(.small) } else { Button("Restart") { Task { await store.perform("restart") } } .controlSize(.small) + .disabled(store.running != nil) } } } @@ -239,7 +242,7 @@ struct MenuPanel: View { } Spacer() Button("Change…") { - store.mode == .local ? store.chooseCheckout() : store.editRemote() + if store.mode == .local { store.chooseCheckout() } else { store.editRemote() } } .controlSize(.small) } @@ -248,24 +251,6 @@ struct MenuPanel: View { // ── Notices and footer ─────────────────────────────────────────────── - private func failureNotice(_ failure: StackStore.ActionFailure) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("stack \(failure.command.joined(separator: " ")) failed").font(.callout.bold()) - Text(failure.message).font(.caption.monospaced()).lineLimit(6) - HStack { - Button("Run in Terminal") { - store.openInTerminal(failure.command) - store.lastFailure = nil - } - Button("Dismiss") { store.lastFailure = nil } - } - .controlSize(.small) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) - } - private var footer: some View { HStack(spacing: 14) { Button("Doctor") { store.openInTerminal(["doctor"]) } @@ -356,7 +341,6 @@ struct AttentionRow: View { struct StackletRow: View { let stacklet: Stacklet - let busyVerb: String? let memory: Int? @ObservedObject var store: StackStore @State private var hovering = false @@ -371,9 +355,8 @@ struct StackletRow: View { } } Spacer() - if let busyVerb { - Text(busyVerb).font(.caption).foregroundStyle(.secondary) - ProgressView().controlSize(.small) + if let running = store.running, running.key == stacklet.id { + RunningLabel(action: running) } else { Text(detail) .font(.caption.monospacedDigit()).foregroundStyle(.secondary) @@ -415,12 +398,160 @@ struct StackletRow: View { } label: { Image(systemName: "ellipsis.circle") } + .disabled(store.running != nil) + .help(store.running != nil ? "Another action is running" : "") .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() } } +/// "restarting 0:42" while a lifecycle action runs, so a slow `up` that is +/// pulling images reads as working rather than hung. +struct RunningLabel: View { + let action: StackStore.RunningAction + + var body: some View { + TimelineView(.periodic(from: action.startedAt, by: 1)) { context in + let seconds = max(0, Int(context.date.timeIntervalSince(action.startedAt))) + Text("\(Self.progressive(action.verb)) \(seconds / 60):\(String(format: "%02d", seconds % 60))") + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + ProgressView().controlSize(.small) + } + + private static func progressive(_ verb: String) -> String { + switch verb { + case "up": "starting" + case "down": "stopping" + case "restart": "restarting" + default: verb + } + } +} + +/// An action's outcome, or a choice that could not be applied. +struct BannerView: View { + let banner: StackStore.Banner + @ObservedObject var store: StackStore + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon).foregroundStyle(tint).frame(width: 16) + VStack(alignment: .leading, spacing: 4) { + Text(banner.title).font(.callout.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + if let hint = banner.hint { + Text(.init(hint)).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let detail = banner.detail { + Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(6).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + if banner.kind != .success { + HStack(spacing: 12) { + if let terminal = banner.terminal { + Button(terminal.label) { store.openTerminal(terminal) } + } + if let retry = banner.retry { + Button("Run in Terminal") { store.openInTerminal(retry); store.banner = nil } + } + Button("Dismiss") { store.banner = nil } + } + .buttonStyle(.link).font(.caption) + } + } + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } + + private var icon: String { + switch banner.kind { + case .success: "checkmark.circle.fill" + case .info: "clock.fill" + case .failure: "xmark.octagon.fill" + } + } + + private var tint: Color { + switch banner.kind { + case .success: .green + case .info: .blue + case .failure: .red + } + } +} + +/// Why the stack cannot be read right now. With an older answer on screen, +/// it says how old, so nothing below reads as current. +struct ProblemNotice: View { + let problem: Problem + let since: Date? + @ObservedObject var store: StackStore + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange).frame(width: 16) + VStack(alignment: .leading, spacing: 4) { + Text(problem.title).font(.callout.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + if let hint = problem.hint { + Text(.init(hint)).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let detail = problem.detail { + Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(4).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + if let since { + Text("Below: the state from \(relative(since)).").font(.caption).foregroundStyle(.secondary) + } + if let terminal = problem.terminal { + Button(terminal.label) { store.openTerminal(terminal) } + .buttonStyle(.link).font(.caption) + } + } + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } +} + +/// Sections that could not be read, named, instead of silently missing. The +/// same cause is said once: an older checkout lacks several reports at once. +struct ReportProblems: View { + let problems: [(report: String, problem: Problem)] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Label("Not shown: \(problems.map(\.report).joined(separator: ", "))", systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary) + ForEach(causes, id: \.self) { cause in + Text(.init(cause)).font(.caption).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 4) + } + + private var causes: [String] { + var seen: [String] = [] + for (_, problem) in problems { + let line = [problem.title, problem.hint].compactMap { $0 }.joined(separator: ". ") + if !seen.contains(line) { seen.append(line) } + } + return seen + } +} + struct ErrorsList: View { let report: ErrorsReport @ObservedObject var store: StackStore diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift index 31b6e4a5..e498aaac 100644 --- a/apps/macos/Sources/StackMenu/Model.swift +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -257,16 +257,31 @@ func parseTimestamp(_ text: String) -> Date? { let fraction = s[s.index(after: dot).. String { if abs(now.timeIntervalSince(date)) < 60 { return "just now" } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter.localizedString(for: date, relativeTo: now) + return Formatters.relative.localizedString(for: date, relativeTo: now) +} + +/// Built once: the panel formats dates on every redraw. +private enum Formatters { + static let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + static let isoFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + static let relative: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() } // ── Configuration ───────────────────────────────────────────────────────── diff --git a/apps/macos/Sources/StackMenu/StackCLI.swift b/apps/macos/Sources/StackMenu/StackCLI.swift index 02fc9522..ea62857a 100644 --- a/apps/macos/Sources/StackMenu/StackCLI.swift +++ b/apps/macos/Sources/StackMenu/StackCLI.swift @@ -31,38 +31,64 @@ enum Connection: Equatable { } } +// ── What went wrong, for a person ───────────────────────────────────────── + +/// A failure as the panel shows it: what happened, what to do, and the raw +/// output for anyone who wants it. +struct Problem: Equatable, Error { + enum Kind { case connection, timeout, command } + + let kind: Kind + let title: String + /// What to do about it, when there is something to do. + var hint: String? + /// The last lines the command printed. + var detail: String? + /// A command line that helps, run in Terminal on this Mac. + var terminal: TerminalCommand? + + struct TerminalCommand: Equatable { + let label: String + let line: String + } +} + struct StackCLI { let connection: Connection struct Result { - let exitCode: Int32 - let stdout: String - let stderr: String + var exitCode: Int32 = 0 + var stdout = "" + var stderr = "" + /// Stopped because it ran past its time. + var timedOut = false + /// Past its time but left running (lifecycle actions). + var stillRunning = false + /// The process could not be started at all. + var launchError: String? - var succeeded: Bool { exitCode == 0 } + var succeeded: Bool { exitCode == 0 && !timedOut && !stillRunning && launchError == nil } /// The last lines are where the CLI says what went wrong. Both /// streams, because commands print their progress to either. func tail(_ lines: Int = 6) -> String { (stdout + "\n" + stderr).split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } .suffix(lines).joined(separator: "\n") } } - enum Failure: LocalizedError { - case exited(Result) - case undecodable(command: String, reason: String) - - var errorDescription: String? { - switch self { - case .exited(let result): result.tail(3) - case .undecodable(let command, let reason): "Unexpected output from stack \(command): \(reason)" - } - } + /// What happens when a command runs past its time. + enum Overrun { + /// Stop it. For reads: an answer that late is worth nothing. + case stop + /// Stop waiting but let it finish, calling back when it does. For + /// lifecycle actions: a slow `up` is usually still pulling images, + /// and killing it halfway helps nobody. + case detach(whenDone: @Sendable (Result) -> Void) } - /// Reading calls give up after a minute; a lifecycle action may pull - /// images and gets much longer. static let readTimeout: TimeInterval = 60 static let actionTimeout: TimeInterval = 15 * 60 @@ -85,28 +111,41 @@ struct StackCLI { /// Keys are TOML keys, kept exactly as written. func config() async throws -> ConfigReport { try await json(["config", "--json"], snakeCase: false) } - /// Runs a command and decodes its JSON output. + /// Runs a command and decodes its JSON output, or throws a `Problem`. /// /// The exit code alone does not decide: `doctor` exits 1 when it has /// found an error and still prints its report. Anything before the first /// `{` line is skipped, such as a one-time notice the CLI prints on the /// first run after a config change. private func json(_ arguments: [String], snakeCase: Bool = true) async throws -> T { - let result = await run(arguments, timeout: Self.readTimeout) + let result = await run(arguments, timeout: Self.readTimeout, overrun: .stop) + // A run that was killed or never started may have printed half a + // document; what it printed says nothing. + if result.timedOut || result.launchError != nil { throw diagnose(result, arguments) } let lines = result.stdout.split(separator: "\n", omittingEmptySubsequences: false) guard let start = lines.firstIndex(where: { $0.hasPrefix("{") }) else { - throw Failure.exited(result) + throw readProblem(result, arguments) } - let body = lines[start...].joined(separator: "\n") + let body = Data(lines[start...].joined(separator: "\n").utf8) let decoder = JSONDecoder() if snakeCase { decoder.keyDecodingStrategy = .convertFromSnakeCase } do { - return try decoder.decode(T.self, from: Data(body.utf8)) + return try decoder.decode(T.self, from: body) } catch { - throw Failure.undecodable(command: arguments.joined(separator: " "), reason: String(describing: error)) + // A failing command answers `{"error": "..."}` (the CLI's + // contract): that message is for people, so it is the title. + if let reply = try? JSONDecoder().decode(ErrorReply.self, from: body) { + throw Problem(kind: .command, title: reply.error) + } + throw Problem(kind: .command, + title: "The app does not understand what stack \(arguments[0]) returned", + hint: "The app and the checkout are probably from different versions.", + detail: String(describing: error)) } } + private struct ErrorReply: Decodable { let error: String } + // ── Running ────────────────────────────────────────────────────────── /// What to execute for `./stack `. @@ -120,124 +159,167 @@ struct StackCLI { case .local(let checkout): return (checkout.appendingPathComponent("stack"), arguments, checkout) case .remote(let host, let checkout): - let line = "cd \(Self.remotePath(checkout)) && ./stack " + arguments.map(Self.shellQuote).joined(separator: " ") + let line = Self.inRemoteCheckout(checkout, "./stack " + arguments.map(Self.shellQuote).joined(separator: " ")) return (URL(fileURLWithPath: "/usr/bin/ssh"), - Self.sshOptions(batch: true) + [host, "zsh -lc " + Self.shellQuote(line)], nil) + Self.sshOptions(batch: true) + ["--", host, "zsh -lc " + Self.shellQuote(line)], nil) } } - /// Runs `./stack ` to completion off the main thread, and - /// stops it after `timeout`. Stdin is empty so a hook that prompts fails - /// instead of waiting on an answer nobody can give. - func run(_ arguments: [String], timeout: TimeInterval = actionTimeout) async -> Result { + /// Runs `./stack ` off the main thread. Stdin is empty, so a + /// hook that prompts fails instead of waiting for an answer nobody can + /// give. + func run(_ arguments: [String], timeout: TimeInterval, overrun: Overrun) async -> Result { let (executable, argv, directory) = invocation(arguments) - let description = "stack " + arguments.joined(separator: " ") return await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - process.executableURL = executable - process.arguments = argv - if let directory { process.currentDirectoryURL = directory } - process.environment = Self.environment() - process.standardInput = FileHandle.nullDevice - let out = Pipe(), err = Pipe() - process.standardOutput = out - process.standardError = err - do { - try process.run() - } catch { - continuation.resume(returning: Result(exitCode: -1, stdout: "", stderr: error.localizedDescription)) - return - } - let timedOut = Flag() - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - if process.isRunning { - timedOut.value = true - process.terminate() - } - } - // Both pipes drain at once: a command that fills one while - // the other is being read would otherwise block forever. - let errData = Collected() - let group = DispatchGroup() - group.enter() - DispatchQueue.global().async { - errData.data = err.fileHandleForReading.readDataToEndOfFile() - group.leave() - } - let outData = out.fileHandleForReading.readDataToEndOfFile() - group.wait() - process.waitUntilExit() - var stderr = Self.stripANSI(String(decoding: errData.data, as: UTF8.self)) - if timedOut.value { stderr += "\n\(description) did not finish within \(Int(timeout)) seconds." } - continuation.resume(returning: Result( - exitCode: process.terminationStatus, - stdout: Self.stripANSI(String(decoding: outData, as: UTF8.self)), - stderr: stderr)) - } + let process = Process() + process.executableURL = executable + process.arguments = argv + if let directory { process.currentDirectoryURL = directory } + process.environment = Self.environment() + process.standardInput = FileHandle.nullDevice + let run = ProcessRun(process: process, continuation: continuation) + run.start(timeout: timeout, overrun: overrun) } } - private final class Collected: @unchecked Sendable { var data = Data() } - private final class Flag: @unchecked Sendable { var value = false } - // ── Terminal ───────────────────────────────────────────────────────── /// Opens Terminal on `./stack ` on the connected machine. /// Used for anything interactive or long to read: installs, logs, doctor. - func openInTerminal(_ arguments: [String]) { - openInTerminal(line: "./stack " + arguments.map(Self.shellQuote).joined(separator: " "), - name: arguments.joined(separator: "-")) + @discardableResult + func openInTerminal(_ arguments: [String]) -> Bool { + openInTerminal(inCheckout: "./stack " + arguments.map(Self.shellQuote).joined(separator: " ")) } /// Opens Terminal on a command line exactly as the CLI printed it, such /// as a doctor fix. `stack …` runs the checkout's wrapper. - func openInTerminal(fix: String) { - openInTerminal(line: fix.hasPrefix("stack ") ? "./" + fix : fix, name: "fix") + @discardableResult + func openInTerminal(fix: String) -> Bool { + openInTerminal(inCheckout: fix.hasPrefix("stack ") ? "./" + fix : fix) } - /// Opens a file of the instance in a text editor: TextEdit (or the - /// default editor) for a local file, the remote `$EDITOR` in Terminal - /// for a remote one. - func edit(_ path: String) { + /// Opens a file of the instance in a text editor: the default editor for + /// a local file, the remote `$EDITOR` in Terminal for a remote one. + @discardableResult + func edit(_ path: String) -> Bool { switch connection { case .local: - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = ["-t", path] - try? process.run() + return NSWorkspace.shared.open(URL(fileURLWithPath: path)) || Self.openWithTextEdit(path) case .remote: - openInTerminal(line: "${EDITOR:-nano} " + Self.shellQuote(path), name: "edit") + return openInTerminal(inCheckout: "${EDITOR:-nano} " + Self.shellQuote(path)) } } - /// Writes `line`, run in the checkout, to a `.command` file and opens - /// it. Launch Services opens it in Terminal without the Automation - /// permission that scripting Terminal would need. - private func openInTerminal(line: String, name: String) { - let body: String + private static func openWithTextEdit(_ path: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-t", path] + return (try? process.run()) != nil + } + + /// `line`, run in the checkout on the connected machine. + private func openInTerminal(inCheckout line: String) -> Bool { switch connection { case .local(let checkout): // Terminal does not inherit this app's environment. An app // pointed at another instance with STACK_DIR passes it on. let stackDir = ProcessInfo.processInfo.environment["STACK_DIR"] .map { "export STACK_DIR=\(Self.shellQuote($0))\n" } ?? "" - body = "\(stackDir)cd \(Self.shellQuote(checkout.path)) || exit 1\n\(line)\n" + return Self.openShell("\(stackDir)cd \(Self.shellQuote(checkout.path)) || exit 1\n\(line)") case .remote(let host, let checkout): - let remote = "cd \(Self.remotePath(checkout)) && \(line)" - let ssh = (["ssh", "-t"] + Self.sshOptions(batch: false) + [host]).map(Self.shellQuote).joined(separator: " ") - body = "\(ssh) \(Self.shellQuote("zsh -lc " + Self.shellQuote(remote)))\n" + let remote = Self.inRemoteCheckout(checkout, line) + let ssh = (["ssh", "-t"] + Self.sshOptions(batch: false) + ["--", host]).map(Self.shellQuote) + .joined(separator: " ") + return Self.openShell("\(ssh) \(Self.shellQuote("zsh -lc " + Self.shellQuote(remote)))") } - let script = "#!/bin/zsh -l\n" + body - let safeName = name.filter { $0.isLetter || $0.isNumber || $0 == "-" } - let file = FileManager.default.temporaryDirectory.appendingPathComponent("stack-\(safeName).command") + } + + /// Runs `script` in a new Terminal window on this Mac. A `.command` file + /// opens there through Launch Services, which needs no Automation + /// permission, unlike scripting Terminal directly. Each gets its own + /// file, so two windows never share one that is being rewritten. + @discardableResult + static func openShell(_ script: String) -> Bool { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("famstack-\(UUID().uuidString.prefix(8)).command") do { - try script.write(to: file, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) - NSWorkspace.shared.open(file) + try ("#!/bin/zsh -l\nrm -f -- \"$0\"\n" + script + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: file.path) } catch { - NSSound.beep() + return false + } + return NSWorkspace.shared.open(file) + } + + // ── Diagnosis ──────────────────────────────────────────────────────── + + /// A read that returned no JSON. Its stdout is never shown: an older CLI + /// that ignores `--json` can print a whole file there, stack.toml with + /// its credentials included. + private func readProblem(_ result: Result, _ arguments: [String]) -> Problem { + var problem = diagnose(result, arguments) + guard problem.kind == .command else { return problem } + let machine = connection.isRemote ? connection.label : "this Mac" + let stderr = result.stderr.split(whereSeparator: \.isNewline).suffix(2).joined(separator: "\n") + // argparse exits 2 for a subcommand or flag it does not know. + if result.exitCode == 2 { + return Problem(kind: .command, title: "Not in the famstack version on \(machine)", + hint: "Update that checkout with `./stack update`.") + } + problem = Problem(kind: .command, title: "stack \(arguments[0]) gave no report the app can read", + hint: "The famstack on \(machine) may be older than the app; `./stack update` brings it level.", + detail: stderr.isEmpty ? nil : stderr) + return problem + } + + /// Turns a failed run into what the panel says. It reads only what the + /// app controls (the process, its timeout, ssh's documented exit code, + /// the exit code of its own remote wrapper) and otherwise shows the + /// CLI's last lines as they are: those are written for people already. + func diagnose(_ result: Result, _ arguments: [String]) -> Problem { + let command = "stack " + arguments.joined(separator: " ") + let detail = result.tail(4).isEmpty ? nil : result.tail(4) + + if let launch = result.launchError { + return Problem(kind: .connection, title: "Could not run ./stack", + hint: "Choose the checkout again in Setup → Connection.", detail: launch) + } + if result.stillRunning { + return Problem(kind: .timeout, title: "\(command) is still running", + hint: "It continues in the background; the panel updates when it finishes.") + } + if result.timedOut { + return Problem(kind: .timeout, title: "\(command) did not answer in time", + hint: "Docker on \(connection.label) may be stuck, or the connection dropped.", + detail: detail) + } + if case .remote(let host, let checkout) = connection { + switch result.exitCode { + case Self.sshFailed: + return Problem(kind: .connection, title: "Could not connect to \(host) over SSH", + hint: "`ssh \(host)` has to work without a password, and that Mac needs Remote Login.", + detail: result.stderr.split(whereSeparator: \.isNewline).last + .map { $0.trimmingCharacters(in: .whitespaces) }, + terminal: .init(label: "Test in Terminal", + line: "ssh -- \(Self.shellQuote(host)) exit && echo 'SSH works.'")) + case Self.noRemoteCheckout: + return Problem(kind: .connection, title: "No famstack checkout at \(checkout) on \(host)", + hint: "Set the checkout path in Setup → Connection.") + default: + break + } } + return Problem(kind: .command, title: "\(command) failed", detail: detail) + } + + /// ssh's own failures (it documents 255 for them), and the code the + /// remote wrapper exits with when the path holds no checkout. + private static let sshFailed: Int32 = 255 + private static let noRemoteCheckout: Int32 = 96 + + /// `line` run in the remote checkout, or exit 96 when there is none. + private static func inRemoteCheckout(_ checkout: String, _ line: String) -> String { + "{ cd \(remotePath(checkout)) 2>/dev/null && [ -x ./stack ]; } || exit \(noRemoteCheckout); \(line)" } // ── Helpers ────────────────────────────────────────────────────────── @@ -245,7 +327,8 @@ struct StackCLI { /// `BatchMode` makes ssh fail instead of asking for a password or a /// host key nobody can type into; Terminal sessions may ask. private static func sshOptions(batch: Bool) -> [String] { - (batch ? ["-o", "BatchMode=yes"] : []) + ["-o", "ConnectTimeout=8", "-o", "LogLevel=ERROR"] + (batch ? ["-o", "BatchMode=yes"] : []) + + ["-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=15", "-o", "LogLevel=ERROR"] } /// A remote path quoted for the remote shell, with a leading `~/` left @@ -267,11 +350,125 @@ struct StackCLI { return env } - private static func shellQuote(_ s: String) -> String { + static func shellQuote(_ s: String) -> String { "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" } - private static func stripANSI(_ s: String) -> String { + fileprivate static func stripANSI(_ s: String) -> String { s.replacingOccurrences(of: "\u{1B}\\[[0-9;?]*[A-Za-z]", with: "", options: .regularExpression) } } + +// ── One process ─────────────────────────────────────────────────────────── + +/// Runs one process and collects both of its streams. +/// +/// It is done when the process exits, not when its pipes close: `stack` +/// starts docker, and a docker child can hold the pipes open after `stack` +/// itself is gone. So after the exit the readers get a moment to drain, and +/// then the result is handed back regardless. +private final class ProcessRun: @unchecked Sendable { + private let process: Process + private let lock = NSLock() + private var stdout = Data() + private var stderr = Data() + private var continuation: CheckedContinuation? + private var whenDone: (@Sendable (StackCLI.Result) -> Void)? + private var timedOut = false + private var exited = false + private let drained = DispatchGroup() + + init(process: Process, continuation: CheckedContinuation) { + self.process = process + self.continuation = continuation + } + + func start(timeout: TimeInterval, overrun: StackCLI.Overrun) { + let out = Pipe(), err = Pipe() + process.standardOutput = out + process.standardError = err + collect(out) { self.stdout.append($0) } + collect(err) { self.stderr.append($0) } + + process.terminationHandler = { [self] process in + lock.withLock { exited = true } + DispatchQueue.global().async { [self] in + _ = drained.wait(timeout: .now() + 1) + out.fileHandleForReading.readabilityHandler = nil + err.fileHandleForReading.readabilityHandler = nil + finish(exitCode: process.terminationReason == .uncaughtSignal + ? 128 + process.terminationStatus : process.terminationStatus) + } + } + + do { + try process.run() + } catch { + out.fileHandleForReading.readabilityHandler = nil + err.fileHandleForReading.readabilityHandler = nil + deliver(StackCLI.Result(exitCode: -1, launchError: error.localizedDescription)) + return + } + + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [self] in + // Decided under the lock that `finish` also takes, so an exit in + // the same instant either wins completely or not at all. + let overran = lock.withLock { () -> Bool in + guard !exited else { return false } + switch overrun { + case .stop: timedOut = true + case .detach(let callback): whenDone = callback + } + return true + } + guard overran else { return } + switch overrun { + case .stop: + process.terminate() + // A process that ignores SIGTERM gets SIGKILL. + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [self] in + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + } + case .detach: + deliver(StackCLI.Result(stillRunning: true)) + } + } + } + + private func collect(_ pipe: Pipe, into append: @escaping (Data) -> Void) { + drained.enter() + pipe.fileHandleForReading.readabilityHandler = { [self] handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + drained.leave() + } else { + lock.withLock { append(data) } + } + } + } + + private func finish(exitCode: Int32) { + let result = lock.withLock { + StackCLI.Result(exitCode: exitCode, + stdout: StackCLI.stripANSI(String(decoding: stdout, as: UTF8.self)), + stderr: StackCLI.stripANSI(String(decoding: stderr, as: UTF8.self)), + timedOut: timedOut) + } + // Whoever is still waiting gets the result; a caller that was + // already told "still running" hears it through the callback. + if !deliver(result), let callback = lock.withLock({ whenDone }) { callback(result) } + } + + /// Resumes the caller exactly once, whichever of exit, timeout or launch + /// failure comes first. Returns whether this call was the one. + @discardableResult + private func deliver(_ result: StackCLI.Result) -> Bool { + let pending = lock.withLock { () -> CheckedContinuation? in + defer { continuation = nil } + return continuation + } + pending?.resume(returning: result) + return pending != nil + } +} diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift index 04cca594..cc15e6c5 100644 --- a/apps/macos/Sources/StackMenu/StackStore.swift +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -19,8 +19,13 @@ final class StackStore: ObservableObject { @Published private(set) var checkout: URL? @Published private(set) var remoteHost: String @Published private(set) var remoteCheckout: String + @Published private(set) var status: StackStatus? - @Published private(set) var loadError: String? + /// When `status` was last read successfully. + @Published private(set) var statusAt: Date? + /// Why the last status read failed; the panel then marks what it shows + /// as the last known state. + @Published private(set) var loadProblem: Problem? @Published private(set) var refreshing = false @Published private(set) var doctor: DoctorReport? @@ -28,17 +33,53 @@ final class StackStore: ObservableObject { @Published private(set) var host: HostReport? @Published private(set) var backup: BackupReport? @Published private(set) var config: ConfigReport? + /// Reports that could not be read, by name, so a missing section is + /// explained instead of silently absent. + @Published private(set) var reportProblems: [(report: String, problem: Problem)] = [] @Published private(set) var checking = false @Published private(set) var checkedAt: Date? - /// Stacklet id (or "" for the whole stack) to the verb running on it. - @Published private(set) var busy: [String: String] = [:] - @Published var lastFailure: ActionFailure? + /// The one lifecycle action running. Actions run one at a time: two + /// `up`s racing each other on one Docker is not something to invite. + @Published private(set) var running: RunningAction? + @Published var banner: Banner? + + struct RunningAction: Equatable { + let id = UUID() + /// A stacklet id, or "" for an action on the whole stack. + let key: String + let verb: String + let startedAt: Date + } + + /// A message at the top of the panel: an action's outcome, or a choice + /// that could not be applied. + struct Banner: Identifiable { + enum Kind { case success, info, failure } - struct ActionFailure: Identifiable { let id = UUID() - let command: [String] - let message: String + let kind: Kind + let title: String + var hint: String? + var detail: String? + /// Stack arguments to run again in Terminal, where the output stays. + var retry: [String]? + var terminal: Problem.TerminalCommand? + + init(_ kind: Kind, _ title: String, hint: String? = nil) { + self.kind = kind + self.title = title + self.hint = hint + } + + init(_ kind: Kind, _ problem: Problem, title: String? = nil, retry: [String]? = nil) { + self.kind = kind + self.title = title ?? problem.title + hint = problem.hint + detail = problem.detail + terminal = problem.terminal + self.retry = retry + } } private static let checkoutKey = "checkout" @@ -48,10 +89,17 @@ final class StackStore: ObservableObject { private static let defaultRemoteCheckout = "~/famstack" private static let statusInterval: TimeInterval = 60 private static let detailInterval: TimeInterval = 600 + private static let successBannerSeconds: UInt64 = 6 + private var timers: [Timer] = [] + private var wakeObserver: NSObjectProtocol? /// Bumped on every switch of machine, so an answer that arrives after /// the switch is dropped instead of shown for the wrong machine. private var generation = 0 + /// A read asked for while one runs is not dropped: it runs right after, + /// against whatever machine is selected by then. + private var refreshQueued = false + private var checkQueued = false init() { let defaults = UserDefaults.standard @@ -67,6 +115,17 @@ final class StackStore: ObservableObject { Task { @MainActor in await self?.check() } }, ] + // After sleep the last answer is hours old. The network needs a + // moment to come back, a remote machine especially. + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + await self?.refresh() + await self?.check() + } + } Task { await refresh() await check() @@ -86,37 +145,62 @@ final class StackStore: ObservableObject { // ── Reading ────────────────────────────────────────────────────────── func refresh() async { - guard let cli, !refreshing else { return } - let started = generation + if refreshing { refreshQueued = true; return } refreshing = true defer { refreshing = false } - do { - let fresh = try await cli.status() - guard started == generation else { return } - status = fresh - loadError = nil - } catch { - guard started == generation else { return } - loadError = error.localizedDescription - } + repeat { + refreshQueued = false + guard let cli else { return } + let started = generation + do { + let fresh = try await cli.status() + guard started == generation else { continue } + status = fresh + statusAt = Date() + loadProblem = nil + } catch { + guard started == generation else { continue } + loadProblem = error as? Problem + ?? Problem(kind: .command, title: "Could not read the stack's status", detail: "\(error)") + } + } while refreshQueued } /// The slower reports, all at once. One failing leaves the others. func check() async { - guard let cli, !checking else { return } - let started = generation + if checking { checkQueued = true; return } checking = true defer { checking = false } - let wantsBackup = status?.isInstalled("backup") ?? false - async let doctor = try? cli.doctor() - async let errors = try? cli.errors() - async let host = try? cli.host() - async let backup = wantsBackup ? try? cli.backup() : nil - async let config = try? cli.config() - let reports = await (doctor, errors, host, backup, config) - guard started == generation else { return } - (self.doctor, self.errors, self.host, self.backup, self.config) = reports - checkedAt = Date() + repeat { + checkQueued = false + guard let cli else { return } + let started = generation + // Right after a switch of machine there is no status yet, and + // without it the backup report would be skipped. + if status == nil { await refresh() } + let wantsBackup = status?.isInstalled("backup") ?? false + async let doctor = Self.attempt { try await cli.doctor() } + async let errors = Self.attempt { try await cli.errors() } + async let host = Self.attempt { try await cli.host() } + async let backup = wantsBackup ? Self.attempt { try await cli.backup() } : (nil, nil) + async let config = Self.attempt { try await cli.config() } + let (d, e, h, b, c) = await (doctor, errors, host, backup, config) + guard started == generation else { continue } + (self.doctor, self.errors, self.host, self.backup, self.config) = (d.0, e.0, h.0, b.0, c.0) + reportProblems = [("Doctor", d.1), ("Errors", e.1), ("Memory", h.1), ("Backup", b.1), ("Setup", c.1)] + .compactMap { name, problem in problem.map { (name, $0) } } + checkedAt = Date() + } while checkQueued + } + + private static func attempt(_ read: () async throws -> T) async -> (T?, Problem?) { + do { + return (try await read(), nil) + } catch let problem as Problem { + return (nil, problem) + } catch { + return (nil, Problem(kind: .command, title: "\(error)")) + } } /// When the panel opens: status always, the rest unless just done. @@ -139,13 +223,17 @@ final class StackStore: ObservableObject { host?.stacklets.first { $0.id == stacklet }?.memoryBytes } + func name(of stacklet: String) -> String { + status?.stacklets.first { $0.id == stacklet }?.name ?? stacklet + } + /// What the menu bar icon shows, worst state first. Only errors turn it /// into a warning: a warning that never clears (no backup set up) would /// teach the admin to stop looking at the icon. var summary: Summary { guard connection != nil else { return .unconfigured } - guard let status, loadError == nil else { return .unreachable } - if !busy.isEmpty { return .busy } + guard let status, loadProblem == nil else { return .unreachable } + if running != nil { return .busy } if status.installed.contains(where: { $0.health.needsAttention }) { return .attention } if attention.contains(where: { $0.level == .error }) { return .attention } return .healthy @@ -166,21 +254,76 @@ final class StackStore: ObservableObject { // ── Acting ─────────────────────────────────────────────────────────── - /// Runs a lifecycle verb in the background, then re-reads everything. - /// One verb per stacklet at a time; the CLI serialises the rest. + /// Runs a lifecycle verb in the background, says how it went, and + /// re-reads everything. func perform(_ verb: String, _ stacklet: String? = nil) async { - guard let cli else { return } + guard let cli, running == nil else { return } let key = stacklet ?? "" - guard busy[key] == nil else { return } let command = [verb] + (stacklet.map { [$0] } ?? []) - busy[key] = verb - let result = await cli.run(command) - busy[key] = nil - if !result.succeeded { - lastFailure = ActionFailure(command: command, message: result.tail()) + let started = generation + let action = RunningAction(key: key, verb: verb, startedAt: Date()) + running = action + banner = nil + + let result = await cli.run(command, timeout: StackCLI.actionTimeout, overrun: .detach { [weak self] late in + Task { @MainActor in self?.finish(command, late, generation: started) } + }) + // Only this action's own indicator is cleared: after a detach or a + // switch of machine, another action may be the running one by now. + if running?.id == action.id { running = nil } + if result.stillRunning { + // Left running; its callback reports the outcome later. The + // panel is free for other actions meanwhile. + show(Banner(.info, cli.diagnose(result, command))) + return + } + finish(command, result, generation: started) + } + + private func finish(_ command: [String], _ result: StackCLI.Result, generation started: Int) { + guard started == generation, let cli else { return } + let target = command.count > 1 ? name(of: command[1]) : nil + if result.succeeded { + show(Banner(.success, Self.done(command[0], target))) + } else { + let problem = cli.diagnose(result, command) + let title = problem.kind == .command ? Self.failed(command[0], target) : nil + show(Banner(.failure, problem, title: title, retry: command)) + } + Task { + await refresh() + await check() + } + } + + private static func done(_ verb: String, _ target: String?) -> String { + guard let target else { return verb == "restart" ? "Restarted what was running old code" : "Done" } + return switch verb { + case "up": "\(target) started" + case "down": "\(target) stopped" + case "restart": "\(target) restarted" + default: "Done" + } + } + + private static func failed(_ verb: String, _ target: String?) -> String { + let what = target ?? "the stacklets running old code" + return switch verb { + case "up": "Could not start \(what)" + case "down": "Could not stop \(what)" + case "restart": "Could not restart \(what)" + default: "stack \(verb) failed" + } + } + + /// Shows a banner; a success clears itself after a few seconds. + private func show(_ new: Banner) { + banner = new + guard new.kind == .success else { return } + Task { + try? await Task.sleep(nanoseconds: Self.successBannerSeconds * 1_000_000_000) + if banner?.id == new.id { banner = nil } } - await refresh() - await check() } /// A doctor fix: `stack up|restart|down ` runs in the background like @@ -189,17 +332,28 @@ final class StackStore: ObservableObject { let words = fix.split(separator: " ").map(String.init) if words.count == 3, words[0] == "stack", ["up", "restart", "down"].contains(words[1]) { Task { await perform(words[1], words[2]) } - } else { - cli?.openInTerminal(fix: fix) + } else if cli?.openInTerminal(fix: fix) == false { + show(Banner(.failure, "Could not open Terminal")) } } func openInTerminal(_ arguments: [String]) { - cli?.openInTerminal(arguments) + if cli?.openInTerminal(arguments) == false { + show(Banner(.failure, "Could not open Terminal")) + } + } + + func openTerminal(_ command: Problem.TerminalCommand) { + if !StackCLI.openShell(command.line) { + show(Banner(.failure, "Could not open Terminal")) + } } func edit(_ path: String) { - cli?.edit(path) + if cli?.edit(path) == false { + show(Banner(.failure, "Could not open \((path as NSString).lastPathComponent)", + hint: "Open it yourself: \(path)")) + } } /// The address the CLI names for the stacklet. A checkout older than @@ -208,8 +362,10 @@ final class StackStore: ObservableObject { func openInBrowser(_ stacklet: Stacklet) { var address = stacklet.url if address == nil, mode == .local, let port = stacklet.port { address = "http://localhost:\(port)" } - guard let address, let url = URL(string: address) else { return } - NSWorkspace.shared.open(url) + guard let address, let url = URL(string: address), NSWorkspace.shared.open(url) else { + show(Banner(.failure, "Could not open \(stacklet.name) in the browser")) + return + } } func canOpen(_ stacklet: Stacklet) -> Bool { @@ -220,16 +376,18 @@ final class StackStore: ObservableObject { func setMode(_ new: Mode) { guard new != mode else { return } + // Switching to a remote that was never set up asks for it first, + // and a cancel leaves the switch where it was. + if new == .remote && remoteHost.isEmpty && !editRemote(switching: true) { return } mode = new UserDefaults.standard.set(new.rawValue, forKey: Self.modeKey) - if new == .remote && remoteHost.isEmpty { - editRemote() - } reconnect() } - /// Asks for the SSH host and the checkout path on it. - func editRemote() { + /// Asks for the SSH host and the checkout path on it. Returns whether + /// something was saved. + @discardableResult + func editRemote(switching: Bool = false) -> Bool { let host = NSTextField(string: remoteHost) host.placeholderString = "ssh alias or user@host" let path = NSTextField(string: remoteCheckout) @@ -243,20 +401,34 @@ final class StackStore: ObservableObject { let alert = NSAlert() alert.messageText = "Connect to a stack on another Mac" - alert.informativeText = "The app runs ./stack there over SSH with your key. The server needs Remote Login turned on." + alert.informativeText = "The app runs ./stack there over SSH with your key, without a password. " + + "That Mac needs Remote Login turned on." alert.accessoryView = fields alert.addButton(withTitle: "Connect") alert.addButton(withTitle: "Cancel") NSApp.activate(ignoringOtherApps: true) alert.window.initialFirstResponder = host - guard alert.runModal() == .alertFirstButtonReturn else { return } - - remoteHost = host.stringValue.trimmingCharacters(in: .whitespaces) - let typed = path.stringValue.trimmingCharacters(in: .whitespaces) - remoteCheckout = typed.isEmpty ? Self.defaultRemoteCheckout : typed + guard alert.runModal() == .alertFirstButtonReturn else { return false } + + let newHost = host.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let typedPath = path.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + // Whatever is typed becomes an ssh argument: a leading dash would be + // read as an option, and whitespace is never part of a host. + guard !newHost.isEmpty, !newHost.hasPrefix("-"), !newHost.contains(where: \.isWhitespace) else { + show(Banner(.failure, "“\(newHost)” is not an SSH host", + hint: "Use an alias from ~/.ssh/config or user@host.")) + return false + } + guard !typedPath.contains(where: \.isNewline) else { + show(Banner(.failure, "The checkout path cannot span lines")) + return false + } + remoteHost = newHost + remoteCheckout = typedPath.isEmpty ? Self.defaultRemoteCheckout : typedPath UserDefaults.standard.set(remoteHost, forKey: Self.remoteHostKey) UserDefaults.standard.set(remoteCheckout, forKey: Self.remoteCheckoutKey) - if mode == .remote { reconnect() } + if mode == .remote && !switching { reconnect() } + return true } private func label(_ text: String) -> NSTextField { @@ -269,8 +441,9 @@ final class StackStore: ObservableObject { /// Forgets everything read from the previous machine and reads again. private func reconnect() { generation += 1 - status = nil; loadError = nil; lastFailure = nil - doctor = nil; errors = nil; host = nil; backup = nil; config = nil; checkedAt = nil + status = nil; statusAt = nil; loadProblem = nil; banner = nil; running = nil + doctor = nil; errors = nil; host = nil; backup = nil; config = nil + reportProblems = []; checkedAt = nil Task { await refresh() await check() @@ -285,7 +458,8 @@ final class StackStore: ObservableObject { NSApp.activate(ignoringOtherApps: true) guard panel.runModal() == .OK, let url = panel.url else { return } guard StackCLI.isCheckout(url) else { - loadError = "\(url.path) has no ./stack and stacklets/ folder" + show(Banner(.failure, "\(url.lastPathComponent) is not a famstack checkout", + hint: "Choose the folder that holds ./stack and stacklets/.")) return } UserDefaults.standard.set(url.path, forKey: Self.checkoutKey) From 60bd2d8696c3de4e4e9176efbab27ad788511260 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Oct 2026 13:31:33 +0200 Subject: [PATCH 15/19] fix(doctor): point an exited container's fix at its stacklet's logs `stack doctor` derived the stacklet for `stack logs` from the container name. A container left from an older version of a stacklet keeps its old name, so `famstack-wiki-preview-de` in the memory project got `stack logs wiki`, a command for a stacklet that does not exist. The fix names the stacklet the container belongs to. --- lib/stack/doctor.py | 6 +++--- tests/unit/framework/test_doctor.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/stack/doctor.py b/lib/stack/doctor.py index 3e69d702..36feb942 100644 --- a/lib/stack/doctor.py +++ b/lib/stack/doctor.py @@ -99,7 +99,7 @@ def check_env_drift(stacklet: str, container: str, drifted: list[str]) -> Findin ) -def check_exited(container: str, exit_code: int, since: str) -> Finding | None: +def check_exited(stacklet: str, container: str, exit_code: int, since: str) -> Finding | None: """A container that stopped and stayed stopped. `stack status` reports the stacklet as failing without naming which @@ -112,7 +112,7 @@ def check_exited(container: str, exit_code: int, since: str) -> Finding | None: level=ERROR, title=f"{container} exited ({exit_code})", detail=f"Stopped {since} and has not come back.", - fix=f"stack logs {container.split('-')[1] if '-' in container else container}", + fix=f"stack logs {stacklet}", ) @@ -315,7 +315,7 @@ def diagnose(stacklets, expected_env, containers_for, container_env, for container in containers: name = container["name"] if container["state"] != "running": - found = check_exited(name, container["exit_code"], container["since"]) + found = check_exited(stacklet, name, container["exit_code"], container["since"]) if found: findings.append(found) # A stopped container's environment says nothing useful. diff --git a/tests/unit/framework/test_doctor.py b/tests/unit/framework/test_doctor.py index 5d2fd377..48c6c3d6 100644 --- a/tests/unit/framework/test_doctor.py +++ b/tests/unit/framework/test_doctor.py @@ -249,19 +249,27 @@ def test_no_drift_produces_no_finding(): def test_clean_exit_is_not_a_finding(): - assert check_exited("stack-core-job", 0, "2 minutes ago") is None + assert check_exited("core", "stack-core-job", 0, "2 minutes ago") is None def test_nonzero_exit_names_the_container_and_code(): # The real case: watchtower Exited(128) three weeks ago, while status # only said the stacklet was failing. - finding = check_exited("stack-core-watchtower", 128, "3 weeks ago") + finding = check_exited("core", "stack-core-watchtower", 128, "3 weeks ago") assert finding.is_error assert "stack-core-watchtower" in finding.title assert "128" in finding.title assert "3 weeks ago" in finding.detail +def test_the_fix_reads_the_logs_of_the_containers_stacklet(): + # A container left from an older version of a stacklet keeps its old + # name ("famstack-wiki-preview-de" in the memory project); the name says + # nothing about which stacklet's logs to read. + finding = check_exited("memory", "famstack-wiki-preview-de", 1, "11 days ago") + assert finding.fix == "stack logs memory" + + # ── missing credentials ────────────────────────────────────────────────── # # The real incident: `memory` mints its Forgejo write token in From ab059d42be56e2d9ce742d5ba5d5f387a2f88c19 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Oct 2026 13:32:18 +0200 Subject: [PATCH 16/19] fix: give the menu bar app's uptime in hours on the first day The panel said "up 0 days" for a Mac started today and "up 1 days" a day later. It gives hours for the first day, then "1 day" and "n days". --- apps/macos/Sources/StackMenu/MenuPanel.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift index 16d24731..b4bf662e 100644 --- a/apps/macos/Sources/StackMenu/MenuPanel.swift +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -122,11 +122,21 @@ struct MenuPanel: View { private func subtitle(_ status: StackStatus) -> String { let running = status.installed.filter { $0.online }.count var parts = ["\(running) of \(status.installed.count) running"] - if let up = store.host?.uptimeSeconds { parts.append("up \(up / 86400) days") } + if let up = store.host?.uptimeSeconds { parts.append("up \(Self.uptime(up))") } if let connection = store.connection, connection.isRemote { parts.append("on \(connection.label)") } return parts.joined(separator: " · ") } + /// Hours for the first day, then days: "up 0 days" said nothing. + private static func uptime(_ seconds: Int) -> String { + let days = seconds / 86400 + if days == 0 { + let hours = max(1, seconds / 3600) + return hours == 1 ? "1 hour" : "\(hours) hours" + } + return days == 1 ? "1 day" : "\(days) days" + } + // ── Overview ───────────────────────────────────────────────────────── @ViewBuilder private var overview: some View { From 5709b14ce4c2820e29480d85552b3e11c74b87e0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Oct 2026 13:32:18 +0200 Subject: [PATCH 17/19] build: version the menu bar app with the stack it ships with The bundle said 0.1.0 whatever the release. `build.sh` takes the version from the CLI, without a release candidate's label, which macOS does not accept there, and uses the commit count as the build number, so a release candidate and its release differ. --- apps/macos/build.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/macos/build.sh b/apps/macos/build.sh index bec4b469..2936d839 100755 --- a/apps/macos/build.sh +++ b/apps/macos/build.sh @@ -26,7 +26,14 @@ rm -rf build/AppIcon.iconset ".build/$config/StackMenu" --icon build/AppIcon.iconset iconutil -c icns -o "$app/Contents/Resources/AppIcon.icns" build/AppIcon.iconset -cat > "$app/Contents/Info.plist" <<'PLIST' +# The app is versioned with the stack it ships with. macOS wants digits +# only here, so a release candidate's label is left off: 0.4.0-rc.1 -> 0.4.0. +# The build number tells a release candidate from the release: the commit +# count, which only grows. The Setup tab shows the stack's full version. +version="$(sed -n 's/^VERSION = "\([0-9.]*\).*"/\1/p' ../../lib/stack/cli.py)" +build="$(git rev-list --count HEAD 2>/dev/null || echo 0)" + +cat > "$app/Contents/Info.plist" < @@ -36,7 +43,8 @@ cat > "$app/Contents/Info.plist" <<'PLIST' CFBundleExecutable StackMenu CFBundleIconFile AppIcon CFBundlePackageType APPL - CFBundleShortVersionString 0.1.0 + CFBundleShortVersionString ${version} + CFBundleVersion ${build} LSMinimumSystemVersion 14.0 LSUIElement From 47c1c4e78d712b714bbba044e21d8615117fd227 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Oct 2026 13:33:19 +0200 Subject: [PATCH 18/19] docs: describe how to install and connect the menu bar app The admin guide gets a section on the menu bar app: what it shows, how to build and install it from the checkout, and how to connect it to the server Mac over SSH. The app's README calls it beta and names the copy step; the operator guide says to rebuild it after an update. --- apps/macos/README.md | 8 ++++++-- docs/admin-guide.md | 35 +++++++++++++++++++++++++++++++++++ docs/agent/ops.md | 1 + 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index 71e67478..d6fb92d8 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -1,4 +1,4 @@ -# Menu bar app (prototype) +# Menu bar app (beta) A native macOS menu bar app for the stack on this Mac. **Overview** answers, in this order: is everything fine, what needs me, what is running, what @@ -36,9 +36,13 @@ Needs macOS 14 and the Command Line Tools; no Xcode. ```bash apps/macos/build.sh # prints the path of build/famstack.app -open apps/macos/build/famstack.app +cp -R apps/macos/build/famstack.app /Applications/ +open /Applications/famstack.app ``` +The build is signed ad hoc, for the Mac it was built on. After +`./stack update`, build and copy it again. + To check a layout change without opening the menu, render both tabs from the real data to PNG files (`-overview.png`, `-setup.png`): diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 26eacf2f..48109b26 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -739,6 +739,41 @@ Key things to know: All commands output JSON when piped. Use `--json` to force it, `--pretty` to force human output. +### The menu bar app (beta) + +A macOS menu bar app shows the stack at a glance: what needs you (with the +command that fixes it), each stacklet with start, stop, restart, open and +logs, errors from the last 24 hours, and the Mac's disk and memory. A +**Setup** tab shows the family, address mode, language, AI and data dir, and +opens `stack.toml` and `users.toml` for editing. It runs the `stack` CLI and +nothing else, so it starts no services and keeps no state of its own. + +Install it from the checkout. It needs macOS 14 and the Command Line Tools, +which Homebrew already installed: + +```bash +apps/macos/build.sh # builds apps/macos/build/famstack.app +cp -R apps/macos/build/famstack.app /Applications/ +open /Applications/famstack.app +``` + +The app is signed for the Mac it was built on, so build it on the Mac that +runs it. After `./stack update`, run both commands again to update the app. + +**Which machine.** **Setup → Connection** switches between this Mac and +another one: + +- **This Mac**: the checkout you run `./stack` from (`~/famstack` unless you + choose another with **Change…**). +- **Remote**: the server Mac over SSH, from a laptop for example. Give its + SSH host (an alias from `~/.ssh/config` or `user@host`) and its checkout + path. The server needs **Remote Login** on (System Settings → General → + Sharing), and your SSH key must work without a password prompt. Logs and + editing open Terminal with an SSH session to the server. + +Against a checkout older than 0.4.0 the app leaves out the sections that need +newer commands (doctor, errors, memory, setup) and says so. + --- ## Backups diff --git a/docs/agent/ops.md b/docs/agent/ops.md index af26ca85..ab23dd31 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -132,6 +132,7 @@ A release is a git tag. `./stack update` moves the checkout to one and names wha - **Works from a branch or a fork.** On `main` it moves to the tag and says the branch is left behind (`git switch main` returns). Tags are fetched from every remote, so a fork needs the project added as a remote or there is nothing to update to. - **A tag checkout is a detached HEAD.** `git pull` fails there. Never suggest `git pull origin main` as the fix: it succeeds and silently moves the instance onto unreleased code while `./stack version` still reports the last release. - **`./stack version` now says what is running,** not what the source constant claims: `0.3.0-beta.3-104-g9a5bdc9-dirty` is the tag, the distance past it, the commit, and an unclean tree. `version`, `status`, `list`, `doctor` and `update` all print the same string. `./stack doctor` adds a warning when a newer release exists, without fetching, so that half is as fresh as the last `git fetch`. +- **The menu bar app is not updated with the checkout.** If the admin uses it (`/Applications/famstack.app`), rebuild and copy it after an update: `apps/macos/build.sh && cp -R apps/macos/build/famstack.app /Applications/`. See the admin guide, "The menu bar app". - **The instance survives a tag switch.** Config, secrets, data and `~/-extensions/` are all outside git. Full prose: [../admin-guide.md](../admin-guide.md) § Updating. From eacb7b08bde29ea4db4751bc28f6a2db9866d5fa Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Oct 2026 13:39:18 +0200 Subject: [PATCH 19/19] feat(cli): install the menu bar app with stack app install The menu bar app is signed for the Mac that builds it, so it ships as source. Installing it took the build script and a copy by hand, and an update meant knowing to do both again. `./stack app install` builds the app from the checkout, puts it in `/Applications` (`~/Applications` when that is not writable), replacing an older build as a whole, and opens it. Running it again updates the app, and `./stack update` says so when the app is installed. --- apps/macos/README.md | 7 +- docs/admin-guide.md | 11 +-- docs/agent/ops.md | 3 +- lib/stack/app.py | 90 +++++++++++++++++++ lib/stack/cli.py | 47 ++++++++++ tests/unit/framework/test_app_install.py | 107 +++++++++++++++++++++++ 6 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 lib/stack/app.py create mode 100644 tests/unit/framework/test_app_install.py diff --git a/apps/macos/README.md b/apps/macos/README.md index d6fb92d8..209bdd00 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -35,13 +35,12 @@ checkout older than those commands, the sections that need them are left out. Needs macOS 14 and the Command Line Tools; no Xcode. ```bash -apps/macos/build.sh # prints the path of build/famstack.app -cp -R apps/macos/build/famstack.app /Applications/ -open /Applications/famstack.app +./stack app install # builds, puts it in /Applications, opens it ``` The build is signed ad hoc, for the Mac it was built on. After -`./stack update`, build and copy it again. +`./stack update`, run `./stack app install` again. For development, +`apps/macos/build.sh` builds `build/famstack.app` without installing it. To check a layout change without opening the menu, render both tabs from the real data to PNG files (`-overview.png`, `-setup.png`): diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 48109b26..5db54227 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -752,13 +752,14 @@ Install it from the checkout. It needs macOS 14 and the Command Line Tools, which Homebrew already installed: ```bash -apps/macos/build.sh # builds apps/macos/build/famstack.app -cp -R apps/macos/build/famstack.app /Applications/ -open /Applications/famstack.app +./stack app install ``` -The app is signed for the Mac it was built on, so build it on the Mac that -runs it. After `./stack update`, run both commands again to update the app. +That builds the app (about a minute the first time), puts it in +`/Applications` (or `~/Applications` when that is not writable) and opens it. +The app is signed for the Mac it was built on, so install it on the Mac that +runs it. After `./stack update`, run `./stack app install` again to update the +app; `update` reminds you when the app is installed. **Which machine.** **Setup → Connection** switches between this Mac and another one: diff --git a/docs/agent/ops.md b/docs/agent/ops.md index ab23dd31..b7e53a74 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -39,6 +39,7 @@ If a precondition is missing, `./stack` prints exactly what to do. Don't improvi | `./stack logs ` | yes | no | tail container logs | | `./stack errors` | yes | no | recent error logs (24h) | | `./stack host` | yes | no | disk / memory / uptime | +| `./stack app install` | yes | no | builds the menu bar app from the checkout, puts it in `/Applications` and opens it; again after an update | | `./stack config [--secrets]` | yes | no | prints resolved config | | `./stack ai models` | yes | no | lists installed AI models | | `./stack ai switch ` | yes | no | points `[ai]` at an AI server the stack does not manage; checks it, installs nothing, restarts nothing. Warns when the address is outside the home network | @@ -132,7 +133,7 @@ A release is a git tag. `./stack update` moves the checkout to one and names wha - **Works from a branch or a fork.** On `main` it moves to the tag and says the branch is left behind (`git switch main` returns). Tags are fetched from every remote, so a fork needs the project added as a remote or there is nothing to update to. - **A tag checkout is a detached HEAD.** `git pull` fails there. Never suggest `git pull origin main` as the fix: it succeeds and silently moves the instance onto unreleased code while `./stack version` still reports the last release. - **`./stack version` now says what is running,** not what the source constant claims: `0.3.0-beta.3-104-g9a5bdc9-dirty` is the tag, the distance past it, the commit, and an unclean tree. `version`, `status`, `list`, `doctor` and `update` all print the same string. `./stack doctor` adds a warning when a newer release exists, without fetching, so that half is as fresh as the last `git fetch`. -- **The menu bar app is not updated with the checkout.** If the admin uses it (`/Applications/famstack.app`), rebuild and copy it after an update: `apps/macos/build.sh && cp -R apps/macos/build/famstack.app /Applications/`. See the admin guide, "The menu bar app". +- **The menu bar app is not updated with the checkout.** If the admin uses it (`/Applications/famstack.app`), run `./stack app install` again after an update; `update` reminds the admin when the app is installed. See the admin guide, "The menu bar app". - **The instance survives a tag switch.** Config, secrets, data and `~/-extensions/` are all outside git. Full prose: [../admin-guide.md](../admin-guide.md) § Updating. diff --git a/lib/stack/app.py b/lib/stack/app.py new file mode 100644 index 00000000..1e7ae030 --- /dev/null +++ b/lib/stack/app.py @@ -0,0 +1,90 @@ +"""The menu bar app: built from the checkout, installed into Applications. + +The app is signed ad hoc by `apps/macos/build.sh`, which makes it run on the +Mac that built it and nowhere else, so it ships as source and every install +is a build. Installing again after `stack update` is how it is updated. + + result = install(repo_root) # {"installed": "/Applications/famstack.app"} + installed() # the bundle's path, or None +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +BUNDLE = "famstack.app" +# The system's Applications folder first; the user's own when that one is +# not writable, which is the case for an account that is not an admin. +FOLDERS = [Path("/Applications"), Path(os.path.expanduser("~/Applications"))] +# A release build with SwiftPM takes about a minute on an M1. +BUILD_TIMEOUT_SECS = 900 + + +def installed(folders: list[Path] = FOLDERS) -> Path | None: + """Where the app is installed, or None.""" + for folder in folders: + if (folder / BUNDLE).exists(): + return folder / BUNDLE + return None + + +def install(repo_root: Path, *, folders: list[Path] = FOLDERS, open_after: bool = True) -> dict: + """Build the app from the checkout and put it in the first writable folder. + + An app already there is replaced as a whole, so nothing of an older + build survives. A failed build installs nothing. + """ + script = Path(repo_root) / "apps" / "macos" / "build.sh" + if not script.exists(): + return {"error": "this checkout has no menu bar app (apps/macos); it arrived in 0.4.0"} + + built, error = _build(script) + if built is None: + return {"error": error} + + target = _writable(folders) + if target is None: + return {"error": "no Applications folder to write to: " + ", ".join(str(f) for f in folders)} + destination = target / BUNDLE + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(built, destination, symlinks=True) + + if open_after: + subprocess.run(["open", str(destination)], check=False) + return {"installed": str(destination)} + + +def _build(script: Path) -> tuple[Path | None, str]: + """Run the build script; the bundle it prints, or why it failed.""" + try: + done = subprocess.run([str(script)], capture_output=True, text=True, + timeout=BUILD_TIMEOUT_SECS) + except FileNotFoundError: + return None, f"cannot run {script}" + except subprocess.TimeoutExpired: + return None, f"the build took longer than {BUILD_TIMEOUT_SECS // 60} minutes" + if done.returncode != 0: + tail = (done.stderr or done.stdout).strip().splitlines()[-5:] + hint = ("" if shutil.which("swift") else + " Swift is missing: install the Command Line Tools with `xcode-select --install`.") + return None, "the build failed: " + " / ".join(tail) + hint + lines = done.stdout.strip().splitlines() + built = Path(lines[-1]) if lines else None + if built is None or not built.is_dir(): + return None, "the build printed no app bundle" + return built, "" + + +def _writable(folders: list[Path]) -> Path | None: + for folder in folders: + try: + folder.mkdir(parents=True, exist_ok=True) + except OSError: + continue + if os.access(folder, os.W_OK): + return folder + return None diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 19391cf1..3803dadb 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -31,6 +31,7 @@ from dataclasses import asdict from pathlib import Path +from . import app as menubar_app from . import caddy from . import docker from . import doctor @@ -1394,6 +1395,42 @@ def handle_errors(stck, args): print(f" {DIM}more:{RESET} {TEAL}./stack logs {entry['stacklet']}{RESET}\n") +APP_VERBS = {"install": "build the menu bar app from this checkout and put it in Applications"} + + +def handle_app(stck, args): + """The menu bar app. `install` builds it here and installs it; running + it again after `stack update` updates it. Without a verb, the verbs.""" + if not args.verb: + report = {"commands": APP_VERBS} + if args.json: + json.dump(report, sys.stdout, indent=2) + print() + return + print() + for verb, what in APP_VERBS.items(): + print(f" {TEAL}./stack app {verb}{RESET} {DIM}{what}{RESET}") + print() + return + if args.verb not in APP_VERBS: + print_error({"error": f"Unknown verb '{args.verb}'. Use: {', '.join(APP_VERBS)}"}) + sys.exit(1) + + if not args.json: + print("\n Building the menu bar app (about a minute)...") + result = menubar_app.install(stck.root, open_after=not args.json) + if "error" in result: + print_error(result) + sys.exit(1) + if args.json: + json.dump(result, sys.stdout, indent=2) + print() + return + print(f" {GREEN}\u2713{RESET} Installed {TEAL}{result['installed']}{RESET}") + print(f" {DIM}It sits in the menu bar. Setup \u2192 Connection picks this Mac " + f"or another one over SSH.{RESET}\n") + + def _uptime_seconds() -> int | None: """Seconds since boot, from `sysctl kern.boottime` (`{ sec = 1727..., ...}`).""" try: @@ -1911,6 +1948,11 @@ def handle_update(stck, args): print(f"\n {GREEN}\u2713{RESET} Updated to {TEAL}{target}{RESET}") _print_restart_advice(targets, changed, running, touches_framework, touched_stacklets) + # The app is built from the checkout, so moving the checkout leaves the + # installed one behind until it is built again. + if menubar_app.installed(): + print(f" {DIM}The menu bar app is built from the checkout:{RESET} " + f"{TEAL}./stack app install{RESET} {DIM}updates it.{RESET}\n") def print_restart_call_to_action(targets, framework=False) -> None: @@ -2057,6 +2099,7 @@ def _plugin_help(module_path: str): "logs": handle_logs, "errors": handle_errors, "host": handle_host, + "app": handle_app, "version": handle_version, "update": handle_update, } @@ -2083,6 +2126,7 @@ def _plugin_help(module_path: str): ]), ("Setup", [ ("update []", "Move the checkout to a release (says what to restart)"), + ("app install", "Build the menu bar app and put it in Applications"), ("install", "Interactive setup wizard"), ("uninstall", "Remove all services, config, and data"), ("init", "Create Docker network and data directories"), @@ -2206,6 +2250,9 @@ def main(): p = sub.add_parser("errors") p.add_argument("--since", default="24h", help="How far back to look: 30m, 24h, 168h") p.add_argument("--json", action="store_true", help="Output as JSON") + p = sub.add_parser("app") + p.add_argument("verb", nargs="?", default=None, help="install") + p.add_argument("--json", action="store_true", help="Output as JSON") p = sub.add_parser("host") p.add_argument("--json", action="store_true", help="Output as JSON") diff --git a/tests/unit/framework/test_app_install.py b/tests/unit/framework/test_app_install.py new file mode 100644 index 00000000..81a408b6 --- /dev/null +++ b/tests/unit/framework/test_app_install.py @@ -0,0 +1,107 @@ +"""`stack app install`: the menu bar app, built from the checkout and installed. + +The app is built on the Mac that runs it (it is signed for that Mac only), +so installing means: run the checkout's build script, put the bundle in an +Applications folder, replace whatever version was there. Running it again +after `stack update` is how the app is updated. + +These tests drive `install` against a checkout whose build script writes a +stand-in bundle, so they run without Swift. +""" + +from __future__ import annotations + +import os +import stat +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "lib")) + +from stack import app # noqa: E402 + +BUILD = """#!/bin/sh +set -e +cd "$(dirname "$0")" +mkdir -p build/famstack.app/Contents +echo "{marker}" > build/famstack.app/Contents/marker +echo "$(pwd)/build/famstack.app" +""" + + +def _checkout(tmp_path: Path, marker: str = "new", script: str = BUILD) -> Path: + root = tmp_path / "checkout" + build = root / "apps" / "macos" / "build.sh" + build.parent.mkdir(parents=True, exist_ok=True) + build.write_text(script.format(marker=marker)) + build.chmod(build.stat().st_mode | stat.S_IEXEC) + return root + + +def _marker(bundle: Path) -> str: + return (bundle / "Contents" / "marker").read_text().strip() + + +class TestInstallingTheApp: + + def test_the_built_app_lands_in_applications(self, tmp_path): + root = _checkout(tmp_path) + apps = tmp_path / "Applications" + apps.mkdir() + + result = app.install(root, folders=[apps], open_after=False) + + assert result["installed"] == str(apps / "famstack.app") + assert _marker(apps / "famstack.app") == "new" + + def test_installing_again_replaces_the_old_app(self, tmp_path): + # The update path: an older build is there and must not survive in parts. + apps = tmp_path / "Applications" + old = apps / "famstack.app" / "Contents" + old.mkdir(parents=True) + (old / "marker").write_text("old") + (old / "left-over").write_text("from the old build") + + app.install(_checkout(tmp_path), folders=[apps], open_after=False) + + assert _marker(apps / "famstack.app") == "new" + assert not (apps / "famstack.app" / "Contents" / "left-over").exists() + + def test_a_folder_that_cannot_be_written_falls_back_to_the_next(self, tmp_path): + locked = tmp_path / "Applications" + locked.mkdir() + locked.chmod(0o555) + home_apps = tmp_path / "home" / "Applications" + try: + result = app.install(_checkout(tmp_path), folders=[locked, home_apps], open_after=False) + finally: + locked.chmod(0o755) + + assert result["installed"] == str(home_apps / "famstack.app") + + def test_a_failed_build_installs_nothing_and_says_why(self, tmp_path): + apps = tmp_path / "Applications" + apps.mkdir() + root = _checkout(tmp_path, script="#!/bin/sh\necho 'error: no such module SwiftUI' >&2\nexit 1\n") + + result = app.install(root, folders=[apps], open_after=False) + + assert "error" in result + assert not (apps / "famstack.app").exists() + + def test_a_checkout_without_the_app_says_so(self, tmp_path): + result = app.install(tmp_path, folders=[tmp_path], open_after=False) + + assert "apps/macos" in result["error"] + + +def test_installed_finds_the_app_in_either_folder(tmp_path): + home_apps = tmp_path / "home" / "Applications" + (home_apps / "famstack.app").mkdir(parents=True) + + assert app.installed(folders=[tmp_path / "Applications", home_apps]) == home_apps / "famstack.app" + assert app.installed(folders=[tmp_path / "nowhere"]) is None + + +def test_the_default_folders_are_the_system_then_the_users_applications(): + assert app.FOLDERS == [Path("/Applications"), Path(os.path.expanduser("~/Applications"))]