Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cb7ce6f
feat: add a menu bar app that shows and controls the stack
arthware-dev Sep 25, 2026
d356b40
fix(doctor): check stacklets that run a single container
arthware-dev Sep 25, 2026
0eb8635
feat(doctor): report findings as JSON with --json
arthware-dev Sep 25, 2026
dae74d0
feat(cli): add stack errors for recent error lines across stacklets
arthware-dev Sep 25, 2026
4e4810c
feat(cli): add stack host for memory, disk and per-stacklet usage
arthware-dev Sep 25, 2026
dbc7d76
feat: show problems, recent errors and memory in the menu bar app
arthware-dev Sep 25, 2026
a560e78
fix(cli): print the instance's stack.toml when STACK_DIR is set
arthware-dev Sep 25, 2026
b8021e2
feat(cli): print the configuration as JSON with credentials hidden
arthware-dev Sep 25, 2026
5dbaf81
feat: show the stack's setup in the menu bar app
arthware-dev Sep 25, 2026
b627b2e
feat: split the menu bar panel into overview and setup tabs
arthware-dev Sep 25, 2026
504e761
feat: use the famstack mark as the menu bar and app icon
arthware-dev Sep 25, 2026
4bbfc93
feat(cli): include each stacklet's address in list and status output
arthware-dev Sep 25, 2026
42b2de7
feat: connect the menu bar app to a stack on another Mac over SSH
arthware-dev Sep 25, 2026
6a4bc2b
feat: say what failed and stop hung commands in the menu bar app
arthware-dev Sep 26, 2026
60bd2d8
fix(doctor): point an exited container's fix at its stacklet's logs
arthware-dev Oct 7, 2026
ab059d4
fix: give the menu bar app's uptime in hours on the first day
arthware-dev Oct 7, 2026
5709b14
build: version the menu bar app with the stack it ships with
arthware-dev Oct 7, 2026
47c1c4e
docs: describe how to install and connect the menu bar app
arthware-dev Oct 7, 2026
eacb7b0
feat(cli): install the menu bar app with stack app install
arthware-dev Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/macos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.build/
build/
10 changes: 10 additions & 0 deletions apps/macos/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
)
74 changes: 74 additions & 0 deletions apps/macos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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
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`,
`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 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`
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

Needs macOS 14 and the Command Line Tools; no Xcode.

```bash
./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`, 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 (`<prefix>-overview.png`, `<prefix>-setup.png`):

```bash
apps/macos/build/famstack.app/Contents/MacOS/StackMenu --snapshot /tmp/panel
```

## Which machine

**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 <host> 'zsh -lc "cd <checkout> && ./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
```
111 changes: 111 additions & 0 deletions apps/macos/Sources/StackMenu/App.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import AppKit
import SwiftUI

@main
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(isInserted: .constant(!Self.isTool)) {
MenuPanel(store: store)
} label: {
Image(nsImage: Logo.menuBarImage(store.summary.logo))
}
.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)
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, 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)
.background(Color(nsColor: .windowBackgroundColor))
.environment(\.colorScheme, .dark)
let url = URL(fileURLWithPath: "\(prefix)-\(tab.rawValue.lowercased()).png")
render(view, to: url)
}
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)
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)
}
}
129 changes: 129 additions & 0 deletions apps/macos/Sources/StackMenu/Logo.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
61 changes: 61 additions & 0 deletions apps/macos/Sources/StackMenu/LogoOutline.swift
Original file line number Diff line number Diff line change
@@ -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
}()
}
Loading
Loading