diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce00e68..8a38746 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ Release jobs set `STABLEBAR_RELEASE_BUILD=1` when packaging to preserve their st ## Docs images -`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data and a fixed time, and writes them as WebP. 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). +`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). ## Agent skills diff --git a/Sources/StableBar/BarInspector.swift b/Sources/StableBar/BarInspector.swift index 9366e01..43bc812 100644 --- a/Sources/StableBar/BarInspector.swift +++ b/Sources/StableBar/BarInspector.swift @@ -44,6 +44,7 @@ private extension WidgetTransition { struct BarInspector: View { @Bindable var editor: ConfigurationEditor let screen: ScreenKind + @Environment(\.fixedDate) private var fixedDate let kinds: [ScreenKind] let names: [String: String] let source: (WidgetConfiguration) -> PackageOrigin @@ -401,7 +402,7 @@ struct BarInspector: View { case let .clock(format): SettingsGroup("Clock & date") { TimelineView(.periodic(from: .now, by: format.contains("s") ? 1 : 60)) { context in - Text(widget.label(output: nil, activeApp: nil, date: context.date).text) + Text(widget.label(output: nil, activeApp: nil, date: fixedDate ?? context.date).text) .font(.system(size: 24, weight: .medium, design: .rounded)).monospacedDigit() .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 8) } @@ -516,7 +517,7 @@ struct BarInspector: View { } label: { VStack(alignment: .leading, spacing: 4) { Text(choice.title).font(.system(size: 10)).opacity(0.65) - Text(choice.sample).font(.system(size: 12, weight: .medium)).monospacedDigit() + Text(choice.sample(at: fixedDate ?? .now)).font(.system(size: 12, weight: .medium)).monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading).padding(9) .background(!custom && format == choice.value ? Color.accentColor.opacity(0.16) : SettingsPalette.control, diff --git a/Sources/StableBar/BarView.swift b/Sources/StableBar/BarView.swift index 08fda95..e32305d 100644 --- a/Sources/StableBar/BarView.swift +++ b/Sources/StableBar/BarView.swift @@ -227,6 +227,10 @@ extension EnvironmentValues { } @Entry var moreWidgetsAnchor: NSView? @Entry var barExpansion: BarExpansion? + /// A date to draw instead of the current time, so a render shows the same clock on every run. + @Entry var fixedDate: Date? + /// Draws without the network, as a render does: remote images stay empty. + @Entry var offline = false } enum BarExpansion { @@ -329,6 +333,7 @@ struct WidgetView: View { @Environment(\.screenKind) private var screenKind @Environment(\.moreWidgetsAnchor) private var moreWidgetsAnchor @Environment(\.barExpansion) private var barExpansion + @Environment(\.fixedDate) private var fixedDate private var resolution: ResolvedWidgetStyle { store.presentations[widget.id]?.style(on: screenKind) ?? ResolvedWidgetStyle(base: resolveStyle([defaults, widget.style])) @@ -618,10 +623,13 @@ struct WidgetView: View { } } - /// `content` drawn for the time the widget shows: a clock's, redrawn on its schedule, otherwise now. + /// `content` drawn for the time the widget shows: the render's fixed date when it has one, a clock's + /// redrawn on its schedule, otherwise now. @ViewBuilder private func timed(@ViewBuilder _ content: @escaping (Date) -> Content) -> some View { - if case let .clock(format) = widget.content { + if let fixedDate { + content(fixedDate) + } else if case let .clock(format) = widget.content { TimelineView(clockSchedule(format)) { context in content(context.date) } } else { content(.now) diff --git a/Sources/StableBar/ClockCalendar.swift b/Sources/StableBar/ClockCalendar.swift index 2261fee..978d6cf 100644 --- a/Sources/StableBar/ClockCalendar.swift +++ b/Sources/StableBar/ClockCalendar.swift @@ -25,16 +25,18 @@ struct ClockCalendar: View { let openCalendar: () -> Void @State private var offset = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.fixedDate) private var fixedDate var body: some View { TimelineView(.periodic(from: .now, by: 60)) { context in + let now = fixedDate ?? context.date let zone = timeZone.flatMap(TimeZone.init(identifier:)) ?? .autoupdatingCurrent - let month = ClockMonth(date: context.date, offset: offset, timeZone: zone) + let month = ClockMonth(date: now, offset: offset, timeZone: zone) VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 3) { - Text(formatted(context.date, in: zone, pattern: nil)) + Text(formatted(now, in: zone, pattern: nil)) .font(.system(size: 34, weight: .medium, design: .rounded)).monospacedDigit() - Text(formatted(context.date, in: zone, pattern: "EEEE, d MMMM yyyy")) + Text(formatted(now, in: zone, pattern: "EEEE, d MMMM yyyy")) .font(.system(size: 12)).opacity(0.6) if timeZone != nil { Text(zone.identifier.replacingOccurrences(of: "_", with: " ")) @@ -61,7 +63,7 @@ struct ClockCalendar: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 3) { ForEach(0..<42) { index in if let date = month.days[index] { - let today = month.calendar.isDate(date, inSameDayAs: context.date) + let today = month.calendar.isDate(date, inSameDayAs: now) Text(String(month.calendar.component(.day, from: date))) .font(.system(size: 12, weight: today ? .semibold : .regular)).monospacedDigit() .foregroundStyle(today ? appearance.accentText : appearance.foreground) diff --git a/Sources/StableBar/DocsImages.swift b/Sources/StableBar/DocsImages.swift index 249cc16..ffe4acd 100644 --- a/Sources/StableBar/DocsImages.swift +++ b/Sources/StableBar/DocsImages.swift @@ -2,11 +2,32 @@ import AppKit import SwiftUI /// Draws the images in docs/images from the real bar, popup and Settings views, in the dark and -/// light appearance. Sample data and a fixed time give the same pixels on every run until the views -/// change. `scripts/render-docs-images.sh` runs it and converts the images to WebP. +/// light appearance. Sample data, a fixed date in UTC and a local, empty Store index give the same +/// pixels on every run until the views change. `scripts/render-docs-images.sh` runs it and converts +/// the images to WebP. @MainActor enum DocsImages { + /// Thursday 22 October 2026, 09:41 UTC, the time the bar's sample clock shows: what every clock in a + /// render draws instead of the current time. + static let date = Date(timeIntervalSince1970: 1_792_662_060) + + /// Draws times in UTC, so a render shows the same clock on every Mac. + static func useUTC() { + setenv("TZ", "UTC", 1) + NSTimeZone.resetSystemTimeZone() + } + + /// Points the store's Store client at a local, empty index in `directory`, so the theme gallery, + /// the widget catalog and Packages never reach the network while a render draws them. + static func offline(_ store: BarStore, in directory: URL) throws { + let index = directory.appending(path: "index.json") + try Data(#"{"packages":[]}"#.utf8).write(to: index) + store.packageStoreClient = PackageStoreClient(packages: WidgetPackageStore(directory: directory.appending(path: "packages")), + indexURL: index) + } + static func render(to directory: URL) async throws { + useUTC() let samples = FileManager.default.temporaryDirectory.appending(path: "stablebar-docs-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: samples) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -191,9 +212,10 @@ enum DocsImages { return type }) let store = BarStore(configurationURL: url, registry: registry) + try offline(store, in: directory) store.workspaceStore.executablePath = { aerospace.path } store.reloadConfiguration() - store.activeApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first + store.activeApp = SampleApp(bundleIdentifier: "com.apple.finder") return store } @@ -203,6 +225,8 @@ enum DocsImages { 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)))) diff --git a/Sources/StableBar/PopupView.swift b/Sources/StableBar/PopupView.swift index 4c942c9..ee21fad 100644 --- a/Sources/StableBar/PopupView.swift +++ b/Sources/StableBar/PopupView.swift @@ -838,12 +838,13 @@ struct Shimmer: ViewModifier { let active: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.measuringBarWidths) private var measuring + @Environment(\.fixedDate) private var fixedDate func body(content: Content) -> some View { if active && !reduceMotion && !measuring { content.overlay { TimelineView(.animation) { timeline in - let phase = timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 + let phase = (fixedDate ?? timeline.date).timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 GeometryReader { proxy in LinearGradient(colors: [.clear, .white.opacity(0.85), .clear], startPoint: .leading, endPoint: .trailing) .frame(width: proxy.size.width * 0.4) @@ -934,6 +935,7 @@ struct WidgetImage: View { let fit: ImageFit @State private var image: NSImage? @State private var error = "" + @Environment(\.offline) private var offline var body: some View { Group { @@ -956,6 +958,7 @@ struct WidgetImage: View { } else { url = URL(filePath: NSString(string: path).expandingTildeInPath) } + if offline, !url.isFileURL { return } if url.isFileURL { let file = try url.resourceValues(forKeys: [.isApplicationKey, .isExecutableKey, .isRegularFileKey, .contentTypeKey]) if file.isApplication == true || file.isExecutable == true && file.isRegularFile == true && file.contentType?.conforms(to: .image) != true { diff --git a/Sources/StableBar/SampleApp.swift b/Sources/StableBar/SampleApp.swift new file mode 100644 index 0000000..3b66156 --- /dev/null +++ b/Sources/StableBar/SampleApp.swift @@ -0,0 +1,22 @@ +import AppKit + +/// An app as a render shows it: the name and icon of its bundle, whether it runs or not, so a render +/// doesn't depend on the apps running on the Mac that draws it. +final class SampleApp: NSRunningApplication, @unchecked Sendable { + private let name: String? + private let image: NSImage? + private let identifier: String + + init(bundleIdentifier: String) { + identifier = bundleIdentifier + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + let bundle = url.flatMap(Bundle.init(url:)) + name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") ?? bundle?.object(forInfoDictionaryKey: "CFBundleName")) as? String + image = url.map { NSWorkspace.shared.icon(forFile: $0.path) } + super.init() + } + + override var localizedName: String? { name } + override var icon: NSImage? { image } + override var bundleIdentifier: String? { identifier } +} diff --git a/Sources/StableBar/WidgetSettings.swift b/Sources/StableBar/WidgetSettings.swift index d50efea..c3e8cf5 100644 --- a/Sources/StableBar/WidgetSettings.swift +++ b/Sources/StableBar/WidgetSettings.swift @@ -131,10 +131,10 @@ struct WidgetClockFormat { Self(title: "Date + seconds", value: "EEE d MMM HH:mm:ss") ] - var sample: String { + func sample(at date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = value - return formatter.string(from: .now) + return formatter.string(from: date) } } diff --git a/Tests/StableBarTests/FixedDateTests.swift b/Tests/StableBarTests/FixedDateTests.swift new file mode 100644 index 0000000..c9cd9c6 --- /dev/null +++ b/Tests/StableBarTests/FixedDateTests.swift @@ -0,0 +1,102 @@ +import AppKit +import SwiftUI +import Testing +@testable import StableBar + +/// Thursday 22 October 2026, 09:41 UTC: the time the docs images and scenes draw. +private let fixed = Date(timeIntervalSince1970: 1_792_662_060) +private let display = BarDisplay(id: 1, isBuiltIn: false, isMain: true, notch: nil) + +/// The largest difference of one color channel between two drawings. A process draws some antialiased +/// pixels one step (1/255) differently between two drawings of the same content (see DefaultLayoutTests), +/// so drawings that match differ by at most 1. +private func difference(_ first: [UInt8], _ second: [UInt8]) -> Int { + guard first.count == second.count else { return 255 } + return zip(first, second).reduce(0) { max($0, abs(Int($1.0) - Int($1.1))) } +} + +/// `content` drawn at 2× as 8-bit sRGB bytes. +@MainActor private func pixels(_ content: some View) throws -> [UInt8] { + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + let image = try #require(renderer.cgImage) + let context = try #require(CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let data = try #require(context.data) + return Array(UnsafeRawBufferPointer(start: data, count: image.width * image.height * 4)) +} + +@Test @MainActor func barClockDrawsTheFixedDate() throws { + let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"clock","type":"clock","position":"right"}]}"#) + defer { fixture.close() } + let store = try fixture.store() + func bar(_ date: Date) throws -> [UInt8] { + try pixels(BarView(store: store, display: display).frame(width: 600, height: 40).environment(\.fixedDate, date)) + } + let first = try bar(fixed) + #expect(try difference(bar(fixed.addingTimeInterval(3_600)), first) > 1) + #expect(try difference(bar(fixed), first) <= 1) +} + +@Test @MainActor func clockCalendarDrawsTheFixedDate() throws { + func calendar(_ date: Date) throws -> [UInt8] { + try pixels(ClockCalendar(timeZone: "UTC", appearance: WidgetAppearance(WidgetStyle(), defaults: WidgetStyle()), openCalendar: {}) + .frame(width: 320) + .environment(\.fixedDate, date)) + } + let first = try calendar(fixed) + #expect(try difference(calendar(fixed.addingTimeInterval(40 * 86_400)), first) > 1) + #expect(try difference(calendar(fixed), first) <= 1) +} + +@Test func clockFormatSamplesUseTheGivenDate() { + let format = WidgetClockFormat.common[4] + #expect(format.value == "EEE d MMM HH:mm") + #expect(format.sample(at: fixed) != format.sample(at: fixed.addingTimeInterval(86_400))) +} + +/// `content` drawn in an offscreen window, where a scroll view lays out its lazy grids, as the bitmap's bytes. +@MainActor private func windowPixels(_ content: some View, size: NSSize) throws -> [UInt8] { + let window = NSWindow(contentRect: CGRect(origin: CGPoint(x: -9000, y: -6000), size: size), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let view = NSHostingView(rootView: content) + view.frame = CGRect(origin: .zero, size: size) + window.contentView = view + window.orderFrontRegardless() + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let data = try #require(bitmap.bitmapData) + return Array(UnsafeBufferPointer(start: data, count: bitmap.bytesPerRow * bitmap.pixelsHigh)) +} + +@Test @MainActor func clockSettingsSamplesDrawTheFixedDate() throws { + // The clock shows only the time, so its own row draws the same a day later: only the format samples, which show + // dates, can tell the two days apart, and they do only if they draw the date the render gives them. + let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"clock","type":"clock","position":"right","format":"HH:mm"}]}"#) + defer { fixture.close() } + let editor = ConfigurationEditor(store: try fixture.store()) + editor.selectedID = "clock" + func settings(_ date: Date) throws -> [UInt8] { + try windowPixels(BarInspector(editor: editor, screen: .laptop, kinds: [.laptop], names: [:], source: { _ in .builtIn }, + useSharedBar: { _ in }, remove: { _ in }, scrollPositions: .constant([:])) + .environment(\.fixedDate, date), size: NSSize(width: 560, height: 1000)) + } + let first = try settings(fixed) + #expect(try difference(settings(fixed.addingTimeInterval(86_400)), first) > 1) + #expect(try difference(settings(fixed), first) <= 1) +} + +@Test @MainActor func shimmerDrawsTheFixedDate() async throws { + func shimmer() throws -> [UInt8] { + try pixels(Text("Loading the answer").font(.system(size: 20)).modifier(Shimmer(active: true)) + .frame(width: 240, height: 40).background(Color.black).environment(\.fixedDate, fixed)) + } + let first = try shimmer() + try await Task.sleep(for: .milliseconds(600)) + #expect(try difference(shimmer(), first) <= 1) +} diff --git a/Tests/StableBarTests/SampleAppTests.swift b/Tests/StableBarTests/SampleAppTests.swift new file mode 100644 index 0000000..877c981 --- /dev/null +++ b/Tests/StableBarTests/SampleAppTests.swift @@ -0,0 +1,54 @@ +import AppKit +import os +import SwiftUI +import Testing +@testable import StableBar + +/// Records requests to `offline-image.invalid` and fails them, so a test can tell which images asked the network. +/// 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 == "offline-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 sampleAppComesFromItsBundle() throws { + let app = SampleApp(bundleIdentifier: "com.apple.calculator") + // The name in the system's language, as Finder shows it: "Calculator" in English, "Calculette" in French. + let url = try #require(NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.calculator")) + #expect(app.localizedName == (FileManager.default.displayName(atPath: url.path) as NSString).deletingPathExtension) + #expect(app.icon != nil) + #expect(app.bundleIdentifier == "com.apple.calculator") +} + +@Test @MainActor func offlineImagesMakeNoRequest() async throws { + URLProtocol.registerClass(RecordingURLProtocol.self) + defer { URLProtocol.unregisterClass(RecordingURLProtocol.self) } + RecordingURLProtocol.reset() + func show(_ path: String, offline: Bool) -> NSWindow { + let window = NSWindow(contentRect: CGRect(x: -9000, y: -6000, width: 120, height: 120), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: WidgetImage(path: path, fit: .fit).frame(width: 100, height: 100) + .environment(\.offline, offline)) + window.orderFrontRegardless() + return window + } + let windows = [show("https://offline-image.invalid/offline.png", offline: true), + show("https://offline-image.invalid/online.png", offline: false)] + defer { windows.forEach { $0.close() } } + // The online image is the control: once its request arrives, both images have loaded. Wait for it by + // polling, since a busy main actor can delay the images' tasks well past any fixed sleep. + for _ in 0..<150 where RecordingURLProtocol.requests.isEmpty { + try await Task.sleep(for: .milliseconds(100)) + } + try await Task.sleep(for: .milliseconds(200)) + #expect(RecordingURLProtocol.requests.map(\.lastPathComponent) == ["online.png"]) +}