Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
/.github/ @M4n0x @Seb-GRAF
/.agents/ @M4n0x @Seb-GRAF
/.claude/ @M4n0x @Seb-GRAF
/scenes/ @M4n0x @Seb-GRAF
/scene.schema.json @M4n0x @Seb-GRAF
/.releaserc.json @M4n0x @Seb-GRAF
/commitlint.config.mjs @M4n0x @Seb-GRAF
/package.json @M4n0x @Seb-GRAF
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The developer's own StableBar is usually running while you work.
- Run the tests for what you changed with `swift test --filter <name>`. CI runs the whole suite on macOS for every pull request.
- `node --test .github/scripts/*.test.mjs` tests the pull request checks in `.github/scripts`.
- `StableBar --validate PATH` checks a configuration without starting its scripts.
- Visible changes (the bar, popups, setup, Settings) need before/after screenshots in the pull request. Ask before launching the app or taking screenshots: a second bar appears on the developer's screen. `StableBar --render-docs-images DIR` draws the bar, popups and Settings in offscreen windows instead, so nothing appears on screen.
- Visible changes (the bar, popups, setup, Settings) need before/after screenshots in the pull request. Ask before launching the app or taking screenshots: a second bar appears on the developer's screen. `StableBar --render-docs-images DIR` draws the bar, popups and Settings in offscreen windows instead, so nothing appears on screen. For a before/after of your change, add or extend a scene in `scenes/` and draw it with `StableBar --render-scenes DIR scenes`, from a build of `main` and of your branch: it runs nothing and draws offscreen (see [Scenes](CONTRIBUTING.md#scenes)).

## Hit every surface

Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ Release jobs set `STABLEBAR_RELEASE_BUILD=1` when packaging to preserve their st

`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data, a fixed date (Thursday 22 October 2026, 09:41 UTC) and a local, empty Store index, and writes them as WebP. Two runs of one commit on one Mac draw the same pixels. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main` and on pull requests that change the app, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift).

## Scenes

A scene file in [scenes](scenes) describes one screen to draw: a configuration, inline or as a path such as `../Resources/config.json`, the sample outputs, graph history and AeroSpace workspaces its widgets show, and what to draw: the `bar`, the bar in edit mode (`editBar`), a widget's `popup`, or a `settings` page. [scene.schema.json](scene.schema.json) lists every key. Draw them with:

```sh
StableBar --render-scenes /tmp/scenes scenes
```

It writes `<name>-dark.png` and `<name>-light.png` for each scene, and `scenes.json` with each scene's images or the error that stopped it. It runs nothing (no command, provider, window manager or network request), and every clock shows Thursday 22 October 2026, 09:41 UTC, so two runs of one commit on one Mac draw the same pixels. Text about the Mac itself, such as a display's name or the window managers installed, still comes from the Mac that draws.

A change you can see extends the scene that shows its area, or adds one when none does. Scenes can be edited, merged or deleted. Keep the format additive: a build reports a scene it can't read as an error and draws the others.

## Agent skills

Skills for coding agents live in [.agents/skills](.agents/skills), one folder per skill with a `SKILL.md`. Codex reads that directory, and `.claude/skills` links to it for Claude Code. Invoke a skill by name (`/triage` in Claude Code, `$triage` in Codex) or describe the task and let the agent choose it.
Expand Down
22 changes: 21 additions & 1 deletion Sources/StableBar/BarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -624,14 +624,34 @@ final class BarStore: NSObject {
return (next, loader)
}

