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
19 changes: 13 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ here instead of restating it.

## What this repository is

OpenWritr is a macOS menu bar app (`LSUIElement`) for push-to-talk voice-to-text,
built with Swift Package Manager for macOS 14+ on Apple Silicon. End users install
the signed, notarized DMG/ZIP from GitHub Releases and receive updates in place
through the app itself, so a bad release reaches every installed copy within about
a day. Changes to `UpdateManager`, the broker profile/publication handoff, or signing can strand
users on an old version.
OpenWritr is a macOS push-to-talk voice-to-text app, built with Swift Package
Manager for macOS 14+ on Apple Silicon. It launches as an `LSUIElement` menu bar
utility, and a persisted Presence setting can switch it at runtime to Dock only
or Dock plus menu bar. End users install the signed, notarized DMG/ZIP from GitHub
Releases and receive updates in place through the app itself, so a bad release
reaches every installed copy within about a day. Changes to `UpdateManager`, the
broker profile/publication handoff, or signing can strand users on an old version.

## What this repository is not

Expand Down Expand Up @@ -59,6 +60,12 @@ Concurrency model: `AppViewModel` is `@MainActor`. `AudioEngine` is `@unchecked

Preferences live in `UserDefaults` (no separate plist). Custom cleanup prompts use a versioned per-provider/model store so bundled tuned defaults can change without overwriting user text.

`AppPresence` is owned by `AppViewModel`. In the running app,
`SystemApplicationPresenceController` is the only type that changes
`NSApplication.ActivationPolicy`; failed changes leave the previous reachable
mode active. `UISnapshotRenderer` separately uses `.prohibited` for its
headless command-line rendering mode.

`scripts/build-app.sh` is a local diagnostic build. It signs with a Developer ID Application or Apple Development certificate found in the local keychain (or named in `OPENWRITR_SIGNING_IDENTITY`) and exits with an error if there is none; it creates no certificate. Ad-hoc signatures are refused, because macOS would reset the app's permissions. Distributable builds do not use this script or any OpenWritr workflow: they are assembled, signed, notarized, and packaged by `trsdn/macos-notarization-broker` profile `openwritr`.

## Enhanced Mode
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ broker publication handoff fails when the section for a tag is missing or empty.

## [1.6.6] — 2026-09-22

### Added
- A Presence setting for choosing menu bar only, Dock only, or both, with changes applied immediately and restored at launch

### Fixed
- Auto-paste cancellation now shows a recoverable error instead of success when OpenWritr cannot preserve the existing clipboard

Expand Down
2 changes: 1 addition & 1 deletion Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Torsten Mahr. Released under the MIT License.</string>
<key>OpenWritrDescription</key>
<string>Native macOS menu bar app for push-to-talk voice-to-text. Transcription runs locally on the Apple Neural Engine; optional cleanup via Apple Intelligence, GitHub Copilot, or an OpenAI-compatible API.</string>
<string>Native macOS push-to-talk voice-to-text app with configurable menu bar and Dock presence. Transcription runs locally on the Apple Neural Engine; optional cleanup via Apple Intelligence, GitHub Copilot, or an OpenAI-compatible API.</string>
<key>OpenWritrLicense</key>
<string>MIT</string>
<key>OpenWritrRepositoryURL</key>
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Release](https://img.shields.io/github/v/release/trsdn/OpenWritr)](https://github.com/trsdn/OpenWritr/releases/latest)
[![Conformance](.github/badges/conformance.svg)](.github/conformance.yml)

**Status: actively maintained.** Native macOS menu bar app for push-to-talk voice-to-text. Core transcription runs locally on the Apple Neural Engine; optional enhancement can use Apple Intelligence, GitHub Copilot, or any OpenAI-compatible API.
**Status: actively maintained.** Native macOS push-to-talk voice-to-text app. Core transcription runs locally on the Apple Neural Engine; optional enhancement can use Apple Intelligence, GitHub Copilot, or any OpenAI-compatible API.

**[Website](https://trsdn.github.io/OpenWritr/)** · **[Download](https://github.com/trsdn/OpenWritr/releases/latest)** · **[Changelog](CHANGELOG.md)**

Expand All @@ -22,6 +22,8 @@

The bottom-center recording indicator uses a live, voice-reactive waveform. Listening, transcription, enhancement, completion, and error states share the same compact borderless design.

OpenWritr starts as a menu bar utility. In **Settings → App → Show OpenWritr In**, you can instead show it only in the Dock or in both the Dock and menu bar. Dock modes also make OpenWritr available in Command-Tab; use the OpenWritr app menu or `Command-,` to reopen Settings.

## Performance

| Metric | Value |
Expand Down Expand Up @@ -147,7 +149,7 @@ OpenWritr checks `trsdn/OpenWritr` GitHub Releases for newer, Developer ID-signe

## Accessibility

OpenWritr is a menu bar utility driven by a held hotkey (Fn/Globe or Right Shift). Settings and the menu use standard SwiftUI controls, which expose names and roles to VoiceOver; the recording overlay carries an accessibility label describing its state. Text uses system fonts and colours.
OpenWritr is driven by a held hotkey (Fn/Globe or Right Shift) and can appear in the menu bar, Dock, or both. Settings and the menu use standard SwiftUI controls, which expose names and roles to VoiceOver; the recording overlay carries an accessibility label describing its state. Text uses system fonts and colours.

Known limitations, stated rather than left to be discovered:

Expand Down Expand Up @@ -217,7 +219,7 @@ Generated daily by [`stats.yml`](.github/workflows/stats.yml) and committed to t

```
Sources/OpenWritr/
├── OpenWritrApp.swift # App entry, MenuBarExtra, state machine
├── OpenWritrApp.swift # App entry, presence modes, state machine
├── MenuBarView.swift # Menu bar dropdown UI
├── SettingsView.swift # Dedicated settings window
├── AudioEngine.swift # AVAudioEngine, 16kHz capture, realtime-safe
Expand Down
111 changes: 107 additions & 4 deletions Sources/OpenWritr/OpenWritrApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,51 @@ enum AppState: Sendable {
}
}

