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. 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 { 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() {