private func display(_ next: Configuration, loader: ConfigurationLoader) {
/// Takes what `loader` read besides the configuration itself: profiles, packages and contributions.
private func adopt(_ loader: ConfigurationLoader) {
profiles = loader.profiles
pinnedProfile = loader.pinnedProfile
widgetPackageNames = loader.widgetPackageNames
widgetPackageIDs = loader.widgetPackageIDs
builtInWidgetIDs = loader.builtInWidgetIDs
contributions = loader.contributions
configurationWarning = loader.newerVersionWarning
}

/// Shows `next` as `loader` read it for `context`, in `setup`, without starting its scripts,
/// providers, window managers or app reading: what a render draws. Its scripts are the caller's.
func showWithoutRunning(_ next: Configuration, loader: ConfigurationLoader, context: ConfigurationContext, setup: ScreenSetup) {
configurationLoader = loader
self.context = context
screenSetup = setup
adopt(loader)
configuration = next
effectiveDocument = loader.document
origins = loader.origins
activeProfiles = loader.activeProfiles
screenDocuments = loader.screenDocuments
preparePresentations()
}

private func display(_ next: Configuration, loader: ConfigurationLoader) {
adopt(loader)
if effectiveDocument == loader.document {
origins = loader.origins
activeProfiles = loader.activeProfiles
Expand Down
44 changes: 12 additions & 32 deletions Sources/StableBar/DocsImages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,9 @@ enum DocsImages {
}

private static let display = BarDisplay(id: 1, isBuiltIn: false, isMain: true, notch: nil)
private static let darkBackdrop = NSImage(contentsOf: Bundle.main.url(forResource: "SetupAmberDark", withExtension: "heic")!)!

private static func backdrop(dark: Bool) -> some View {
Image(nsImage: dark ? darkBackdrop : Wallpaper.image!).resizable().scaledToFill()
OffscreenSnapshot.backdrop(dark: dark)
}

private static func bar(_ store: BarStore) -> some View {
Expand Down Expand Up @@ -224,36 +223,17 @@ enum DocsImages {
/// in an offscreen window below the desktop icons.
private static func snapshot(_ content: some View, size: NSSize, dark: Bool, stores: [BarStore], ready: () -> Bool,
to url: URL) async throws {
let view = NSHostingView(rootView: content
.environment(\.fixedDate, date)
.environment(\.offline, true)
.frame(width: size.width, height: size.height)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.14))))
view.frame = NSRect(origin: .zero, size: size)
let window = NSWindow(contentRect: NSRect(x: -9000, y: -6000, width: size.width, height: size.height),
styleMask: [.borderless], backing: .buffered, defer: false)
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopIconWindow)) + 1)
window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
window.isOpaque = false
window.backgroundColor = .clear
window.contentView = view
window.orderFrontRegardless()
while !ready() || stores.contains(where: { ($0.configuration!.widgets.contains { $0.content == .workspaces } && $0.workspaceStore.workspaces.isEmpty) || $0.scripts.values.contains { $0.outputNumber == 0 } }) {
try await Task.sleep(for: .milliseconds(100))
}
for store in stores {
store.scripts["system-monitor"]?.samples = cpuHistory
store.scripts["system"]?.samples = cpuHistory
}
try await Task.sleep(for: .seconds(3))
let image = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2), pixelsHigh: Int(size.height * 2),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
image.size = size
view.cacheDisplay(in: view.bounds, to: image)
try image.representation(using: .png, properties: [:])!.write(to: url)
window.orderOut(nil)
try await OffscreenSnapshot.write(content, size: size, dark: dark, settle: .seconds(3), ready: {
ready() && !stores.contains { store in
(store.configuration!.widgets.contains { $0.content == .workspaces } && store.workspaceStore.workspaces.isEmpty)
|| store.scripts.values.contains { $0.outputNumber == 0 }
}
}, prepare: {
for store in stores {
store.scripts["system-monitor"]?.samples = cpuHistory
store.scripts["system"]?.samples = cpuHistory
}
}, to: url)
}

