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/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/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/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/OffscreenSnapshot.swift b/Sources/StableBar/OffscreenSnapshot.swift new file mode 100644 index 0000000..2c63884 --- /dev/null +++ b/Sources/StableBar/OffscreenSnapshot.swift @@ -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) + } +} diff --git a/Sources/StableBar/SceneFile.swift b/Sources/StableBar/SceneFile.swift new file mode 100644 index 0000000..5b70c60 --- /dev/null +++ b/Sources/StableBar/SceneFile.swift @@ -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)) + } +} diff --git a/Sources/StableBar/SceneRenderer.swift b/Sources/StableBar/SceneRenderer.swift new file mode 100644 index 0000000..0cce247 --- /dev/null +++ b/Sources/StableBar/SceneRenderer.swift @@ -0,0 +1,251 @@ +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, +/// window manager or network request runs, and nothing asks for a permission. +@MainActor +enum SceneRenderer { + /// 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 = folder.appending(path: "\(sceneURL.deletingPathExtension().lastPathComponent).configuration.json") + data = try JSONEncoder().encode(document) + case let .file(path): + configurationURL = URL(filePath: path, relativeTo: folder).standardizedFileURL + data = nil + } + let store = BarStore(configurationURL: configurationURL, themesDirectory: themes) + var loader = ConfigurationLoader() + loader.themesDirectory = themes + 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 { + 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 = SampleApp(bundleIdentifier: scene.activeApp ?? "com.apple.finder") + try DocsImages.offline(store, in: directory) + store.preparePresentations() + 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) + 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)) + } + entries[name] = .object(["images": .array(images)]) + } catch { + report.failures += 1 + entries[name] = .object(["error": .string(errorMessage(error))]) + } + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + 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 + } + + /// 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) + + 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: + 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 { + 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/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/SceneFileTests.swift b/Tests/StableBarTests/SceneFileTests.swift new file mode 100644 index 0000000..76255d8 --- /dev/null +++ b/Tests/StableBarTests/SceneFileTests.swift @@ -0,0 +1,123 @@ +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))) +} + +@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() + } + } +} + +@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")) +} + +// 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]) +} diff --git a/Tests/StableBarTests/SceneRendererTests.swift b/Tests/StableBarTests/SceneRendererTests.swift new file mode 100644 index 0000000..adfe8a1 --- /dev/null +++ b/Tests/StableBarTests/SceneRendererTests.swift @@ -0,0 +1,386 @@ +import AppKit +import Foundation +import os +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"]) +} + +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")) +} + +@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) +} + +// 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 { + 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.recorded.withLock { $0.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.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"}]}}}]}, + "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 new file mode 100644 index 0000000..71f1702 --- /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, "maximum": 4000 }, + "height": { "description": "The image's height in points.", "type": "number", "minimum": 20, "maximum": 4000 } + } +} 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" +}