enum AppPresence: String, CaseIterable, Identifiable, Sendable {
case menuBarOnly
case dockOnly
case dockAndMenuBar

var id: String { rawValue }

var label: String {
switch self {
case .menuBarOnly: return "Menu Bar Only"
case .dockOnly: return "Dock Only"
case .dockAndMenuBar: return "Dock and Menu Bar"
}
}

var showsMenuBar: Bool {
self != .dockOnly
}

var showsDock: Bool {
self != .menuBarOnly
}

static func restored(from rawValue: String?) -> AppPresence {
rawValue.flatMap(AppPresence.init(rawValue:)) ?? .menuBarOnly
}
}

@MainActor
protocol ApplicationPresenceControlling {
func apply(_ presence: AppPresence, activate: Bool) -> Bool
}

@MainActor
final class SystemApplicationPresenceController: ApplicationPresenceControlling {
func apply(_ presence: AppPresence, activate: Bool) -> Bool {
let policy: NSApplication.ActivationPolicy = presence.showsDock ? .regular : .accessory
guard NSApplication.shared.setActivationPolicy(policy) else { return false }
if activate, presence.showsDock {
NSApplication.shared.activate(ignoringOtherApps: true)
}
return true
}
}

@MainActor
@Observable
final class AppViewModel {
Expand Down Expand Up @@ -66,6 +111,7 @@ final class AppViewModel {
var soundEnabled: Bool = true
var autoPasteEnabled: Bool = true
var launchAtLogin: Bool = false
var appPresence: AppPresence
var hotkeyChoice: HotkeyChoice = .fn
var availableInputDevices: [AudioInputDevice] = []
var selectedInputDeviceID: AudioDeviceID?
Expand Down Expand Up @@ -98,6 +144,8 @@ final class AppViewModel {
private let doneDisplayDuration: Duration
private let transientErrorDisplayDuration: Duration
private let errorLogger: any ErrorLogging
private let applicationPresenceController: any ApplicationPresenceControlling
private let appPresenceDefaults: UserDefaults
private var didConfigure = false
private var didAttemptInitialSetup = false
private var isInitializing = false
Expand Down Expand Up @@ -129,8 +177,14 @@ final class AppViewModel {
errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "AppViewModel"),
startsOperational: Bool = false,
doneDisplayDuration: Duration = AppViewModel.defaultDoneDisplayDuration,
transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration
transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration,
applicationPresenceController: any ApplicationPresenceControlling =
SystemApplicationPresenceController(),
appPresenceDefaults: UserDefaults = .standard
) {
appPresence = AppPresence.restored(
from: appPresenceDefaults.string(forKey: "appPresence")
)
injectedAudioEngine = audioEngine
self.transcriptionManager = transcriptionManager
self.grammarEnhancer = grammarEnhancer
Expand All @@ -139,6 +193,8 @@ final class AppViewModel {
self.errorLogger = errorLogger
self.doneDisplayDuration = doneDisplayDuration
self.transientErrorDisplayDuration = transientErrorDisplayDuration
self.applicationPresenceController = applicationPresenceController
self.appPresenceDefaults = appPresenceDefaults
if startsOperational {
configureAudioCallbacks()
isOperational = true
Expand Down Expand Up @@ -332,6 +388,7 @@ final class AppViewModel {
didConfigure = true

restorePreferences()
applyRestoredAppPresence()
refreshAppleIntelligenceAvailability()

updateManager.onWillInstall = { [weak self] in
Expand Down Expand Up @@ -391,6 +448,9 @@ final class AppViewModel {

private func restorePreferences() {
let defaults = UserDefaults.standard
appPresence = AppPresence.restored(
from: appPresenceDefaults.string(forKey: "appPresence")
)
if defaults.object(forKey: "soundEnabled") != nil {
soundEnabled = defaults.bool(forKey: "soundEnabled")
}
Expand Down Expand Up @@ -665,6 +725,26 @@ final class AppViewModel {
UserDefaults.standard.set(value, forKey: key)
}

func setAppPresence(_ presence: AppPresence) {
guard presence != appPresence else { return }
guard applicationPresenceController.apply(presence, activate: true) else {
errorLogger.logError("Failed to apply app presence mode \(presence.rawValue)")
return
}
appPresence = presence
appPresenceDefaults.set(presence.rawValue, forKey: "appPresence")
}

func applyRestoredAppPresence() {
guard applicationPresenceController.apply(appPresence, activate: false) else {
errorLogger.logError("Failed to restore app presence mode \(appPresence.rawValue)")
appPresence = .menuBarOnly
appPresenceDefaults.set(AppPresence.menuBarOnly.rawValue, forKey: "appPresence")
_ = applicationPresenceController.apply(.menuBarOnly, activate: false)
return
}
}

func setEnhancedProvider(_ provider: EnhancedProvider) {
if provider == .appleIntelligence {
refreshAppleIntelligenceAvailability()
Expand Down Expand Up @@ -1735,14 +1815,21 @@ final class AppViewModel {
}

struct OpenWritrApp: App {
@State private var viewModel = AppViewModel()
@State private var viewModel: AppViewModel

init() {
let viewModel = AppViewModel()
_viewModel = State(initialValue: viewModel)
Task { @MainActor in
await viewModel.setup()
}
}

var body: some Scene {
MenuBarExtra {
MenuBarExtra(isInserted: menuBarInsertion) {
MenuBarView(viewModel: viewModel)
} label: {
menuBarIcon
.task { await viewModel.setup() }
}

Settings {
Expand All @@ -1755,6 +1842,22 @@ struct OpenWritrApp: App {
.windowResizability(.contentSize)
}

private var menuBarInsertion: Binding<Bool> {
Binding(
get: { viewModel.appPresence.showsMenuBar },
set: { isInserted in
guard isInserted != viewModel.appPresence.showsMenuBar else { return }
if isInserted {
viewModel.setAppPresence(
viewModel.appPresence.showsDock ? .dockAndMenuBar : .menuBarOnly
)
} else if viewModel.appPresence.showsDock {
viewModel.setAppPresence(.dockOnly)
}
}
)
}

private var menuBarIcon: some View {
Group {
switch viewModel.state {
Expand Down
23 changes: 21 additions & 2 deletions Sources/OpenWritr/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ struct SettingsSnapshotConfiguration {

/// Keeps the hosting window above other windows, including the floating
/// recording overlay, and brings it to the front whenever it is shown.
/// An `LSUIElement` app is never active on its own, so it also activates the app
/// here rather than in a tap gesture, which keyboard activation would skip.
/// In menu-bar-only mode the app is not active on its own, so this also
/// activates it here rather than in a tap gesture, which keyboard activation
/// would skip.
private struct KeepWindowOnTop: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
Expand Down Expand Up @@ -288,6 +289,24 @@ struct SettingsView: View {
}

Section("App") {
Picker("Show OpenWritr In", selection: Binding(
get: { viewModel.appPresence },
set: { viewModel.setAppPresence($0) }
)) {
ForEach(AppPresence.allCases) { presence in
Text(presence.label).tag(presence)
}
}

Text(
viewModel.appPresence.showsDock
? "Dock modes also show OpenWritr in Command-Tab. Use the OpenWritr app menu or Command-Comma to reopen Settings."
: "OpenWritr stays in the menu bar without appearing in the Dock or Command-Tab."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

Toggle("Launch at Login", isOn: Binding(
get: { viewModel.launchAtLogin },
set: { _ in viewModel.toggleLaunchAtLogin() }
Expand Down
1 change: 1 addition & 0 deletions Sources/OpenWritr/UISnapshotRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ enum UISnapshotRenderer {
viewModel.enhancedModel = .luna
viewModel.appleIntelligenceAvailability = .available
viewModel.launchAtLogin = false
viewModel.appPresence = .menuBarOnly
viewModel.debugModeEnabled = false
return viewModel
}
Expand Down
Loading
Loading