private static let cpuHistory: [Double] = (0..<60).map { index in
Expand Down
4 changes: 4 additions & 0 deletions Sources/StableBar/LaunchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum LaunchCommand {
case installFromStore(URL, String, String?)
case buildSource(packages: URL, output: URL, previous: URL?, url: URL?, name: String?)
case renderDocsImages(URL)
case renderScenes(output: URL, scenes: [URL])
case triage(URL, agent: TriageAgent?, model: String?)
case triageContext(URL)
case report(URL)
Expand Down Expand Up @@ -117,6 +118,9 @@ enum LaunchCommand {
self = targeted ? .control(url, RuntimeRequest(action: .reload)) : .reload
case "--render-docs-images" where arguments.count == 2:
self = .renderDocsImages(URL(filePath: (arguments[1] as NSString).expandingTildeInPath))
case "--render-scenes" where arguments.count >= 3:
let paths = arguments.dropFirst().map { URL(filePath: ($0 as NSString).expandingTildeInPath) }
self = .renderScenes(output: paths[0], scenes: Array(paths.dropFirst()))
case "triage":
self = try Self.triage(url, Array(arguments.dropFirst()))
case "--triage-context" where arguments.count == 1:
Expand Down
62 changes: 62 additions & 0 deletions Sources/StableBar/OffscreenSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import AppKit
import SwiftUI

/// Draws views to PNG files in offscreen windows, for the docs images and scene files. Nothing appears
/// on screen, and every clock in the drawing shows `DocsImages.date`.
@MainActor
enum OffscreenSnapshot {
private static let darkBackdrop = Bundle.main.url(forResource: "SetupAmberDark", withExtension: "heic").flatMap(NSImage.init(contentsOf:))

/// The setup tour's amber wallpaper, or a flat color where the app's resources aren't bundled, as in tests.
@ViewBuilder static func backdrop(dark: Bool) -> some View {
if let image = dark ? darkBackdrop : Wallpaper.image {
Image(nsImage: image).resizable().scaledToFill()
} else {
Color(white: dark ? 0.16 : 0.9)
}
}

/// Draws `content` at twice `size` in an offscreen window below the desktop icons and writes it to
/// `url` as PNG. It waits for `ready`, at most a minute, then runs `prepare` and lets the views
/// settle for `settle` before drawing.
static func write(_ content: some View, size: NSSize, dark: Bool, settle: Duration, ready: () -> Bool = { true },
prepare: () -> Void = {}, to url: URL) async throws {
let view = NSHostingView(rootView: content
.environment(\.fixedDate, DocsImages.date)
.environment(\.offline, true)
.frame(width: size.width, height: size.height)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.14))))
view.frame = NSRect(origin: .zero, size: size)
let window = NSWindow(contentRect: NSRect(x: -9000, y: -6000, width: size.width, height: size.height),
styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopIconWindow)) + 1)
window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
window.isOpaque = false
window.backgroundColor = .clear
window.contentView = view
window.orderFrontRegardless()
defer { window.orderOut(nil) }
let deadline = ContinuousClock.now + .seconds(60)
while !ready() {
guard ContinuousClock.now < deadline else {
throw CommandFailure(message: "\(url.deletingPathExtension().lastPathComponent): the views weren't ready after a minute.")
}
try await Task.sleep(for: .milliseconds(100))
}
prepare()
try await Task.sleep(for: settle)
guard let image = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2), pixelsHigh: Int(size.height * 2),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else {
throw CommandFailure(message: "\(url.deletingPathExtension().lastPathComponent): no image of \(Int(size.width)) × \(Int(size.height)) points.")
}
image.size = size
view.cacheDisplay(in: view.bounds, to: image)
guard let png = image.representation(using: .png, properties: [:]) else {
throw CommandFailure(message: "\(url.deletingPathExtension().lastPathComponent): the image couldn't be encoded as PNG.")
}
try png.write(to: url)
}
}
86 changes: 86 additions & 0 deletions Sources/StableBar/SceneFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

