From fc87d70611998b9f155f28d30dd208f18ea970a1 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 23 Sep 2026 16:40:50 -0500 Subject: [PATCH 1/3] Refactor: Extract pure resource-bundle lookup into TimberModel The icon loader is about to stop using the synthesized Bundle.module accessor, so pull the directory search into a testable helper first. Covers the flat bundle layout shipped in v0.1.1, the structured layout, and the missing-bundle case. --- Sources/TimberModel/TimberModel.swift | 19 ++++++ Tests/TimberModelTests/TimberModelTests.swift | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/Sources/TimberModel/TimberModel.swift b/Sources/TimberModel/TimberModel.swift index 3c7fada..ccb0dbf 100644 --- a/Sources/TimberModel/TimberModel.swift +++ b/Sources/TimberModel/TimberModel.swift @@ -235,6 +235,25 @@ public enum TimberModel { "\"" + text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" } + // MARK: - Resource bundle lookup + + /// Locate `.bundle` under the first candidate dir that + /// contains it, without trapping: unlike the synthesized + /// `Bundle.module` accessor (which calls fatalError when the bundle + /// is missing), absence yields nil so callers can degrade gracefully + /// (e.g. fall back to SF Symbols for icons). + public static func resourceBundleURL(bundleName: String, candidateDirs: [URL]) -> URL? { + for dir in candidateDirs { + let url = dir.appendingPathComponent(bundleName + ".bundle", isDirectory: true) + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + if exists, isDirectory.boolValue { + return url + } + } + return nil + } + // MARK: - Child process environment /// PATH handed to `timber` children. GUI apps launch with a minimal diff --git a/Tests/TimberModelTests/TimberModelTests.swift b/Tests/TimberModelTests/TimberModelTests.swift index 11a083b..dc318d6 100644 --- a/Tests/TimberModelTests/TimberModelTests.swift +++ b/Tests/TimberModelTests/TimberModelTests.swift @@ -162,6 +162,67 @@ final class TimberModelTests: XCTestCase { ) } + // MARK: - resource bundle lookup + + /// Fixture mirroring the v0.1.1 installed layout: a flat + /// `.bundle` directory (PNGs at top level, no Info.plist) + /// that the synthesized Bundle.module failed to resolve, trapping + /// the popover on first open. The defensive lookup must find it. + func testResourceBundleURLFindsFlatBundle() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let bundle = root.appendingPathComponent("Timber_Timber.bundle", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: bundle.appendingPathComponent("zed.png").path, contents: Data()) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + + let found = TimberModel.resourceBundleURL(bundleName: "Timber_Timber", candidateDirs: [root]) + XCTAssertEqual(found, bundle) + // And plain Foundation resolution works on what we found. + let loaded = found.flatMap(Bundle.init(url:)) + XCTAssertNotNil(loaded?.url(forResource: "zed", withExtension: "png")) + } + + func testResourceBundleURLFindsStructuredBundle() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let resources = root.appendingPathComponent("Timber_Timber.bundle/Contents/Resources", isDirectory: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: resources.appendingPathComponent("zed.png").path, contents: Data()) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + + let found = TimberModel.resourceBundleURL(bundleName: "Timber_Timber", candidateDirs: [root]) + XCTAssertNotNil(found) + XCTAssertNotNil(found.flatMap(Bundle.init(url:))?.url(forResource: "zed", withExtension: "png")) + } + + func testResourceBundleURLReturnsNilWhenMissing() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + XCTAssertNil(TimberModel.resourceBundleURL(bundleName: "Timber_Timber", candidateDirs: [missing])) + XCTAssertNil(TimberModel.resourceBundleURL(bundleName: "Timber_Timber", candidateDirs: [])) + } + + func testResourceBundleURLPrefersFirstMatch() throws { + let first = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let second = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + for root in [first, second] { + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Timber_Timber.bundle", isDirectory: true), + withIntermediateDirectories: true + ) + } + addTeardownBlock { + try? FileManager.default.removeItem(at: first) + try? FileManager.default.removeItem(at: second) + } + + let found = TimberModel.resourceBundleURL(bundleName: "Timber_Timber", candidateDirs: [first, second]) + XCTAssertEqual(found?.deletingLastPathComponent(), first) + } + // MARK: - child PATH func testChildPATHPrependsMissingDirs() { From 2d642c3b392a0d0ace35001b58a450523fd10351 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 23 Sep 2026 16:41:47 -0500 Subject: [PATCH 2/3] Resolve action icons without Bundle.module's fatalError path The synthesized Bundle.module accessor traps when it cannot find Timber_Timber.bundle, which crashed the popover on first open in v0.1.1 even though the bundle ships in the release zip. Locate it with the tested TimberModel helper instead and fall back to SF Symbols, so a missing resource degrades instead of trapping. --- Sources/Timber/TimberApp.swift | 53 ++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/Sources/Timber/TimberApp.swift b/Sources/Timber/TimberApp.swift index 98ccaac..d08a38b 100644 --- a/Sources/Timber/TimberApp.swift +++ b/Sources/Timber/TimberApp.swift @@ -31,27 +31,62 @@ final class SizingHostingView: NSHostingView { } } -/// Action icons, loaded from the bundled PNGs by explicit URL (proven to -/// resolve) instead of named lookup, with every scale wired so Retina -/// picks the right representation. +/// Action icons, loaded from the bundled PNGs by explicit URL instead of +/// named lookup, with every scale wired so Retina picks the right +/// representation. +/// +/// The bundle is located defensively and never via Bundle.module: its +/// synthesized accessor calls fatalError when the bundle is missing, +/// which crashed the popover on first open in v0.1.1. A missing bundle +/// or missing PNG degrades to an SF Symbol instead of trapping. enum TimberIcons { - static let zed = load("zed") - static let herdr = load("herdr") + static let zed = load("zed", fallbackSystemName: "terminal") + static let herdr = load("herdr", fallbackSystemName: "server.rack") - private static func load(_ name: String) -> NSImage { + private static func load(_ name: String, fallbackSystemName: String) -> NSImage { + if let image = bundledImage(named: name) { + return image + } + return NSImage(systemSymbolName: fallbackSystemName, accessibilityDescription: name) ?? NSImage() + } + + private static func bundledImage(named name: String) -> NSImage? { + let dirs = candidateDirs() + guard let bundleURL = TimberModel.resourceBundleURL(bundleName: "Timber_Timber", candidateDirs: dirs) else { + return nil + } + guard let bundle = Bundle(url: bundleURL) else { + return nil + } let image = NSImage() + var found = false for scale in ["@3x", "@2x", ""] { - guard let url = Bundle.module.url(forResource: "\(name)\(scale)", withExtension: "png"), - let src = NSImage(contentsOf: url) - else { continue } + guard let url = bundle.url(forResource: "\(name)\(scale)", withExtension: "png") else { continue } + guard let src = NSImage(contentsOf: url) else { continue } + found = true for rep in src.representations { rep.size = NSSize(width: 14, height: 14) image.addRepresentation(rep) } } + guard found else { + return nil + } image.size = NSSize(width: 14, height: 14) return image } + + private static func candidateDirs() -> [URL] { + var dirs: [URL] = [] + if let resources = Bundle.main.resourceURL { + dirs.append(resources) + } + dirs.append(Bundle.main.bundleURL) + if let executables = Bundle.main.executableURL?.deletingLastPathComponent() { + dirs.append(executables) + } + return dirs + } } final class AppDelegate: NSObject, NSApplicationDelegate { From 09f0304cfd9c4a5f91b64d37b7b788df07145230 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 23 Sep 2026 16:52:47 -0500 Subject: [PATCH 3/3] Add AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6057ddc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +This project is vibe coded, no commit-by-commit review needed.