Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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
control window. (#32)

## [1.3.2] - 2026-09-21

### Added
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added Resources/MenuBarIcon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 89 additions & 8 deletions Scripts/make-icon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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.")
12 changes: 12 additions & 0 deletions Sources/OpenPromptr/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
88 changes: 79 additions & 9 deletions Sources/OpenPromptr/OpenPromptrApp.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import AppKit
import Combine
import OpenPromptrCore
import SwiftUI

@MainActor
Expand All @@ -14,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
Expand Down Expand Up @@ -50,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
Expand Down Expand Up @@ -225,34 +251,78 @@ 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,
updates: UpdateManager,
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() {
showControlsHandler?()
}

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)
}
Expand Down
34 changes: 34 additions & 0 deletions Sources/OpenPromptr/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import OpenPromptrCore
import SwiftUI

/// Preferences window (⌘,): everything that is set once and rarely touched,
Expand All @@ -7,13 +8,46 @@ struct SettingsView: View {

var body: some View {
VStack(alignment: .leading, spacing: 12) {
presenceSection
startupSection
remoteControlSection
}
.padding(16)
.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) {
Expand Down
Loading
Loading