diff --git a/AGENTS.md b/AGENTS.md index 0fb6f8f..6f87a3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e3483..7bdb876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Info.plist b/Info.plist index 5c0b899..4a9f4f8 100644 --- a/Info.plist +++ b/Info.plist @@ -13,7 +13,7 @@ NSHumanReadableCopyright Copyright © 2026 Torsten Mahr. Released under the MIT License. OpenWritrDescription - 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. + 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. OpenWritrLicense MIT OpenWritrRepositoryURL diff --git a/README.md b/README.md index e4608da..da116f3 100644 --- a/README.md +++ b/README.md @@ -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)** @@ -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 | @@ -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: @@ -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 diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index 8a13b48..76ba732 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -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 { @@ -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? @@ -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 @@ -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 @@ -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 @@ -332,6 +388,7 @@ final class AppViewModel { didConfigure = true restorePreferences() + applyRestoredAppPresence() refreshAppleIntelligenceAvailability() updateManager.onWillInstall = { [weak self] in @@ -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") } @@ -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() @@ -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 { @@ -1755,6 +1842,22 @@ struct OpenWritrApp: App { .windowResizability(.contentSize) } + private var menuBarInsertion: Binding { + 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 { diff --git a/Sources/OpenWritr/SettingsView.swift b/Sources/OpenWritr/SettingsView.swift index a18a0dd..20f2350 100644 --- a/Sources/OpenWritr/SettingsView.swift +++ b/Sources/OpenWritr/SettingsView.swift @@ -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() @@ -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() } diff --git a/Sources/OpenWritr/UISnapshotRenderer.swift b/Sources/OpenWritr/UISnapshotRenderer.swift index db01438..499910e 100644 --- a/Sources/OpenWritr/UISnapshotRenderer.swift +++ b/Sources/OpenWritr/UISnapshotRenderer.swift @@ -155,6 +155,7 @@ enum UISnapshotRenderer { viewModel.enhancedModel = .luna viewModel.appleIntelligenceAvailability = .available viewModel.launchAtLogin = false + viewModel.appPresence = .menuBarOnly viewModel.debugModeEnabled = false return viewModel } diff --git a/Tests/OpenWritrTests/AppPresenceTests.swift b/Tests/OpenWritrTests/AppPresenceTests.swift new file mode 100644 index 0000000..3adfb4f --- /dev/null +++ b/Tests/OpenWritrTests/AppPresenceTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import OpenWritr + +@Suite("App presence") +@MainActor +struct AppPresenceTests { + @Test func modesExposeExpectedSurfaces() { + #expect(AppPresence.menuBarOnly.showsMenuBar) + #expect(!AppPresence.menuBarOnly.showsDock) + #expect(!AppPresence.dockOnly.showsMenuBar) + #expect(AppPresence.dockOnly.showsDock) + #expect(AppPresence.dockAndMenuBar.showsMenuBar) + #expect(AppPresence.dockAndMenuBar.showsDock) + } + + @Test func invalidStoredValueFallsBackToMenuBar() { + #expect(AppPresence.restored(from: "unknown") == .menuBarOnly) + #expect(AppPresence.restored(from: nil) == .menuBarOnly) + } + + @Test func validStoredValueRestoresMode() { + #expect(AppPresence.restored(from: "dockOnly") == .dockOnly) + #expect(AppPresence.restored(from: "dockAndMenuBar") == .dockAndMenuBar) + } + + @Test func changingPresenceAppliesAndPersistsMode() { + let dependencies = makeDependencies() + defer { dependencies.defaults.removePersistentDomain(forName: dependencies.suiteName) } + let viewModel = dependencies.makeViewModel() + + viewModel.setAppPresence(.dockAndMenuBar) + + #expect(viewModel.appPresence == .dockAndMenuBar) + #expect(dependencies.controller.appliedModes == [.dockAndMenuBar]) + #expect(dependencies.controller.activationRequests == [true]) + #expect(dependencies.defaults.string(forKey: "appPresence") == "dockAndMenuBar") + } + + @Test func restoringDockModeDoesNotActivateApp() { + let dependencies = makeDependencies() + defer { dependencies.defaults.removePersistentDomain(forName: dependencies.suiteName) } + dependencies.defaults.set("dockOnly", forKey: "appPresence") + let viewModel = dependencies.makeViewModel() + + viewModel.applyRestoredAppPresence() + + #expect(viewModel.appPresence == .dockOnly) + #expect(dependencies.controller.appliedModes == [.dockOnly]) + #expect(dependencies.controller.activationRequests == [false]) + } + + @Test func failedPresenceChangeKeepsReachableMode() { + let dependencies = makeDependencies() + defer { dependencies.defaults.removePersistentDomain(forName: dependencies.suiteName) } + dependencies.controller.shouldSucceed = false + let viewModel = dependencies.makeViewModel() + + viewModel.setAppPresence(.dockOnly) + + #expect(viewModel.appPresence == .menuBarOnly) + #expect(dependencies.defaults.string(forKey: "appPresence") == nil) + #expect(dependencies.errorLogger.messages == [ + "Failed to apply app presence mode dockOnly", + ]) + } + + private func makeDependencies() -> PresenceDependencies { + let suiteName = "AppPresenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return PresenceDependencies( + suiteName: suiteName, + defaults: defaults, + controller: RecordingPresenceController(), + errorLogger: PresenceErrorLogger() + ) + } +} + +@MainActor +private struct PresenceDependencies { + let suiteName: String + let defaults: UserDefaults + let controller: RecordingPresenceController + let errorLogger: PresenceErrorLogger + + func makeViewModel() -> AppViewModel { + AppViewModel( + errorLogger: errorLogger, + applicationPresenceController: controller, + appPresenceDefaults: defaults + ) + } +} + +@MainActor +private final class RecordingPresenceController: ApplicationPresenceControlling { + var shouldSucceed = true + private(set) var appliedModes: [AppPresence] = [] + private(set) var activationRequests: [Bool] = [] + + func apply(_ presence: AppPresence, activate: Bool) -> Bool { + appliedModes.append(presence) + activationRequests.append(activate) + return shouldSucceed + } +} + +@MainActor +private final class PresenceErrorLogger: ErrorLogging { + private(set) var messages: [String] = [] + + func logError(_ message: String) { + messages.append(message) + } +} diff --git a/docs/accessibility.md b/docs/accessibility.md index 7062528..966f329 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -10,9 +10,12 @@ session or an audit. The Settings window's tree was read with System Events. Every toggle (`Auto-Paste`, `Sound Effects`, `Enhanced Mode`, `Always Enhance Recordings`, `Launch at Login`, `Debug Mode`, `Automatically Check for Updates`) is exposed as a -switch with a name, and both pop-up buttons (`Input Device`, `Push-to-Talk Key`, -`Provider`) are exposed as pop-up buttons with a name. The menu bar menu is a -standard `NSMenu`; every item has a title and is enabled or disabled correctly. +switch with a name, and the pop-up buttons (`Input Device`, `Push-to-Talk Key`, +`Provider`) are exposed with names. The later `Show OpenWritr In` control is also +a labelled standard SwiftUI `Picker` and is covered by deterministic Settings +snapshots, but it has not received a separate running accessibility-tree read. +The menu bar menu is a standard `NSMenu` when that presence mode is enabled; +every item has a title and is enabled or disabled correctly. The model picker for OpenAI-compatible providers had an empty label with the visible label hidden, so it had no name; it now carries the accessibility label `OpenAI-compatible model`. Two push buttons (`Edit` and `Check for Updates Now…`) had no title through System Events, although the source gives each a text title diff --git a/docs/index.html b/docs/index.html index 428c97b..58bba8e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -694,7 +694,7 @@

Your voice.
Instant text.

- Native macOS menu bar app for anyone who dictates on a Mac: it turns speech into text in under a second. + Native macOS push-to-talk app for anyone who dictates on a Mac: it turns speech into text in under a second and can live in the menu bar, Dock, or both. Push to talk, release to paste. Local transcription with optional cleanup via Apple Intelligence, GitHub Copilot, or an OpenAI-compatible API.

diff --git a/docs/self-assessment.md b/docs/self-assessment.md index ad73efc..9b16938 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -48,7 +48,7 @@ Automation Availability does not apply: hosted runners are available and used. | ID | Result | Evidence | |---|---|---| -| B01 | pass | Description says what it is: native macOS menu bar app for push-to-talk voice-to-text, local Parakeet transcription, optional cleanup. | +| B01 | pass | Description says what it is: native macOS push-to-talk voice-to-text app with configurable menu bar and Dock presence, local Parakeet transcription, optional cleanup. | | B02 | pass | README states purpose, status ("actively maintained"), install and usage, and links (site, download, changelog, licence). Audience is stated through purpose and the Requirements section (macOS 14+, Apple Silicon). | | B03 | pass | `LICENSE` is MIT, detected by GitHub. | | B04 | pass | `.gitignore` covers `.build/`, `.swiftpm/`, `DerivedData/`, `dist/`, `.artifacts/`, disk images, and Xcode output. OpenWritr contains no Apple credential setup script, release environment, certificate import, or notarization secret surface; those exist only in the public broker. The committed badge SVG is generated output the record documents. |