From 49c8454c6f63446a57bd108aa7a7747e47fa5a2a Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:10:11 +0100 Subject: [PATCH 1/8] build: read scene files A scene file holds a configuration, inline or as a path, the sample outputs, graph history, AeroSpace workspaces and active app its widgets show, and what to draw: the bar, edit mode, a widget's popup or a Settings page, in one or both appearances. Unknown properties are errors, as in configurations, and scene.schema.json describes the format. --- Sources/StableBar/SceneFile.swift | 76 +++++++++++++++++++ Tests/StableBarTests/SceneFileTests.swift | 92 +++++++++++++++++++++++ scene.schema.json | 52 +++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 Sources/StableBar/SceneFile.swift create mode 100644 Tests/StableBarTests/SceneFileTests.swift create mode 100644 scene.schema.json diff --git a/Sources/StableBar/SceneFile.swift b/Sources/StableBar/SceneFile.swift new file mode 100644 index 0000000..9c4a6bf --- /dev/null +++ b/Sources/StableBar/SceneFile.swift @@ -0,0 +1,76 @@ +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 + width = try values.decodeIfPresent(Double.self, forKey: .width) + height = try values.decodeIfPresent(Double.self, forKey: .height) + 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)) + } +} diff --git a/Tests/StableBarTests/SceneFileTests.swift b/Tests/StableBarTests/SceneFileTests.swift new file mode 100644 index 0000000..01a6b15 --- /dev/null +++ b/Tests/StableBarTests/SceneFileTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import StableBar + +private let repository = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + +private func scene(_ json: String) throws -> SceneFile { + try JSONDecoder().decode(SceneFile.self, from: Data(json.utf8)) +} + +@Test func sceneDecodesEveryProperty() throws { + let decoded = try scene(#""" + { + "$schema": "../scene.schema.json", + "configuration": { "widgets": [] }, + "outputs": { "shell": { "text": "Sample" }, "plain": "Text" }, + "history": { "system": [0.1, 0.2] }, + "workspaces": [{ "workspace": "1" }], + "windows": [], + "activeApp": "com.apple.finder", + "draw": "settings", + "widget": "clock", + "tab": "style", + "appearances": ["light"], + "width": 900, + "height": 500 + } + """#) + #expect(decoded.configuration == .inline(["widgets": .array([])])) + #expect(decoded.outputs["shell"] == .object(["text": .string("Sample")])) + #expect(decoded.outputs["plain"] == .string("Text")) + #expect(decoded.history["system"] == [0.1, 0.2]) + #expect(decoded.workspaces?.count == 1) + #expect(decoded.windows?.isEmpty == true) + #expect(decoded.activeApp == "com.apple.finder") + #expect(decoded.draw == .settings) + #expect(decoded.widget == "clock") + #expect(decoded.tab == .style) + #expect(decoded.appearances == [.light]) + #expect(decoded.width == 900) + #expect(decoded.height == 500) + #expect(try scene(#"{"configuration": "config.json", "draw": "bar"}"#).configuration == .file("config.json")) +} + +@Test func sceneDefaultsToBothAppearances() throws { + let decoded = try scene(#"{"configuration": {"widgets": []}, "draw": "bar"}"#) + #expect(decoded.appearances == [.dark, .light]) + #expect(decoded.outputs.isEmpty) + #expect(decoded.history.isEmpty) + #expect(decoded.workspaces == nil) + #expect(decoded.widget == nil) +} + +@Test func sceneRejectsUnknownProperties() throws { + let error = #expect(throws: (any Error).self) { try scene(#"{"configuration": {}, "draw": "bar", "colour": 1}"#) } + let message = errorMessage(try #require(error)) + #expect(message.contains("colour")) + #expect(message.contains("Unknown property.")) +} + +@Test(arguments: [ + (#"{"configuration": {}, "draw": "popup"}"#, "widget"), + (#"{"configuration": {}, "draw": "settings"}"#, "section"), + (#"{"configuration": {}, "draw": "settings", "section": "theme", "tab": "style"}"#, "tab") +]) +func sceneNamesWhatItDraws(json: String, key: String) throws { + let error = #expect(throws: (any Error).self) { try scene(json) } + #expect(errorMessage(try #require(error)).contains(key)) +} + +@Test func sceneRejectsAnUnknownDraw() { + #expect(throws: (any Error).self) { try scene(#"{"configuration": {}, "draw": "window"}"#) } +} + +@Test func sceneSchemaListsTheSamePropertiesAsTheDecoder() throws { + let schema = try JSONDecoder().decode([String: ConfigurationValue].self, + from: Data(contentsOf: repository.appending(path: "scene.schema.json"))) + guard case let .object(properties)? = schema["properties"] else { Issue.record("scene.schema.json has no properties"); return } + #expect(Set(properties.keys) == Set(SceneFile.CodingKeys.allCases.map(\.stringValue) + ["$schema"])) + /// The strings of the `enum` in `value`, or in its `items` for an array. + func choices(_ value: ConfigurationValue?) -> Set { + guard case let .object(object)? = value else { return [] } + if case let .array(values)? = object["enum"] { + return Set(values.compactMap { if case let .string(text) = $0 { text } else { nil } }) + } + return choices(object["items"]) + } + #expect(choices(properties["draw"]) == Set(SceneFile.Draw.allCases.map(\.rawValue))) + #expect(choices(properties["section"]) == Set(SceneFile.Section.allCases.map(\.rawValue))) + #expect(choices(properties["tab"]) == Set(SceneFile.Tab.allCases.map(\.rawValue))) + #expect(choices(properties["appearances"]) == Set(SceneFile.Appearance.allCases.map(\.rawValue))) +} diff --git a/scene.schema.json b/scene.schema.json new file mode 100644 index 0000000..c8e597f --- /dev/null +++ b/scene.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StableBar scene", + "description": "One screen that StableBar --render-scenes draws offscreen. A scene runs nothing: its outputs, history and workspaces replace every command, provider and window manager.", + "type": "object", + "additionalProperties": false, + "required": ["configuration", "draw"], + "properties": { + "$schema": { "type": "string" }, + "configuration": { + "description": "A StableBar configuration, or the path of one relative to this file.", + "oneOf": [{ "type": "object" }, { "type": "string" }] + }, + "outputs": { + "description": "Script outputs by widget id or provider name: an object as a script prints it, or plain text.", + "type": "object", + "additionalProperties": { "type": ["object", "string"] } + }, + "history": { + "description": "Graph samples by widget id or provider name.", + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "number" } } + }, + "workspaces": { + "description": "AeroSpace's list-workspaces --json objects.", + "type": "array", + "items": { "type": "object" } + }, + "windows": { + "description": "AeroSpace's list-windows --json objects.", + "type": "array", + "items": { "type": "object" } + }, + "activeApp": { "description": "The active app's bundle identifier. Finder by default.", "type": "string" }, + "draw": { "description": "What to draw.", "enum": ["bar", "editBar", "popup", "settings"] }, + "widget": { "description": "The widget whose popup or Settings page to draw.", "type": "string" }, + "section": { + "description": "The Settings section to draw when no widget is named.", + "enum": ["general", "bar", "theme", "profiles", "packages"] + }, + "tab": { "description": "The tab of the widget's Settings page.", "enum": ["content", "style", "behavior"] }, + "appearances": { + "description": "The appearances to draw, each to its own image. Both by default.", + "type": "array", + "items": { "enum": ["dark", "light"] }, + "minItems": 1, + "uniqueItems": true + }, + "width": { "description": "The image's width in points.", "type": "number", "minimum": 100 }, + "height": { "description": "The image's height in points.", "type": "number", "minimum": 20 } + } +} From 54dd552198c2bcee36f24896e0b303fa02e2f24e Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:14:57 +0100 Subject: [PATCH 2/8] build: build a scene's bar from its samples without running anything SceneRenderer reads the scene's configuration as the test fixtures do and gives each script widget and provider the scene's sample output and history, or an empty output. Its workspaces come from AeroSpace's JSON, its Store index is a local, empty file, and it never starts the store, so no command, provider, window manager or network request runs. --- Sources/StableBar/SceneRenderer.swift | 68 +++++++++++ Tests/StableBarTests/SceneRendererTests.swift | 115 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 Sources/StableBar/SceneRenderer.swift create mode 100644 Tests/StableBarTests/SceneRendererTests.swift diff --git a/Sources/StableBar/SceneRenderer.swift b/Sources/StableBar/SceneRenderer.swift new file mode 100644 index 0000000..ec2e21c --- /dev/null +++ b/Sources/StableBar/SceneRenderer.swift @@ -0,0 +1,68 @@ +import AppKit +import Foundation + +/// Draws scene files offscreen. A scene's store is built the way the tests build theirs: its +/// configuration is read and its widgets get the scene's sample outputs, so no command, provider, +/// window manager or network request runs, and nothing asks for a permission. +@MainActor +enum SceneRenderer { + /// A store showing `scene`, which runs nothing. `directory` holds the files the store needs: an + /// inline configuration and the local Store index. A configuration path is relative to `sceneURL`. + static func store(for scene: SceneFile, at sceneURL: URL, in directory: URL, themes: URL?) throws -> BarStore { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let configurationURL: URL + switch scene.configuration { + case let .inline(document): + configurationURL = directory.appending(path: "config.json") + try JSONEncoder().encode(document).write(to: configurationURL) + case let .file(path): + configurationURL = URL(filePath: path, relativeTo: sceneURL.deletingLastPathComponent()).standardizedFileURL + } + let store = BarStore(configurationURL: configurationURL, themesDirectory: themes) + var loader = ConfigurationLoader() + loader.themesDirectory = themes + let configuration = try loader.load(configurationURL) + store.configuration = configuration + store.effectiveDocument = loader.document + store.origins = loader.origins + store.widgetPackageNames = loader.widgetPackageNames + store.activeProfiles = loader.activeProfiles + + var providers: [String: ScriptWidget] = [:] + for widget in configuration.widgets where widget.enabled { + switch widget.content { + case let .provider(name): + let script = try providers[name] ?? sample(scene, keys: [widget.id, name]) + providers[name] = script + store.scripts[widget.id] = script + case .script: + store.scripts[widget.id] = try sample(scene, keys: [widget.id]) + default: + break + } + } + if let workspaces = scene.workspaces { + store.workspaceStore.workspaces = try workspaceSnapshot(workspaces: JSONEncoder().encode(workspaces), + windows: JSONEncoder().encode(scene.windows ?? [])) + } + store.activeApp = NSRunningApplication.runningApplications(withBundleIdentifier: scene.activeApp ?? "com.apple.finder").first + try DocsImages.offline(store, in: directory) + store.preparePresentations() + return store + } + + /// A script that never runs, showing the scene's output and history for the first of `keys` that + /// has them, or an empty output. + private static func sample(_ scene: SceneFile, keys: [String]) throws -> ScriptWidget { + let script = ScriptWidget() + let output: ScriptOutput + switch keys.lazy.compactMap({ scene.outputs[$0] }).first { + case let .string(text)?: output = try ScriptOutput.read(Data(text.utf8)) + case let value?: output = try ScriptOutput.read(JSONEncoder().encode(value)) + case nil: output = ScriptOutput(text: "") + } + try script.accept(output) + if let samples = keys.lazy.compactMap({ scene.history[$0] }).first { script.samples = samples } + return script + } +} diff --git a/Tests/StableBarTests/SceneRendererTests.swift b/Tests/StableBarTests/SceneRendererTests.swift new file mode 100644 index 0000000..5d7889f --- /dev/null +++ b/Tests/StableBarTests/SceneRendererTests.swift @@ -0,0 +1,115 @@ +import AppKit +import Foundation +import Testing +@testable import StableBar + +private let repository = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() +private let themes = repository.appending(path: "Resources/themes") + +/// A new folder for one test. The caller removes it. +private func scratch() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appending(path: "scene-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + +private func scene(_ json: String) throws -> SceneFile { + try JSONDecoder().decode(SceneFile.self, from: Data(json.utf8)) +} + +@MainActor private func store(_ json: String, in directory: URL) throws -> BarStore { + try SceneRenderer.store(for: scene(json), at: directory.appending(path: "scene.json"), in: directory.appending(path: "work"), + themes: themes) +} + +@Test @MainActor func sceneStoreShowsSampleOutputs() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try store(#""" + { + "configuration": { "widgets": [ + { "id": "shell", "type": "script", "position": "left", "command": "echo live" }, + { "id": "cpu", "type": "system", "position": "right" }, + { "id": "silent", "type": "script", "position": "right", "command": "echo live" } + ] }, + "outputs": { "shell": { "text": "Sample" }, "system": { "text": "18%" } }, + "history": { "cpu": [0.1, 0.2] }, + "draw": "bar" + } + """#, in: directory) + defer { store.stop() } + #expect(store.scripts["shell"]?.output.text == "Sample") + #expect(store.scripts["cpu"]?.output.text == "18%") + #expect(store.scripts["cpu"]?.samples == [0.1, 0.2]) + #expect(store.scripts["silent"]?.output.text == "") +} + +@Test @MainActor func sceneStoreRunsNoCommand() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let widget = directory.appending(path: "widget-ran"), provider = directory.appending(path: "provider-ran") + let store = try store(#""" + { + "configuration": { + "providers": { "probe": { "type": "command", "command": "touch '\#(provider.path)'", "interval": 1 } }, + "widgets": [ + { "id": "shell", "type": "script", "position": "left", "command": "touch '\#(widget.path)'", "interval": 1 }, + { "id": "probe", "type": "provider", "provider": "probe", "position": "right" } + ] + }, + "draw": "bar" + } + """#, in: directory) + defer { store.stop() } + try await Task.sleep(for: .seconds(1.5)) + // A started script records its start before its command launches, which can wait seconds for the login shell. + #expect(store.scripts.values.allSatisfy { $0.diagnostics.lastStarted == nil }) + #expect(!FileManager.default.fileExists(atPath: widget.path)) + #expect(!FileManager.default.fileExists(atPath: provider.path)) +} + +@Test @MainActor func sceneStoreReadsAeroSpaceWorkspaces() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try store(#""" + { + "configuration": { "widgets": [{ "id": "spaces", "type": "workspaces", "position": "left" }] }, + "workspaces": [ + { "workspace": "1", "workspace-is-focused": false, "workspace-is-visible": false, "monitor-appkit-nsscreen-screens-id": 1 }, + { "workspace": "2", "workspace-is-focused": true, "workspace-is-visible": true, "monitor-appkit-nsscreen-screens-id": 1 }, + { "workspace": "3", "workspace-is-focused": false, "workspace-is-visible": false, "monitor-appkit-nsscreen-screens-id": 1 }, + { "workspace": "4", "workspace-is-focused": false, "workspace-is-visible": false, "monitor-appkit-nsscreen-screens-id": 1 } + ], + "windows": [ + { "workspace": "2", "app-name": "Xcode", "app-bundle-id": "com.apple.dt.Xcode", "window-id": 2, "window-title": "StableBar" }, + { "workspace": "2", "app-name": "Terminal", "app-bundle-id": "com.apple.Terminal", "window-id": 3, "window-title": "zsh" } + ], + "draw": "bar" + } + """#, in: directory) + defer { store.stop() } + let workspaces = store.workspaceStore.workspaces + #expect(workspaces.count == 4) + #expect(workspaces.first(where: \.isFocused)?.name == "2") + #expect(workspaces.first { $0.name == "2" }?.apps.count == 2) +} + +@Test @MainActor func sceneStoreMakesNoNetworkRequest() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try store(#"{"configuration": {"widgets": []}, "draw": "settings", "section": "theme"}"#, in: directory) + defer { store.stop() } + let index = await store.packageStoreClient.store.url + #expect(index.isFileURL) + #expect(index.path.hasPrefix(directory.path)) +} + +@Test @MainActor func sceneStoreLoadsAConfigurationFile() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + try Data(#"{"widgets": [{"id": "hello", "type": "text", "position": "left", "text": "Hello"}]}"#.utf8) + .write(to: directory.appending(path: "config.json")) + let store = try store(#"{"configuration": "config.json", "draw": "bar"}"#, in: directory) + defer { store.stop() } + #expect(store.configuration?.widgets.map(\.id) == ["hello"]) +} From bb6a2e062e4bb91da441df53141048363bc0d9c0 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:21:24 +0100 Subject: [PATCH 3/8] build: draw a scene's bar, edit mode, popup or Settings page offscreen SceneRenderer draws what a scene asks for over the setup tour's backdrop, at twice its size, with the fixed date: the bar, the bar in edit mode once its trays are measured, a widget's popup, or a widget's page or a section in Settings. The offscreen snapshot moves out of DocsImages unchanged: a docs render after the move matches the one before it in every pixel of all 14 images. Waiting for the views now gives up after a minute instead of hanging. --- Sources/StableBar/DocsImages.swift | 44 ++----- Sources/StableBar/OffscreenSnapshot.swift | 57 +++++++++ Sources/StableBar/SceneRenderer.swift | 115 ++++++++++++++++++ Tests/StableBarTests/SceneRendererTests.swift | 88 ++++++++++++++ 4 files changed, 272 insertions(+), 32 deletions(-) create mode 100644 Sources/StableBar/OffscreenSnapshot.swift diff --git a/Sources/StableBar/DocsImages.swift b/Sources/StableBar/DocsImages.swift index ffe4acd..7a293dd 100644 --- a/Sources/StableBar/DocsImages.swift +++ b/Sources/StableBar/DocsImages.swift @@ -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 { @@ -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 diff --git a/Sources/StableBar/OffscreenSnapshot.swift b/Sources/StableBar/OffscreenSnapshot.swift new file mode 100644 index 0000000..b3bfcfd --- /dev/null +++ b/Sources/StableBar/OffscreenSnapshot.swift @@ -0,0 +1,57 @@ +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) + 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) + } +} diff --git a/Sources/StableBar/SceneRenderer.swift b/Sources/StableBar/SceneRenderer.swift index ec2e21c..ffe3b9e 100644 --- a/Sources/StableBar/SceneRenderer.swift +++ b/Sources/StableBar/SceneRenderer.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import SwiftUI /// Draws scene files offscreen. A scene's store is built the way the tests build theirs: its /// configuration is read and its widgets get the scene's sample outputs, so no command, provider, @@ -51,6 +52,120 @@ enum SceneRenderer { return store } + /// How long a scene's views settle before they're drawn. + static var settle: Duration = .seconds(2) + + private static let display = BarDisplay(id: 1, isBuiltIn: false, isMain: true, notch: nil) + + /// Draws what `scene` asks for, from `store`, in one appearance, to the PNG file `url`. + static func draw(_ scene: SceneFile, store: BarStore, appearance: SceneFile.Appearance, to url: URL, + settle: Duration = settle) async throws { + guard let configuration = store.configuration else { throw CommandFailure(message: "The scene has no configuration.") } + let dark = appearance == .dark + let width = CGFloat(scene.width ?? 1100) + switch scene.draw { + case .bar: + let height = CGFloat(scene.height ?? configuration.barGeometry(for: display).panelHeight) + try await OffscreenSnapshot.write(bar(store).background { OffscreenSnapshot.backdrop(dark: dark) }, + size: NSSize(width: width, height: height), dark: dark, settle: settle, to: url) + case .editBar: + store.barEditing.begin() + var height = CGFloat(scene.height ?? 0) + if scene.height == nil { height = try await editBarHeight(store, width: width) } + try await OffscreenSnapshot.write(bar(store).background { OffscreenSnapshot.backdrop(dark: dark) }, + size: NSSize(width: width, height: height), dark: dark, settle: settle, to: url) + case .popup: + let look = configuration.look(on: .monitor) + let id = scene.widget ?? "" + guard let widget = look.widgets.first(where: { $0.id == id }) else { + throw CommandFailure(message: "widget: The configuration has no widget \(id).") + } + guard let popup = widgetPopup(widget, store: store) else { throw CommandFailure(message: "widget: \(id) has no popup.") } + let popupWidth = CGFloat(popup.style.width ?? 360) + let content = PopupView(popup: popup, style: popup.baseStyle(widget: widget, defaults: look.popupDefaults.inheriting(look.defaults)), + store: store, widget: widget) + .environment(\.screenKind, .monitor) + .frame(width: popupWidth) + .fixedSize(horizontal: false, vertical: true) + .shadow(color: .black.opacity(0.28), radius: 18, y: 8) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background { OffscreenSnapshot.backdrop(dark: dark) } + try await OffscreenSnapshot.write(content, size: NSSize(width: popupWidth + 48, height: CGFloat(scene.height ?? 600)), + dark: dark, settle: settle, to: url) + case .settings: + store.showScreenSetup(ScreenSetup([display])) + let editor = ConfigurationEditor(store: store) + if let id = scene.widget { + guard configuration.widgets.contains(where: { $0.id == id }) else { + throw CommandFailure(message: "widget: The configuration has no widget \(id).") + } + editor.showWidget(id) + switch scene.tab ?? .content { + case .content: editor.widgetTab = .content + case .style: editor.widgetTab = .style + case .behavior: editor.widgetTab = .behavior + } + } else { + switch scene.section ?? .general { + case .general: editor.section = .general + case .bar: editor.section = .widgets + case .theme: editor.section = .appearance + case .profiles: editor.section = .profiles + case .packages: editor.section = .packages + } + } + let waitsForThemes = scene.widget == nil && scene.section == .theme + try await OffscreenSnapshot.write(SettingsView(store: store, editor: editor).background(Color(nsColor: .windowBackgroundColor)), + size: NSSize(width: CGFloat(scene.width ?? 1080), height: CGFloat(scene.height ?? 760)), + dark: dark, settle: settle, ready: { + !waitsForThemes || (!editor.gallery.choices.isEmpty && editor.gallery.choices.allSatisfy { editor.gallery.previews[$0.key] != nil }) + }, to: url) + } + } + + private static func bar(_ store: BarStore) -> some View { + BarView(store: store, display: display) + .environment(\.screenKind, .monitor) + .frame(maxHeight: .infinity, alignment: .top) + } + + /// The edit bar's height once its layout settles: edit mode measures its trays after the first + /// layout, so the strip grows to fit them. Lays the bar out offscreen until the height stops + /// changing and every widget has a frame, as BarEditViewTests does. + private static func editBarHeight(_ store: BarStore, width: CGFloat) async throws -> CGFloat { + guard var height = store.barEditing.geometry(for: display)?.layoutHeight else { + throw CommandFailure(message: "draw: Edit mode needs a configuration.") + } + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let host = NSHostingView(rootView: BarView(store: store, display: display).environment(\.screenKind, .monitor) + .environment(\.fixedDate, DocsImages.date)) + host.sizingOptions = [] + window.contentView = host + let expected = Set(store.barEditing.entries(on: display).map(\.id)) + for _ in 0..<100 { + host.layoutSubtreeIfNeeded() + let measured = store.barEditing.geometry(for: display)?.layoutHeight ?? height + if measured != height { + height = measured + window.setContentSize(CGSize(width: width, height: height)) + } else if expected.isSubset(of: dropTargets(in: host).flatMap(\.frames.keys)) { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + return height + } + + private static func dropTargets(in view: NSView) -> [BarEditDropTargetView] { + view.subviews.flatMap { subview in + (subview as? BarEditDropTargetView).map { [$0] + dropTargets(in: $0) } ?? dropTargets(in: subview) + } + } + /// A script that never runs, showing the scene's output and history for the first of `keys` that /// has them, or an empty output. private static func sample(_ scene: SceneFile, keys: [String]) throws -> ScriptWidget { diff --git a/Tests/StableBarTests/SceneRendererTests.swift b/Tests/StableBarTests/SceneRendererTests.swift index 5d7889f..858bd95 100644 --- a/Tests/StableBarTests/SceneRendererTests.swift +++ b/Tests/StableBarTests/SceneRendererTests.swift @@ -113,3 +113,91 @@ private func scene(_ json: String) throws -> SceneFile { defer { store.stop() } #expect(store.configuration?.widgets.map(\.id) == ["hello"]) } + +private let display = BarDisplay(id: 1, isBuiltIn: false, isMain: true, notch: nil) + +/// Draws `json` in the dark appearance, with a short settle, and returns its image and the store that drew it. +@MainActor private func drawn(_ json: String, in directory: URL) async throws -> (image: NSBitmapImageRep, store: BarStore) { + let scene = try scene(json) + let store = try SceneRenderer.store(for: scene, at: directory.appending(path: "scene.json"), in: directory.appending(path: "work"), + themes: themes) + let url = directory.appending(path: "out.png") + try await SceneRenderer.draw(scene, store: store, appearance: .dark, to: url, settle: .milliseconds(300)) + return (try #require(NSBitmapImageRep(data: Data(contentsOf: url))), store) +} + +/// How many different colors a grid of samples across `image` finds: 1 for a flat image. +private func colors(_ image: NSBitmapImageRep) -> Int { + var found = Set() + for x in stride(from: 0, to: image.pixelsWide, by: max(1, image.pixelsWide / 40)) { + for y in stride(from: 0, to: image.pixelsHigh, by: max(1, image.pixelsHigh / 40)) { + found.insert(image.colorAt(x: x, y: y).map { "\($0)" } ?? "none") + } + } + return found.count +} + +@Test @MainActor func barSceneDrawsAtTwiceItsSize() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let (image, store) = try await drawn(#""" + {"configuration": {"widgets": [{"id": "hello", "type": "text", "position": "left", "text": "Hello"}]}, "draw": "bar"} + """#, in: directory) + defer { store.stop() } + let height = try #require(store.configuration).barGeometry(for: display).panelHeight + #expect(image.pixelsWide == 2200) + #expect(image.pixelsHigh == Int((2 * height).rounded())) + #expect(colors(image) > 1) +} + +@Test @MainActor func popupSceneDrawsTheClockCalendar() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let (image, store) = try await drawn(#""" + {"configuration": {"widgets": [{"id": "clock", "type": "clock", "position": "right", "details": true}]}, "draw": "popup", "widget": "clock"} + """#, in: directory) + defer { store.stop() } + let widget = try #require(store.configuration?.widgets.first) + let popup = try #require(widgetPopup(widget, store: store)) + #expect(image.pixelsWide == Int((2 * ((popup.style.width ?? 360) + 48)).rounded())) + #expect(image.pixelsHigh == 1200) + #expect(colors(image) > 10) +} + +@Test @MainActor func editBarSceneFitsItsLayoutHeight() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let (image, store) = try await drawn(#""" + {"configuration": {"widgets": [ + {"id": "one", "type": "text", "position": "left", "text": "One"}, + {"id": "two", "type": "text", "position": "center", "text": "Two"}, + {"id": "three", "type": "text", "position": "right", "text": "Three"} + ]}, "draw": "editBar"} + """#, in: directory) + defer { store.stop() } + #expect(store.barEditing.isActive) + let height = try #require(store.barEditing.geometry(for: display)).layoutHeight + #expect(image.pixelsHigh == Int((2 * height).rounded())) + #expect(height >= (try #require(store.configuration).barGeometry(for: display).panelHeight)) +} + +@Test @MainActor func settingsSceneDrawsTheThemeSection() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let (image, store) = try await drawn(#"{"configuration": {"widgets": []}, "draw": "settings", "section": "theme"}"#, in: directory) + defer { store.stop() } + #expect(image.pixelsWide == 2160) + #expect(image.pixelsHigh == 1520) + #expect(colors(image) > 10) +} + +@Test @MainActor func popupSceneWithoutAPopupFails() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let error = await #expect(throws: (any Error).self) { + _ = try await drawn(#""" + {"configuration": {"widgets": [{"id": "hello", "type": "text", "position": "left", "text": "Hello"}]}, "draw": "popup", "widget": "hello"} + """#, in: directory) + } + #expect(errorMessage(try #require(error)).contains("hello")) +} From 9fefc212251310844ced9357ad6674938b4952dc Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:23:02 +0100 Subject: [PATCH 4/8] build: add a hidden command that draws scene files StableBar --render-scenes OUTPUT SCENE... draws each scene file, or every scene file in a folder, to OUTPUT as -.png, in UTC. It writes scenes.json beside them with each scene's images or the error that stopped it, so a build that can't read a newer scene still reports the others, and exits 1 when any scene failed. --- Sources/StableBar/LaunchCommand.swift | 4 ++ Sources/StableBar/SceneRenderer.swift | 60 +++++++++++++++++++ Sources/StableBar/StableBarApp.swift | 10 ++++ Tests/StableBarTests/SceneRendererTests.swift | 56 +++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/Sources/StableBar/LaunchCommand.swift b/Sources/StableBar/LaunchCommand.swift index d24ba5b..c0c8019 100644 --- a/Sources/StableBar/LaunchCommand.swift +++ b/Sources/StableBar/LaunchCommand.swift @@ -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) @@ -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: diff --git a/Sources/StableBar/SceneRenderer.swift b/Sources/StableBar/SceneRenderer.swift index ffe3b9e..ec2ff56 100644 --- a/Sources/StableBar/SceneRenderer.swift +++ b/Sources/StableBar/SceneRenderer.swift @@ -52,6 +52,66 @@ enum SceneRenderer { return store } + struct Report { + var failures = 0 + } + + /// Draws every scene file in `inputs`, a folder standing for its `*.json` files at any depth, to + /// `-.png` in `output`, and writes `scenes.json` there: each scene's images, or + /// the error that stopped it. A scene's name is its file name; a second scene with the same name + /// is an error. Times are drawn in UTC. + static func renderAll(_ inputs: [URL], to output: URL, settle: Duration = settle) async -> Report { + DocsImages.useUTC() + var report = Report() + var entries: [String: ConfigurationValue] = [:] + let work = FileManager.default.temporaryDirectory.appending(path: "stablebar-scenes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: work) } + do { + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + } catch { + FileHandle.standardError.write(Data("\(errorMessage(error))\n".utf8)) + return Report(failures: 1) + } + for file in sceneFiles(in: inputs) { + let name = file.deletingPathExtension().lastPathComponent + if entries[name] != nil { + report.failures += 1 + entries[file.path] = .object(["error": .string("Another scene is also named \(name).")]) + continue + } + do { + let scene = try SceneFile.read(file) + let store = try store(for: scene, at: file, in: work.appending(path: name), themes: bundledThemesDirectory) + defer { store.stop() } + var images: [ConfigurationValue] = [] + for appearance in scene.appearances { + let image = "\(name)-\(appearance.rawValue).png" + try await draw(scene, store: store, appearance: appearance, to: output.appending(path: image), settle: settle) + images.append(.string(image)) + } + entries[name] = .object(["images": .array(images)]) + } catch { + report.failures += 1 + entries[name] = .object(["error": .string(errorMessage(error))]) + } + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try? encoder.encode(["scenes": ConfigurationValue.object(entries)]).write(to: output.appending(path: "scenes.json")) + return report + } + + /// The scene files `inputs` name, sorted: files as given, and the `*.json` files at any depth in folders. + private static func sceneFiles(in inputs: [URL]) -> [URL] { + inputs.flatMap { input -> [URL] in + var isFolder: ObjCBool = false + guard FileManager.default.fileExists(atPath: input.path, isDirectory: &isFolder), isFolder.boolValue else { return [input] } + let files = FileManager.default.enumerator(at: input, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)? + .compactMap { $0 as? URL }.filter { $0.pathExtension == "json" } ?? [] + return files.sorted { $0.path < $1.path } + } + } + /// How long a scene's views settle before they're drawn. static var settle: Duration = .seconds(2) diff --git a/Sources/StableBar/StableBarApp.swift b/Sources/StableBar/StableBarApp.swift index 9bfb764..a07eed6 100644 --- a/Sources/StableBar/StableBarApp.swift +++ b/Sources/StableBar/StableBarApp.swift @@ -75,6 +75,16 @@ struct StableBarApp { exit(0) } NSApplication.shared.run() + case let .renderScenes(output, scenes): + NSApplication.shared.setActivationPolicy(.accessory) + Task { + let report = await SceneRenderer.renderAll(scenes, to: output) + if report.failures > 0 { + FileHandle.standardError.write(Data("\(report.failures) scene(s) failed; see \(output.appending(path: "scenes.json").path)\n".utf8)) + } + exit(report.failures > 0 ? 1 : 0) + } + NSApplication.shared.run() case let .triage(url, agent, model): try Triage.run(configuration: url, agent: agent, model: model) case let .triageContext(url): diff --git a/Tests/StableBarTests/SceneRendererTests.swift b/Tests/StableBarTests/SceneRendererTests.swift index 858bd95..303f888 100644 --- a/Tests/StableBarTests/SceneRendererTests.swift +++ b/Tests/StableBarTests/SceneRendererTests.swift @@ -201,3 +201,59 @@ private func colors(_ image: NSBitmapImageRep) -> Int { } #expect(errorMessage(try #require(error)).contains("hello")) } + +@Test func renderScenesCommandParses() throws { + guard case let .renderScenes(output, scenes) = try LaunchCommand(arguments: ["--render-scenes", "/tmp/out", "a.json", "scenes"]) else { + Issue.record("Expected renderScenes") + return + } + #expect(output.path == "/tmp/out") + #expect(scenes.map(\.lastPathComponent) == ["a.json", "scenes"]) + #expect(throws: CommandFailure.self) { try LaunchCommand(arguments: ["--render-scenes", "/tmp/out"]) } + #expect(!LaunchCommand.usage.contains("--render-scenes")) +} + +/// The scenes object of the report `renderAll` wrote to `output`. +private func report(in output: URL) throws -> [String: ConfigurationValue] { + let document = try JSONDecoder().decode([String: ConfigurationValue].self, from: Data(contentsOf: output.appending(path: "scenes.json"))) + guard case let .object(scenes)? = document["scenes"] else { throw CommandFailure(message: "scenes.json has no scenes") } + return scenes +} + +private let darkBar = #"{"configuration": {"widgets": [{"id": "hello", "type": "text", "position": "left", "text": "Hello"}]}, "draw": "bar", "appearances": ["dark"]}"# + +@Test @MainActor func renderAllReportsEachScene() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let scenes = directory.appending(path: "scenes"), output = directory.appending(path: "out") + try FileManager.default.createDirectory(at: scenes, withIntermediateDirectories: true) + try Data(darkBar.utf8).write(to: scenes.appending(path: "good.json")) + try Data(#"{"configuration": {}, "draw": "bar", "colour": 1}"#.utf8).write(to: scenes.appending(path: "bad.json")) + let summary = await SceneRenderer.renderAll([scenes], to: output, settle: .milliseconds(300)) + #expect(summary.failures == 1) + let entries = try report(in: output) + #expect(entries["good"] == .object(["images": .array([.string("good-dark.png")])])) + guard case let .object(bad)? = entries["bad"], case let .string(message)? = bad["error"] else { + Issue.record("Expected an error for bad.json") + return + } + #expect(message.contains("colour")) + #expect(FileManager.default.fileExists(atPath: output.appending(path: "good-dark.png").path)) +} + +@Test @MainActor func renderAllRejectsDuplicateNames() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let scenes = directory.appending(path: "scenes"), output = directory.appending(path: "out") + for folder in ["a", "b"] { + try FileManager.default.createDirectory(at: scenes.appending(path: folder), withIntermediateDirectories: true) + try Data(darkBar.utf8).write(to: scenes.appending(path: "\(folder)/clock.json")) + } + let summary = await SceneRenderer.renderAll([scenes], to: output, settle: .milliseconds(300)) + #expect(summary.failures == 1) + let errors = try report(in: output).values.compactMap { entry -> String? in + if case let .object(fields) = entry, case let .string(message)? = fields["error"] { message } else { nil } + } + #expect(errors.count == 1) + #expect(errors.first?.contains("clock") == true) +} From e12c578ceed8301995ff31b1066a8f37ac264888 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:30:42 +0100 Subject: [PATCH 5/8] build: add starter scenes and document them Five scenes cover what the docs images don't: the starter configuration's bar and its edit mode, Settings' General and Bar sections, and the Essentials clock's popup. They read Resources/config.json and examples/essentials.json, so a change to either shows in its scenes. A test loads every scene in scenes/. CONTRIBUTING.md explains the format and the command, and AGENTS.md points visible changes at scenes for their before/after images. --- .github/CODEOWNERS | 2 + AGENTS.md | 2 +- CONTRIBUTING.md | 12 ++ Tests/StableBarTests/SceneFileTests.swift | 16 ++ scenes/essentials-clock-popup.json | 197 ++++++++++++++++++++++ scenes/settings-bar.json | 27 +++ scenes/settings-general.json | 27 +++ scenes/starter-bar.json | 26 +++ scenes/starter-edit-bar.json | 26 +++ 9 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 scenes/essentials-clock-popup.json create mode 100644 scenes/settings-bar.json create mode 100644 scenes/settings-general.json create mode 100644 scenes/starter-bar.json create mode 100644 scenes/starter-edit-bar.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 366760f..aba896d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 15db3c0..a191220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `. 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a38746..f6ffe64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `-dark.png` and `-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. diff --git a/Tests/StableBarTests/SceneFileTests.swift b/Tests/StableBarTests/SceneFileTests.swift index 01a6b15..ff9bd49 100644 --- a/Tests/StableBarTests/SceneFileTests.swift +++ b/Tests/StableBarTests/SceneFileTests.swift @@ -90,3 +90,19 @@ func sceneNamesWhatItDraws(json: String, key: String) throws { #expect(choices(properties["tab"]) == Set(SceneFile.Tab.allCases.map(\.rawValue))) #expect(choices(properties["appearances"]) == Set(SceneFile.Appearance.allCases.map(\.rawValue))) } + +@Test @MainActor func everySceneInTheRepositoryLoads() throws { + let folder = repository.appending(path: "scenes") + let files = (FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)? + .compactMap { $0 as? URL }.filter { $0.pathExtension == "json" } ?? []).sorted { $0.path < $1.path } + #expect(files.count >= 4) + let work = FileManager.default.temporaryDirectory.appending(path: "scenes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: work) } + for file in files { + #expect(throws: Never.self, "\(file.lastPathComponent)") { + let store = try SceneRenderer.store(for: SceneFile.read(file), at: file, in: work.appending(path: file.lastPathComponent), + themes: repository.appending(path: "Resources/themes")) + store.stop() + } + } +} diff --git a/scenes/essentials-clock-popup.json b/scenes/essentials-clock-popup.json new file mode 100644 index 0000000..90953da --- /dev/null +++ b/scenes/essentials-clock-popup.json @@ -0,0 +1,197 @@ +{ + "$schema": "../scene.schema.json", + "configuration": "../examples/essentials.json", + "outputs": { + "system": { + "text": "18%", + "icon": "cpu", + "value": 0.18, + "fields": { + "cpu": "18", + "cpuUser": "12", + "cpuSystem": "6", + "memory": "54", + "memoryUsed": "17.3 GB", + "memoryCompressed": "1.2 GB", + "swap": "256 MB", + "pressure": "normal", + "thermal": "nominal", + "load": "1.62 1.80 1.74", + "uptime": "2d 4h" + }, + "values": { + "cpu": 0.18, + "memory": 0.54 + }, + "lists": { + "apps": [ + { + "name": "Xcode", + "pid": "100", + "path": "/Applications/Xcode.app", + "memory": "2.1 GB", + "cpu": "12" + }, + { + "name": "Safari", + "pid": "101", + "path": "/Applications/Safari.app", + "memory": "840 MB", + "cpu": "4" + }, + { + "name": "Finder", + "pid": "102", + "path": "/System/Library/CoreServices/Finder.app", + "memory": "120 MB", + "cpu": "1" + } + ] + } + }, + "network": { + "text": "↓1.2 MiB/s ↑240 KiB/s", + "icon": "network", + "fields": { + "interface": "en0", + "downloadRate": "1.2 MiB/s", + "uploadRate": "240 KiB/s" + }, + "lists": { + "interfaces": [ + { + "name": "en0", + "downloadRate": "1.2 MiB/s", + "uploadRate": "240 KiB/s" + } + ] + }, + "values": { + "download": 1258291, + "upload": 245760 + } + }, + "battery": { + "text": "82%", + "icon": "battery.75percent", + "values": { + "percent": 0.82 + }, + "fields": { + "percent": "82", + "state": "battery", + "present": "true", + "health": "normal", + "timeRemaining": "285" + } + }, + "calendar": { + "text": "Design review", + "icon": "calendar", + "fields": { + "access": "granted", + "phase": "soon", + "title": "Design review", + "start": "10:30", + "end": "11:15", + "minutes": "12" + }, + "lists": { + "today": [ + { + "title": "Standup", + "start": "09:15", + "end": "09:30" + }, + { + "title": "Design review", + "start": "10:30", + "end": "11:15" + }, + { + "title": "Lunch with Ana", + "start": "12:30", + "end": "13:30" + }, + { + "title": "1:1 with Sam", + "start": "15:00", + "end": "15:30" + }, + { + "title": "Release planning", + "start": "16:30", + "end": "17:15" + } + ] + } + } + }, + "history": { + "system": [ + 0.2, + 0.273, + 0.318, + 0.32, + 0.289, + 0.25, + 0.23, + 0.244, + 0.282, + 0.32, + 0.329, + 0.296, + 0.228, + 0.151, + 0.097, + 0.083, + 0.106, + 0.145, + 0.169, + 0.162, + 0.126, + 0.084, + 0.064, + 0.085, + 0.146, + 0.223, + 0.285, + 0.311, + 0.296, + 0.261, + 0.232, + 0.232, + 0.264, + 0.31, + 0.34, + 0.331, + 0.28, + 0.204, + 0.135, + 0.099, + 0.103, + 0.385, + 0.417, + 0.423, + 0.396, + 0.349, + 0.311, + 0.057, + 0.097, + 0.168, + 0.242, + 0.288, + 0.294, + 0.268, + 0.235, + 0.223, + 0.244, + 0.29, + 0.335, + 0.351 + ] + }, + "draw": "popup", + "widget": "clock", + "height": 460 +} diff --git a/scenes/settings-bar.json b/scenes/settings-bar.json new file mode 100644 index 0000000..3cd5cec --- /dev/null +++ b/scenes/settings-bar.json @@ -0,0 +1,27 @@ +{ + "$schema": "../scene.schema.json", + "configuration": "../Resources/config.json", + "outputs": { + "audio": { + "text": "45%", + "icon": "speaker.wave.2.fill", + "value": 0.45, + "fields": { + "volumeAvailable": "true", + "muted": "false" + }, + "values": { + "volume": 0.45 + }, + "lists": { + "devices": [ + { + "name": "MacBook Pro Speakers" + } + ] + } + } + }, + "draw": "settings", + "section": "bar" +} diff --git a/scenes/settings-general.json b/scenes/settings-general.json new file mode 100644 index 0000000..25a25cc --- /dev/null +++ b/scenes/settings-general.json @@ -0,0 +1,27 @@ +{ + "$schema": "../scene.schema.json", + "configuration": "../Resources/config.json", + "outputs": { + "audio": { + "text": "45%", + "icon": "speaker.wave.2.fill", + "value": 0.45, + "fields": { + "volumeAvailable": "true", + "muted": "false" + }, + "values": { + "volume": 0.45 + }, + "lists": { + "devices": [ + { + "name": "MacBook Pro Speakers" + } + ] + } + } + }, + "draw": "settings", + "section": "general" +} diff --git a/scenes/starter-bar.json b/scenes/starter-bar.json new file mode 100644 index 0000000..cd0806a --- /dev/null +++ b/scenes/starter-bar.json @@ -0,0 +1,26 @@ +{ + "$schema": "../scene.schema.json", + "configuration": "../Resources/config.json", + "outputs": { + "audio": { + "text": "45%", + "icon": "speaker.wave.2.fill", + "value": 0.45, + "fields": { + "volumeAvailable": "true", + "muted": "false" + }, + "values": { + "volume": 0.45 + }, + "lists": { + "devices": [ + { + "name": "MacBook Pro Speakers" + } + ] + } + } + }, + "draw": "bar" +} diff --git a/scenes/starter-edit-bar.json b/scenes/starter-edit-bar.json new file mode 100644 index 0000000..62e8a18 --- /dev/null +++ b/scenes/starter-edit-bar.json @@ -0,0 +1,26 @@ +{ + "$schema": "../scene.schema.json", + "configuration": "../Resources/config.json", + "outputs": { + "audio": { + "text": "45%", + "icon": "speaker.wave.2.fill", + "value": 0.45, + "fields": { + "volumeAvailable": "true", + "muted": "false" + }, + "values": { + "volume": 0.45 + }, + "lists": { + "devices": [ + { + "name": "MacBook Pro Speakers" + } + ] + } + } + }, + "draw": "editBar" +} From 25a88ad014a68107f6da541e050a8f461ed8900b Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 15:08:09 +0100 Subject: [PATCH 6/8] build: keep scene renders to their own scene, size and appearance A review found ways a scene render could leave its scene. Settings switched to a setup's own bar beside the configuration, which loaded the normal way and ran its scripts: the store now shows a scene through showWithoutRunning, which takes everything the loader read (profiles, packages, contributions) without starting anything. Each appearance gets its own store, so profiles for dark or light apply. An inline configuration reads as if it sat beside its scene, so its includes resolve there and Settings shows the same path on every run. A width or height outside 100 to 4000 or 20 to 4000 points is an error instead of a crash, and renderAll counts a report it couldn't write as a failure. --- Sources/StableBar/BarStore.swift | 22 +++- Sources/StableBar/OffscreenSnapshot.swift | 13 +- Sources/StableBar/SceneFile.swift | 6 + Sources/StableBar/SceneRenderer.swift | 42 +++--- Tests/StableBarTests/SceneFileTests.swift | 7 + Tests/StableBarTests/SceneRendererTests.swift | 123 ++++++++++++++++++ scene.schema.json | 4 +- 7 files changed, 193 insertions(+), 24 deletions(-) diff --git a/Sources/StableBar/BarStore.swift b/Sources/StableBar/BarStore.swift index 7307f41..177997e 100644 --- a/Sources/StableBar/BarStore.swift +++ b/Sources/StableBar/BarStore.swift @@ -624,7 +624,8 @@ 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 @@ -632,6 +633,25 @@ final class BarStore: NSObject { 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 diff --git a/Sources/StableBar/OffscreenSnapshot.swift b/Sources/StableBar/OffscreenSnapshot.swift index b3bfcfd..2c63884 100644 --- a/Sources/StableBar/OffscreenSnapshot.swift +++ b/Sources/StableBar/OffscreenSnapshot.swift @@ -47,11 +47,16 @@ enum OffscreenSnapshot { } prepare() try await Task.sleep(for: settle) - 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)! + 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) - try image.representation(using: .png, properties: [:])!.write(to: url) + 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) } } diff --git a/Sources/StableBar/SceneFile.swift b/Sources/StableBar/SceneFile.swift index 9c4a6bf..53a921e 100644 --- a/Sources/StableBar/SceneFile.swift +++ b/Sources/StableBar/SceneFile.swift @@ -58,6 +58,12 @@ struct SceneFile: Decodable { appearances = try values.decodeIfPresent([Appearance].self, forKey: .appearances) ?? Appearance.allCases 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.") } diff --git a/Sources/StableBar/SceneRenderer.swift b/Sources/StableBar/SceneRenderer.swift index ec2ff56..0cce247 100644 --- a/Sources/StableBar/SceneRenderer.swift +++ b/Sources/StableBar/SceneRenderer.swift @@ -7,27 +7,30 @@ import SwiftUI /// window manager or network request runs, and nothing asks for a permission. @MainActor enum SceneRenderer { - /// A store showing `scene`, which runs nothing. `directory` holds the files the store needs: an - /// inline configuration and the local Store index. A configuration path is relative to `sceneURL`. - static func store(for scene: SceneFile, at sceneURL: URL, in directory: URL, themes: URL?) throws -> BarStore { + /// A store showing `scene` in `appearance`, which runs nothing. `directory` holds the local Store + /// index. A configuration path is relative to `sceneURL`, and an inline configuration reads as if it + /// were `.configuration.json` beside it, so its includes resolve there and Settings shows the + /// same path on every run. + static func store(for scene: SceneFile, at sceneURL: URL, in directory: URL, themes: URL?, + appearance: SceneFile.Appearance = .light) throws -> BarStore { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let folder = sceneURL.deletingLastPathComponent() let configurationURL: URL + let data: Data? switch scene.configuration { case let .inline(document): - configurationURL = directory.appending(path: "config.json") - try JSONEncoder().encode(document).write(to: configurationURL) + configurationURL = folder.appending(path: "\(sceneURL.deletingPathExtension().lastPathComponent).configuration.json") + data = try JSONEncoder().encode(document) case let .file(path): - configurationURL = URL(filePath: path, relativeTo: sceneURL.deletingLastPathComponent()).standardizedFileURL + configurationURL = URL(filePath: path, relativeTo: folder).standardizedFileURL + data = nil } let store = BarStore(configurationURL: configurationURL, themesDirectory: themes) var loader = ConfigurationLoader() loader.themesDirectory = themes - let configuration = try loader.load(configurationURL) - store.configuration = configuration - store.effectiveDocument = loader.document - store.origins = loader.origins - store.widgetPackageNames = loader.widgetPackageNames - store.activeProfiles = loader.activeProfiles + let context = ConfigurationContext(appearance: appearance.rawValue) + let configuration = try loader.load(configurationURL, data: data, context: context) + store.showWithoutRunning(configuration, loader: loader, context: context, setup: ScreenSetup([display])) var providers: [String: ScriptWidget] = [:] for widget in configuration.widgets where widget.enabled { @@ -46,7 +49,7 @@ enum SceneRenderer { store.workspaceStore.workspaces = try workspaceSnapshot(workspaces: JSONEncoder().encode(workspaces), windows: JSONEncoder().encode(scene.windows ?? [])) } - store.activeApp = NSRunningApplication.runningApplications(withBundleIdentifier: scene.activeApp ?? "com.apple.finder").first + store.activeApp = SampleApp(bundleIdentifier: scene.activeApp ?? "com.apple.finder") try DocsImages.offline(store, in: directory) store.preparePresentations() return store @@ -81,10 +84,11 @@ enum SceneRenderer { } do { let scene = try SceneFile.read(file) - let store = try store(for: scene, at: file, in: work.appending(path: name), themes: bundledThemesDirectory) - defer { store.stop() } var images: [ConfigurationValue] = [] for appearance in scene.appearances { + let store = try store(for: scene, at: file, in: work.appending(path: "\(name)-\(appearance.rawValue)"), + themes: bundledThemesDirectory, appearance: appearance) + defer { store.stop() } let image = "\(name)-\(appearance.rawValue).png" try await draw(scene, store: store, appearance: appearance, to: output.appending(path: image), settle: settle) images.append(.string(image)) @@ -97,7 +101,12 @@ enum SceneRenderer { } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try? encoder.encode(["scenes": ConfigurationValue.object(entries)]).write(to: output.appending(path: "scenes.json")) + do { + try encoder.encode(["scenes": ConfigurationValue.object(entries)]).write(to: output.appending(path: "scenes.json")) + } catch { + report.failures += 1 + FileHandle.standardError.write(Data("scenes.json: \(errorMessage(error))\n".utf8)) + } return report } @@ -154,7 +163,6 @@ enum SceneRenderer { try await OffscreenSnapshot.write(content, size: NSSize(width: popupWidth + 48, height: CGFloat(scene.height ?? 600)), dark: dark, settle: settle, to: url) case .settings: - store.showScreenSetup(ScreenSetup([display])) let editor = ConfigurationEditor(store: store) if let id = scene.widget { guard configuration.widgets.contains(where: { $0.id == id }) else { diff --git a/Tests/StableBarTests/SceneFileTests.swift b/Tests/StableBarTests/SceneFileTests.swift index ff9bd49..cab363d 100644 --- a/Tests/StableBarTests/SceneFileTests.swift +++ b/Tests/StableBarTests/SceneFileTests.swift @@ -106,3 +106,10 @@ func sceneNamesWhatItDraws(json: String, key: String) throws { } } } + +@Test(arguments: [#""width": 0"#, #""height": 0"#, #""width": 1e30"#, #""height": 99999"#]) +func sceneRejectsImpossibleSizes(size: String) throws { + let error = #expect(throws: (any Error).self) { try scene(#"{"configuration": {}, "draw": "bar", \#(size)}"#) } + let message = errorMessage(try #require(error)) + #expect(message.contains("width") || message.contains("height")) +} diff --git a/Tests/StableBarTests/SceneRendererTests.swift b/Tests/StableBarTests/SceneRendererTests.swift index 303f888..c269967 100644 --- a/Tests/StableBarTests/SceneRendererTests.swift +++ b/Tests/StableBarTests/SceneRendererTests.swift @@ -257,3 +257,126 @@ private let darkBar = #"{"configuration": {"widgets": [{"id": "hello", "type": " #expect(errors.count == 1) #expect(errors.first?.contains("clock") == true) } + +// MARK: - Review fixes + +/// Records requests to `scene-image.invalid` and fails them, so a test can tell whether a render asked the network for an image. +private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var requests: [URL] = [] + override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "scene-image.invalid" } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if let url = request.url { Self.requests.append(url) } + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + override func stopLoading() {} +} + +@Test @MainActor func settingsSceneLoadsNoOtherBar() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let ran = directory.appending(path: "ran") + try Data(#"{"widgets": [{"id": "hello", "type": "text", "position": "left", "text": "Hello"}]}"#.utf8) + .write(to: directory.appending(path: "config.json")) + try Data(#"{"widgets": [{"id": "probe", "type": "script", "position": "left", "command": "touch '\#(ran.path)'", "interval": 1}]}"#.utf8) + .write(to: directory.appending(path: ScreenSetup([display]).fileName)) + let scene = try scene(#"{"configuration": "config.json", "draw": "settings", "section": "general"}"#) + let store = try SceneRenderer.store(for: scene, at: directory.appending(path: "scene.json"), in: directory.appending(path: "work"), + themes: themes) + defer { store.stop() } + try await SceneRenderer.draw(scene, store: store, appearance: .light, to: directory.appending(path: "out.png"), settle: .milliseconds(300)) + try await Task.sleep(for: .seconds(1)) + #expect(store.configuration?.widgets.map(\.id) == ["hello"]) + #expect(store.scripts.values.allSatisfy { $0.diagnostics.lastStarted == nil }) + #expect(!FileManager.default.fileExists(atPath: ran.path)) +} + +@Test @MainActor func sceneRenderMakesNoImageRequest() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + URLProtocol.registerClass(RecordingURLProtocol.self) + defer { URLProtocol.unregisterClass(RecordingURLProtocol.self) } + RecordingURLProtocol.requests = [] + let (_, store) = try await drawn(#""" + {"configuration": {"widgets": [{"id": "pic", "type": "text", "position": "left", "text": "Pic", + "popup": {"style": {"width": 200}, "layout": {"type": "column", "children": [{"type": "image", "path": "https://scene-image.invalid/icon.png"}]}}}]}, + "draw": "popup", "widget": "pic"} + """#, in: directory) + defer { store.stop() } + try await Task.sleep(for: .milliseconds(500)) + #expect(RecordingURLProtocol.requests.isEmpty) +} + +@Test @MainActor func sceneActiveAppComesFromItsBundle() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try store(#"{"configuration": {"widgets": []}, "activeApp": "com.apple.calculator", "draw": "bar"}"#, in: directory) + defer { store.stop() } + #expect(store.activeApp?.localizedName == "Calculator") + #expect(store.activeApp?.icon != nil) +} + +@Test @MainActor func sceneStoreShowsAStablePath() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let scene = try scene(#"{"configuration": {"widgets": []}, "draw": "settings", "section": "general"}"#) + let sceneURL = directory.appending(path: "general.json") + let first = try SceneRenderer.store(for: scene, at: sceneURL, in: directory.appending(path: "one"), themes: themes) + let second = try SceneRenderer.store(for: scene, at: sceneURL, in: directory.appending(path: "two"), themes: themes) + defer { first.stop(); second.stop() } + #expect(first.configurationURL == second.configurationURL) + #expect(first.configurationURL.deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL) +} + +@Test @MainActor func sceneStoreKeepsProfiles() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try store(#""" + {"configuration": {"widgets": [], "profiles": [{"name": "Work", "configuration": {"bar": {"height": 40}}}]}, "draw": "settings", "section": "profiles"} + """#, in: directory) + defer { store.stop() } + #expect(store.profiles.map(\.name) == ["Work"]) +} + +@Test @MainActor func sceneStoreResolvesProfilesForItsAppearance() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let scene = try scene(#""" + {"configuration": { + "bar": {"height": 30}, + "widgets": [{"id": "label", "type": "text", "position": "left", "text": "Label"}], + "profiles": [{"name": "Night", "when": {"appearance": "dark"}, "configuration": {"bar": {"height": 44}}}] + }, "draw": "bar"} + """#) + func height(_ appearance: SceneFile.Appearance) throws -> (Double?, String) { + let store = try SceneRenderer.store(for: scene, at: directory.appending(path: "scene.json"), + in: directory.appending(path: appearance.rawValue), themes: themes, appearance: appearance) + defer { store.stop() } + return (store.configuration?.bar.height, + "context=\(store.context.appearance) active=\(store.activeProfiles) error=\(String(describing: store.configurationError)) whens=\(store.profiles.map { String(describing: $0.when) })") + } + let light = try height(.light), dark = try height(.dark) + #expect(light.0 == 30, "\(light.1)") + #expect(dark.0 == 44, "\(dark.1)") +} + +@Test @MainActor func inlineSceneResolvesIncludesBesideTheScene() throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + try Data(#"{"widgets": [{"id": "included", "type": "text", "position": "left", "text": "Included"}]}"#.utf8) + .write(to: directory.appending(path: "included.json")) + let store = try store(#"{"configuration": {"includes": ["included.json"]}, "draw": "bar"}"#, in: directory) + defer { store.stop() } + #expect(store.configuration?.widgets.map(\.id) == ["included"]) +} + +@Test @MainActor func renderAllFailsWhenItCannotWriteItsReport() async throws { + let directory = try scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let scenes = directory.appending(path: "scenes"), output = directory.appending(path: "out") + try FileManager.default.createDirectory(at: scenes, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: output.appending(path: "scenes.json"), withIntermediateDirectories: true) + try Data(darkBar.utf8).write(to: scenes.appending(path: "good.json")) + let summary = await SceneRenderer.renderAll([scenes], to: output, settle: .milliseconds(300)) + #expect(summary.failures >= 1) +} diff --git a/scene.schema.json b/scene.schema.json index c8e597f..71f1702 100644 --- a/scene.schema.json +++ b/scene.schema.json @@ -46,7 +46,7 @@ "minItems": 1, "uniqueItems": true }, - "width": { "description": "The image's width in points.", "type": "number", "minimum": 100 }, - "height": { "description": "The image's height in points.", "type": "number", "minimum": 20 } + "width": { "description": "The image's width in points.", "type": "number", "minimum": 100, "maximum": 4000 }, + "height": { "description": "The image's height in points.", "type": "number", "minimum": 20, "maximum": 4000 } } } From 235b07cf13eb779c9afb296ac280f94542c01c9b Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 18:25:56 +0100 Subject: [PATCH 7/8] build: reject scenes with no appearance or the same one twice From CodeRabbit's review: scene.schema.json allows one or both appearances, each once, but the decoder took an empty list, which drew nothing and still reported success, and a repeated one, which drew the same image twice. Both are errors now, reported like any scene a build can't read. --- Sources/StableBar/SceneFile.swift | 4 ++++ Tests/StableBarTests/SceneFileTests.swift | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/Sources/StableBar/SceneFile.swift b/Sources/StableBar/SceneFile.swift index 53a921e..5b70c60 100644 --- a/Sources/StableBar/SceneFile.swift +++ b/Sources/StableBar/SceneFile.swift @@ -56,6 +56,10 @@ struct SceneFile: Decodable { 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) { diff --git a/Tests/StableBarTests/SceneFileTests.swift b/Tests/StableBarTests/SceneFileTests.swift index cab363d..76255d8 100644 --- a/Tests/StableBarTests/SceneFileTests.swift +++ b/Tests/StableBarTests/SceneFileTests.swift @@ -113,3 +113,11 @@ func sceneRejectsImpossibleSizes(size: String) throws { let message = errorMessage(try #require(error)) #expect(message.contains("width") || message.contains("height")) } + +// As scene.schema.json says: at least one appearance, each once. An empty list would draw nothing and report success. +@Test(arguments: [#"[]"#, #"["dark", "dark"]"#]) +func sceneRejectsEmptyOrRepeatedAppearances(appearances: String) throws { + let error = #expect(throws: (any Error).self) { try scene(#"{"configuration": {}, "draw": "bar", "appearances": \#(appearances)}"#) } + #expect(errorMessage(try #require(error)).contains("ppearance")) + #expect(try scene(#"{"configuration": {}, "draw": "bar", "appearances": ["light"]}"#).appearances == [.light]) +} From 7125cdedd84eaf6e14cfd5d799fbcb04b2e9ee0f Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 18:32:45 +0100 Subject: [PATCH 8/8] test: guard the scene image test's request list As CodeRabbit found for the docs images' offline test in the pull request below this one: the recorder appended requests from URL loading threads while the test read and reset the list on the main actor. A lock guards it now. --- Tests/StableBarTests/SceneRendererTests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Tests/StableBarTests/SceneRendererTests.swift b/Tests/StableBarTests/SceneRendererTests.swift index c269967..adfe8a1 100644 --- a/Tests/StableBarTests/SceneRendererTests.swift +++ b/Tests/StableBarTests/SceneRendererTests.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import os import Testing @testable import StableBar @@ -261,12 +262,15 @@ private let darkBar = #"{"configuration": {"widgets": [{"id": "hello", "type": " // MARK: - Review fixes /// Records requests to `scene-image.invalid` and fails them, so a test can tell whether a render asked the network for an image. +/// The URL loading system records from its own threads while the test reads on the main actor, so a lock guards the list. private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) static var requests: [URL] = [] + private static let recorded = OSAllocatedUnfairLock<[URL]>(initialState: []) + static var requests: [URL] { recorded.withLock { $0 } } + static func reset() { recorded.withLock { $0 = [] } } override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "scene-image.invalid" } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { - if let url = request.url { Self.requests.append(url) } + if let url = request.url { Self.recorded.withLock { $0.append(url) } } client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) } override func stopLoading() {} @@ -296,7 +300,7 @@ private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { defer { try? FileManager.default.removeItem(at: directory) } URLProtocol.registerClass(RecordingURLProtocol.self) defer { URLProtocol.unregisterClass(RecordingURLProtocol.self) } - RecordingURLProtocol.requests = [] + RecordingURLProtocol.reset() let (_, store) = try await drawn(#""" {"configuration": {"widgets": [{"id": "pic", "type": "text", "position": "left", "text": "Pic", "popup": {"style": {"width": 200}, "layout": {"type": "column", "children": [{"type": "image", "path": "https://scene-image.invalid/icon.png"}]}}}]},