/// One screen to draw offscreen: a configuration, the sample data its widgets show, and what to draw.
/// `StableBar --render-scenes` reads these from `scenes/`, and scene.schema.json describes them. A scene
/// runs nothing: commands, providers and window managers are replaced by the samples it gives.
struct SceneFile: Decodable {
enum Draw: String, Decodable, CaseIterable { case bar, editBar, popup, settings }
enum Appearance: String, Decodable, CaseIterable { case dark, light }
/// A Settings section, named as the toolbar names it.
enum Section: String, Decodable, CaseIterable { case general, bar, theme, profiles, packages }
enum Tab: String, Decodable, CaseIterable { case content, style, behavior }
/// The configuration inline, or the path of a configuration file relative to the scene file.
enum Configuration: Equatable { case inline([String: ConfigurationValue]), file(String) }

var configuration: Configuration
/// Script outputs keyed by widget id or provider name: an object as a script prints it, or plain text.
var outputs: [String: ConfigurationValue] = [:]
/// Graph samples keyed by widget id or provider name.
var history: [String: [Double]] = [:]
/// AeroSpace's `list-workspaces --json` objects.
var workspaces: [ConfigurationValue]?
/// AeroSpace's `list-windows --json` objects.
var windows: [ConfigurationValue]?
/// The bundle identifier of the active app, Finder by default.
var activeApp: String?
var draw: Draw
/// The widget whose popup or Settings page to draw.
var widget: String?
/// The Settings section to draw when no widget is named.
var section: Section?
/// The tab of the widget's Settings page.
var tab: Tab?
var appearances: [Appearance] = Appearance.allCases
var width: Double?
var height: Double?

enum CodingKeys: String, CodingKey, CaseIterable {
case configuration, outputs, history, workspaces, windows, activeApp, draw, widget, section, tab, appearances, width, height
}

init(from decoder: Decoder) throws {
try decoder.rejectUnknownKeys(CodingKeys.self, allowing: ["$schema"])
let values = try decoder.container(keyedBy: CodingKeys.self)
if let path = try? values.decode(String.self, forKey: .configuration) {
configuration = .file(path)
} else {
configuration = .inline(try values.decode([String: ConfigurationValue].self, forKey: .configuration))
}
outputs = try values.decodeIfPresent([String: ConfigurationValue].self, forKey: .outputs) ?? [:]
history = try values.decodeIfPresent([String: [Double]].self, forKey: .history) ?? [:]
workspaces = try values.decodeIfPresent([ConfigurationValue].self, forKey: .workspaces)
windows = try values.decodeIfPresent([ConfigurationValue].self, forKey: .windows)
activeApp = try values.decodeIfPresent(String.self, forKey: .activeApp)
draw = try values.decode(Draw.self, forKey: .draw)
widget = try values.decodeIfPresent(String.self, forKey: .widget)
section = try values.decodeIfPresent(Section.self, forKey: .section)
tab = try values.decodeIfPresent(Tab.self, forKey: .tab)
appearances = try values.decodeIfPresent([Appearance].self, forKey: .appearances) ?? Appearance.allCases
if appearances.isEmpty || Set(appearances).count != appearances.count {
throw DecodingError.dataCorruptedError(forKey: .appearances, in: values,
debugDescription: "Appearances name dark, light or both, each once.")
}
width = try values.decodeIfPresent(Double.self, forKey: .width)
height = try values.decodeIfPresent(Double.self, forKey: .height)
if let width, !(100...4000).contains(width) {
throw DecodingError.dataCorruptedError(forKey: .width, in: values, debugDescription: "A width is 100 to 4000 points.")
}
if let height, !(20...4000).contains(height) {
throw DecodingError.dataCorruptedError(forKey: .height, in: values, debugDescription: "A height is 20 to 4000 points.")
}
if draw == .popup, widget == nil {
throw DecodingError.dataCorruptedError(forKey: .widget, in: values, debugDescription: "A popup scene names its widget.")
}
if draw == .settings, widget == nil, section == nil {
throw DecodingError.dataCorruptedError(forKey: .section, in: values,
debugDescription: "A settings scene names a widget or a section.")
}
if tab != nil, widget == nil {
throw DecodingError.dataCorruptedError(forKey: .tab, in: values, debugDescription: "A tab belongs to a widget's Settings page.")
}
}

static func read(_ url: URL) throws -> SceneFile {
try JSONDecoder().decode(SceneFile.self, from: Data(contentsOf: url))
}
}
Loading
Loading