From 8e74272930f093d09de3b1b43cddabae2bf1c56d Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Tue, 22 Sep 2026 20:55:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Settings=20=E2=86=92=20Presence:=20Do?= =?UTF-8?q?ck=20icon,=20menu=20bar,=20both=20or=20neither=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppPresence (OpenPromptrCore) stores the choice as two independent facts — shows a Dock icon, shows a menu bar item — persisted in AppSettings, default Dock and menu bar (today's fixed behavior, unchanged for existing users). AppDelegate observes AppModel.$presence and applies it: activation policy (.regular/.accessory) and creating/discarding AppStatusItemController, which now removes its own status item on deinit. The existing applicationShouldHandleReopen already reopens the control window, which is what makes 'neither' recoverable without a Dock icon or menu bar item. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EGjbE4ZXFZyXFd2GQ7xRAp --- CHANGELOG.md | 7 +++ README.md | 19 +++++++ Sources/OpenPromptr/AppModel.swift | 12 +++++ Sources/OpenPromptr/OpenPromptrApp.swift | 56 ++++++++++++++++++-- Sources/OpenPromptr/SettingsView.swift | 34 ++++++++++++ Sources/OpenPromptrCore/Models.swift | 58 ++++++++++++++++++++- Tests/OpenPromptrCoreTests/ModelTests.swift | 41 +++++++++++++++ 7 files changed, 221 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 081e6e8..81012a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Settings → Presence**: choose whether OpenPromptr shows a Dock icon, a menu + bar item, both (the previous fixed behavior, still the default), or neither. + With neither, opening the app again while it's running brings back the + control window. (#32) + ## [1.3.2] - 2026-09-21 ### Added diff --git a/README.md b/README.md index ef1a0ca..69bc97c 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,25 @@ exists (tracked in finds nothing to install. See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for how a release is actually cut and published. +## Presence + +**Settings → Presence** controls how OpenPromptr shows up while it's running: + +| Choice | Dock icon | Menu bar item | +| --- | --- | --- | +| Dock and menu bar (default) | ✓ | ✓ | +| Dock icon only | ✓ | — | +| Menu bar only | — | ✓ | +| Background only | — | — | + +None of the four affects whether output can be started or stopped — that's +always available from the control window, ⌘. in the Output menu while a window +is frontmost, or the [local HTTP API](#local-http-api). With no Dock icon and +no menu bar item, opening the app again while it's already running (double- +clicking it in Finder, Spotlight, or `open`) brings the control window back; +that's also true for the other three choices, in addition to the Dock icon or +menu bar item. + ## Local HTTP API For control from outside the app — a script, a Stream Deck plugin (see diff --git a/Sources/OpenPromptr/AppModel.swift b/Sources/OpenPromptr/AppModel.swift index 639a3ad..8e68324 100644 --- a/Sources/OpenPromptr/AppModel.swift +++ b/Sources/OpenPromptr/AppModel.swift @@ -90,6 +90,7 @@ final class AppModel: ObservableObject { @Published private(set) var autoStartOutput: Bool @Published private(set) var autoResumeOutput: Bool @Published private(set) var enableLocalAPI: Bool + @Published private(set) var presence: AppPresence @Published private(set) var isRunning = false @Published private(set) var isBusy = false @Published private(set) var isRefreshingWindows = false @@ -154,6 +155,7 @@ final class AppModel: ObservableObject { autoStartOutput = loaded.autoStartOutput autoResumeOutput = loaded.autoResumeOutput enableLocalAPI = loaded.enableLocalAPI + presence = loaded.presence let configuration = loaded.configuration workingSource = configuration.source @@ -502,6 +504,16 @@ final class AppModel: ObservableObject { /// See issue #4: a loopback-only HTTP API so an external tool (a script, /// a Stream Deck plugin) can start/stop output and read status without /// going through the menu bar. + /// Whether the app shows a Dock icon, a menu bar status item, both or + /// neither. `AppDelegate` observes this and applies it — an AppKit + /// lifecycle concern that needs the window/update-manager references it + /// already owns, not something `AppModel` can act on itself. + func setPresence(_ newValue: AppPresence) { + presence = newValue + settings.presence = newValue + persistSettings() + } + func setEnableLocalAPI(_ enabled: Bool) { enableLocalAPI = enabled settings.enableLocalAPI = enabled diff --git a/Sources/OpenPromptr/OpenPromptrApp.swift b/Sources/OpenPromptr/OpenPromptrApp.swift index d35e24b..ff85eef 100644 --- a/Sources/OpenPromptr/OpenPromptrApp.swift +++ b/Sources/OpenPromptr/OpenPromptrApp.swift @@ -1,4 +1,6 @@ import AppKit +import Combine +import OpenPromptrCore import SwiftUI @MainActor @@ -225,14 +227,30 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { private func quit() { NSApplication.shared.terminate(nil) } + + deinit { + // Explicit removal rather than relying on dealloc: this instance is + // discarded whenever presence changes to a mode without a menu bar + // item, and a status item left behind would sit in the menu bar with + // nothing behind it. The type is @MainActor, but `deinit` itself is + // not implicitly isolated. + MainActor.assumeIsolated { + NSStatusBar.system.removeStatusItem(statusItem) + } + } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { weak var model: AppModel? + private weak var updates: UpdateManager? private var terminationPending = false - private let statusItemController = AppStatusItemController() + /// Present only while the current presence includes a menu bar item; + /// recreated whenever that becomes true again, since a removed + /// `NSStatusItem` cannot be brought back. + private var statusItemController: AppStatusItemController? private var showControlsHandler: (() -> Void)? + private var presenceSubscription: AnyCancellable? func configure( model: AppModel, @@ -240,12 +258,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { showControls: @escaping () -> Void ) { self.model = model + self.updates = updates showControlsHandler = showControls - statusItemController.configure( - model: model, - updates: updates, - showControls: showControls + // Fires immediately with the current value, so the persisted choice + // takes effect as soon as the model exists, then again on every + // change made in Settings. + presenceSubscription = model.$presence.sink { [weak self] presence in + self?.apply(presence) + } + } + + private func apply(_ presence: AppPresence) { + NSApplication.shared.setActivationPolicy( + presence.showsDockIcon ? .regular : .accessory ) + + if presence.showsMenuBarItem { + guard statusItemController == nil else { + return + } + let controller = AppStatusItemController() + if let model, let updates, let showControlsHandler { + controller.configure( + model: model, + updates: updates, + showControls: showControlsHandler + ) + } + statusItemController = controller + } else { + statusItemController = nil + } } func showControls() { @@ -253,6 +296,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + // Corrected to the persisted choice moments later, once `configure` + // runs with the model; a regular app with no Dock icon flash yet is + // the safer default for that brief window than the reverse. NSApplication.shared.setActivationPolicy(.regular) NSApplication.shared.activate(ignoringOtherApps: true) } diff --git a/Sources/OpenPromptr/SettingsView.swift b/Sources/OpenPromptr/SettingsView.swift index c406a35..9522b77 100644 --- a/Sources/OpenPromptr/SettingsView.swift +++ b/Sources/OpenPromptr/SettingsView.swift @@ -1,3 +1,4 @@ +import OpenPromptrCore import SwiftUI /// Preferences window (⌘,): everything that is set once and rarely touched, @@ -7,6 +8,7 @@ struct SettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { + presenceSection startupSection remoteControlSection } @@ -14,6 +16,38 @@ struct SettingsView: View { .frame(width: 520) } + private var presenceSection: some View { + ControlSection(title: "Presence", systemImage: "menubar.dock.rectangle") { + VStack(alignment: .leading, spacing: 6) { + Picker( + "Presence", + selection: Binding( + get: { model.presence }, + set: { model.setPresence($0) } + ) + ) { + ForEach(AppPresence.allCases, id: \.rawValue) { presence in + Text(presence.localizedName).tag(presence) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Text(model.presence.localizedExplanation) + .font(.caption2) + .foregroundStyle(.secondary) + + if !model.presence.showsMenuBarItem { + Text( + "Output still starts and stops from the control window or the local HTTP API while hidden this way." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + private var startupSection: some View { ControlSection(title: "Startup and recovery", systemImage: "power") { VStack(alignment: .leading, spacing: 6) { diff --git a/Sources/OpenPromptrCore/Models.swift b/Sources/OpenPromptrCore/Models.swift index 641c569..88a1219 100644 --- a/Sources/OpenPromptrCore/Models.swift +++ b/Sources/OpenPromptrCore/Models.swift @@ -576,6 +576,52 @@ private struct LegacyPresetSlot: Codable { var configuration: TeleprompterConfiguration? } +/// How the app presents itself while running: a Dock icon, a menu bar status +/// item, both, or neither. Two independent facts (Dock icon shown, menu bar +/// item shown) rather than four unrelated ones, but named as one setting +/// because that is how a person chooses it. +public enum AppPresence: String, Codable, CaseIterable, Sendable { + case dockAndMenuBar + case dockOnly + case menuBarOnly + case backgroundOnly + + public var showsDockIcon: Bool { + self == .dockAndMenuBar || self == .dockOnly + } + + public var showsMenuBarItem: Bool { + self == .dockAndMenuBar || self == .menuBarOnly + } + + public var localizedName: String { + switch self { + case .dockAndMenuBar: + return "Dock and menu bar" + case .dockOnly: + return "Dock icon only" + case .menuBarOnly: + return "Menu bar only" + case .backgroundOnly: + return "Background only" + } + } + + public var localizedExplanation: String { + switch self { + case .dockAndMenuBar: + return "Shown in the Dock and in the menu bar." + case .dockOnly: + return "Shown in the Dock; reach Settings from its Dock menu or app menu." + case .menuBarOnly: + return "No Dock icon; controlled from the menu bar." + case .backgroundOnly: + return + "No Dock icon and no menu bar icon. Open the app again while it's running to bring up its window." + } + } +} + public struct AppSettings: Codable, Equatable, Sendable { public static let currentSchemaVersion = 2 @@ -584,19 +630,22 @@ public struct AppSettings: Codable, Equatable, Sendable { public var autoStartOutput: Bool public var autoResumeOutput: Bool public var enableLocalAPI: Bool + public var presence: AppPresence public init( schemaVersion: Int = currentSchemaVersion, configuration: TeleprompterConfiguration = .init(), autoStartOutput: Bool = false, autoResumeOutput: Bool = false, - enableLocalAPI: Bool = false + enableLocalAPI: Bool = false, + presence: AppPresence = .dockAndMenuBar ) { self.schemaVersion = schemaVersion self.configuration = configuration self.autoStartOutput = autoStartOutput self.autoResumeOutput = autoResumeOutput self.enableLocalAPI = enableLocalAPI + self.presence = presence } public static var defaults: AppSettings { @@ -611,6 +660,7 @@ public struct AppSettings: Codable, Equatable, Sendable { case autoStartOutput case autoResumeOutput case enableLocalAPI + case presence } public init(from decoder: Decoder) throws { @@ -635,6 +685,11 @@ public struct AppSettings: Codable, Equatable, Sendable { Bool.self, forKey: .enableLocalAPI ) ?? false + presence = + try container.decodeIfPresent( + AppPresence.self, + forKey: .presence + ) ?? .dockAndMenuBar if let configuration = try container.decodeIfPresent( TeleprompterConfiguration.self, @@ -670,6 +725,7 @@ public struct AppSettings: Codable, Equatable, Sendable { try container.encode(autoStartOutput, forKey: .autoStartOutput) try container.encode(autoResumeOutput, forKey: .autoResumeOutput) try container.encode(enableLocalAPI, forKey: .enableLocalAPI) + try container.encode(presence, forKey: .presence) } public func normalized() -> AppSettings { diff --git a/Tests/OpenPromptrCoreTests/ModelTests.swift b/Tests/OpenPromptrCoreTests/ModelTests.swift index b695e49..1930875 100644 --- a/Tests/OpenPromptrCoreTests/ModelTests.swift +++ b/Tests/OpenPromptrCoreTests/ModelTests.swift @@ -398,6 +398,47 @@ func settingsRecoveryIsIndependent() throws { } } +@Test("Presence defaults to Dock and menu bar, matching the app's old fixed behavior") +func presenceDefaultsToDockAndMenuBar() { + #expect(AppSettings.defaults.presence == .dockAndMenuBar) +} + +@Test("Presence derives which of the Dock icon and menu bar item are shown") +func presenceDerivesVisibility() { + #expect(AppPresence.dockAndMenuBar.showsDockIcon) + #expect(AppPresence.dockAndMenuBar.showsMenuBarItem) + #expect(AppPresence.dockOnly.showsDockIcon) + #expect(!AppPresence.dockOnly.showsMenuBarItem) + #expect(!AppPresence.menuBarOnly.showsDockIcon) + #expect(AppPresence.menuBarOnly.showsMenuBarItem) + #expect(!AppPresence.backgroundOnly.showsDockIcon) + #expect(!AppPresence.backgroundOnly.showsMenuBarItem) +} + +@Test("Every presence survives the persistence codec") +func presenceSurvivesCodec() throws { + for presence in AppPresence.allCases { + let settings = AppSettings(presence: presence) + #expect( + try AppSettingsCodec.decode( + AppSettingsCodec.encode(settings) + ) == settings) + } +} + +@Test("Settings saved before this setting existed default to Dock and menu bar") +func presenceDefaultsForOlderSettings() throws { + let data = Data( + """ + { + "schemaVersion": 2, + "configuration": { "source": { "kind": "virtualDisplay" } } + } + """.utf8) + let decoded = try AppSettingsCodec.decode(data) + #expect(decoded.presence == .dockAndMenuBar) +} + @Test("Window identity resolves only when it is unambiguous") func windowIdentityResolution() { let deck = WindowIdentity( From 2f6089d3e1f7b50e534ffc80573d7c616212a619 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Tue, 22 Sep 2026 21:13:20 +0200 Subject: [PATCH 2/2] Menu bar item: use the app's own icon instead of a generic symbol Scripts/make-icon.swift now also renders Resources/MenuBarIcon.png: the same 'T over a mirror line' mark as the Dock icon, without the reflection (too faint to survive scaling to menu bar size) or the background plate, as a template image so macOS recolors it for light/dark/highlighted. build-app.sh copies it into the bundle; AppStatusItemController loads it with a fallback to the old SF Symbol for a bare 'swift build' executable with no Resources. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EGjbE4ZXFZyXFd2GQ7xRAp --- AGENTS.md | 1 + CHANGELOG.md | 3 + Resources/MenuBarIcon.png | Bin 0 -> 5746 bytes Scripts/make-icon.swift | 97 +++++++++++++++++++++-- Sources/OpenPromptr/OpenPromptrApp.swift | 32 +++++++- build-app.sh | 8 ++ 6 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 Resources/MenuBarIcon.png diff --git a/AGENTS.md b/AGENTS.md index c2bcb70..40c24f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ Do not hand-edit these: | Path | Produced by | Source of truth | | --- | --- | --- | | `Resources/AppIcon.icns` | `swift Scripts/make-icon.swift`, called by `build-app.sh` | `Scripts/make-icon.swift` | +| `Resources/MenuBarIcon.png` | `swift Scripts/make-icon.swift`, called by `build-app.sh` | `Scripts/make-icon.swift` | | `dist/` | `build-app.sh` | — (git-ignored) | | `.build/` | SwiftPM | — (git-ignored) | | `docs/assets/*` (once vendored) | copied from `trsdn/design-system` | `docs/assets/VENDORED.md` — re-vendor from a tag, never hand-edit | diff --git a/CHANGELOG.md b/CHANGELOG.md index 81012a2..180d534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The menu bar item now shows the app's own "T over a mirror line" mark + instead of a generic system symbol, generated by `Scripts/make-icon.swift` + alongside the Dock icon. - **Settings → Presence**: choose whether OpenPromptr shows a Dock icon, a menu bar item, both (the previous fixed behavior, still the default), or neither. With neither, opening the app again while it's running brings back the diff --git a/Resources/MenuBarIcon.png b/Resources/MenuBarIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..99d1526671c550b2f43d4f5846fe534dcca58138 GIT binary patch literal 5746 zcmeHL`Bzg(7EWS9 z=e7j;7|vQS3j%={`fl171c5+hK)Hz219}Knij}|z8W-fV9#Sq^^cHAr+!yM*ABTgi z0P08x^z1hO!Cap$-8s<Y%R>K0trv`fI(tRU3S0=vqvxz>ns5&0hdSsKUKqHQ+7U{ zIv|z^UgiL(QTHJiG0O%+JS(#Y9w`}`A5N^&%y|Tp<^vg(f_`R=TqHN6BkHvppT z>l7~J%u#%kmVI}3UgDYl5Myt7N|$j@h!b(Fe=!(`IlxldM?)7_m8WkO%+9zcZJ2z` z$5(qy+zAK6SJ97Xi5N`y!Gc~|Zeqk0_85*!3#E8MxLQ_e^}!6VfvnFIB(N9J8Luki zJkHczL2XJi9ZiZqJ<4%+2_XbKHCz;GjhDmC0nsbV>{Hi^5+svU=IEF5@>lTs*0-*? z=7}Gj)D3pQDYuoU%s$yv;Nw>xi z?Eu+(06-4c-T}Z#!Q@dWk2|Ul>I01uUyQ(cUGxT!>tQgM^R)75Sf-<8nncrk%k-`| zeOUb8pP`TUB4!7S_h)T#YtWWSqgi}K=KMZGa{y;|V$^67z7o+7q`)n!Gk1p|{s ziR9Y06$FHm;H`~IPkzN(q+CKuv2W|T!+>NZjQ!Nez)V)9=q&Dg?7--gT&~4q1b2=_ zJ8z|W9Y?uQP_u=L%%U^)ne6)t_x zt1BJLwa{A)rY_dpo3IcGr*x*uUU&C_P;urpwXtZYy+YM@0V8U@nB%qOqS?P92GBfR zCX8GapfDsqqT@?Xt1gk-RNqt^nAb4fR22out+e?<_P5dPOW%u>w=1H`e{|o!(4h`Y zIVl*PK%`7a6L+mY(dM{64ouP^)KdKzmPpQa3wBbMSU6;dwFG=OZvqQ>v3!JG5YAerv24+5b>XSxEWxqmkr)KAD5M*Gk1Lu{Ar ze4g^Ieo^OEFkx1_K#WUpQ*5O_exhjdvF%s0G83-U?0!5`MD#c}TNkc(VV{R$tZvpt zWIWNRo|YML-IdpPtUDaHvX9?ydnobi3rtVjZf9BlZNpUJ;PsTU0)1)VQ9c;$9WWcz zFV5^Srwj^zPbtDechk)Kk^AALeFlZl(l_>#E)K5$1T^`Bzb_qo09v*8p>|SFRknT#54o3Hi69{f{?66`N|TF25$(pwGl*g73Y)*uahD>q)2n E0&9wNKmY&$ literal 0 HcmV?d00001 diff --git a/Scripts/make-icon.swift b/Scripts/make-icon.swift index 5f4d7c4..136612d 100644 --- a/Scripts/make-icon.swift +++ b/Scripts/make-icon.swift @@ -138,9 +138,23 @@ private func drawArtwork(in context: CGContext) { } // Mirror line. - let lineWidth = letterHeight * 1.45 context.setFillColor(color(0x8FC2FF, 0.85)) - context.addPath(CGPath( + context.addPath(mirrorLinePath()) + context.fillPath() + + // Upright glyph on top of everything. + context.setFillColor(color(0xFFFFFF)) + context.addPath(upright) + context.fillPath() + + context.restoreGState() +} + +/// The mirror line, as its own path — shared by the full artwork and the menu +/// bar glyph so the two never quietly drift apart. +private func mirrorLinePath() -> CGPath { + let lineWidth = letterHeight * 1.45 + return CGPath( roundedRect: rect( x: (canvas - lineWidth) / 2, top: axis - 5, @@ -150,15 +164,76 @@ private func drawArtwork(in context: CGContext) { cornerWidth: 5, cornerHeight: 5, transform: nil - )) - context.fillPath() + ) +} - // Upright glyph on top of everything. - context.setFillColor(color(0xFFFFFF)) - context.addPath(upright) +/// Just the "T" over the mirror line, solid black, no reflection, no +/// background plate. Menu bar icons are template images — macOS recolors the +/// opaque black for light mode, dark mode and the highlighted state — and are +/// drawn small enough that the reflection's soft fade, legible at 1024pt, +/// would not survive scaling down to menu bar size. Reusing the Dock icon's +/// letter and line at their own proportions is the point: recognizable as the +/// same mark, not a different one that happens to share an app. +private func drawMenuBarGlyph(in context: CGContext) { + context.setShouldAntialias(true) + context.setFillColor(color(0x000000)) + context.addPath(mirrorLinePath()) + context.addPath(letterT(top: letterTop, height: letterHeight)) context.fillPath() +} - context.restoreGState() +/// The glyph's own bounding box, with a little breathing room. Computed from +/// the same paths `drawMenuBarGlyph` fills rather than typed out by hand, so +/// a change to the letter or the layout can't silently drift out of sync with +/// its crop. +private func menuBarGlyphBounds() -> CGRect { + let padding: CGFloat = 40 + return letterT(top: letterTop, height: letterHeight).boundingBoxOfPath + .union(mirrorLinePath().boundingBoxOfPath) + .insetBy(dx: -padding, dy: -padding) +} + +private func renderMenuBarIcon(to url: URL) throws { + guard + let context = CGContext( + data: nil, + width: Int(canvas), + height: Int(canvas), + bitsPerComponent: 8, + bytesPerRow: 0, + space: sRGB, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw CocoaError(.fileWriteUnknown) + } + drawMenuBarGlyph(in: context) + + // `CGPath`/`CGContext` use a bottom-left origin; `CGImage.cropping(to:)` + // uses a top-left one. Converting here, once, is cheaper than getting it + // wrong at every call site. + let bounds = menuBarGlyphBounds() + let topLeftBounds = CGRect( + x: bounds.minX, + y: canvas - bounds.maxY, + width: bounds.width, + height: bounds.height + ) + guard let full = context.makeImage(), + let cropped = full.cropping(to: topLeftBounds), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) + else { + throw CocoaError(.fileWriteUnknown) + } + CGImageDestinationAddImage(destination, cropped, nil) + guard CGImageDestinationFinalize(destination) else { + throw CocoaError(.fileWriteUnknown) + } } private func renderPNG(size: Int, to url: URL) throws { @@ -240,4 +315,10 @@ guard iconutil.terminationStatus == 0 else { } try FileManager.default.removeItem(at: iconset) + +try renderMenuBarIcon( + to: repositoryRoot + .appendingPathComponent("Resources") + .appendingPathComponent("MenuBarIcon.png") +) print("Resources/AppIcon.icns was generated.") diff --git a/Sources/OpenPromptr/OpenPromptrApp.swift b/Sources/OpenPromptr/OpenPromptrApp.swift index ff85eef..c7f389e 100644 --- a/Sources/OpenPromptr/OpenPromptrApp.swift +++ b/Sources/OpenPromptr/OpenPromptrApp.swift @@ -16,6 +16,33 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { private let installUpdateItem: NSMenuItem private let laterUpdateItem: NSMenuItem + /// The Dock icon's own "T over a mirror line" mark, generated alongside + /// it by `Scripts/make-icon.swift` (`Resources/MenuBarIcon.png`) so the + /// menu bar item is recognizably the same app rather than an unrelated + /// SF Symbol. `isTemplate` lets macOS recolor it for light mode, dark + /// mode and the highlighted state. + private static func menuBarIcon() -> NSImage { + guard + let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "png"), + let image = NSImage(contentsOf: url) + else { + // The bare executable from `swift build` (no app bundle, no + // Resources) has nothing to load; the packaged app always does. + let fallback = + NSImage( + systemSymbolName: "rectangle.on.rectangle.angled", + accessibilityDescription: "OpenPromptr" + ) ?? NSImage() + fallback.isTemplate = true + return fallback + } + let aspectRatio = image.size.width / image.size.height + image.size = NSSize(width: 18 * aspectRatio, height: 18) + image.isTemplate = true + image.accessibilityDescription = "OpenPromptr" + return image + } + override init() { statusItem = NSStatusBar.system.statusItem( withLength: NSStatusItem.squareLength @@ -52,10 +79,7 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { ) super.init() - statusItem.button?.image = NSImage( - systemSymbolName: "rectangle.on.rectangle.angled", - accessibilityDescription: "OpenPromptr" - ) + statusItem.button?.image = Self.menuBarIcon() statusItem.button?.toolTip = "OpenPromptr" startItem.target = self diff --git a/build-app.sh b/build-app.sh index 1f86ba4..5e71316 100755 --- a/build-app.sh +++ b/build-app.sh @@ -102,6 +102,14 @@ else "${ICON_FILE}" >&2 fi +MENU_BAR_ICON_FILE="${SCRIPT_DIR}/Resources/MenuBarIcon.png" +if [[ -f "${MENU_BAR_ICON_FILE}" ]]; then + install -m 0644 "${MENU_BAR_ICON_FILE}" "${APP_DIR}/Contents/Resources/MenuBarIcon.png" +else + printf 'Warning: %s is missing. Generate it with: swift Scripts/make-icon.swift\n' \ + "${MENU_BAR_ICON_FILE}" >&2 +fi + plutil -lint "${APP_DIR}/Contents/Info.plist" printf 'Signing app with identity "%s" …\n' "${SIGN_IDENTITY}"