diff --git a/README.md b/README.md index f7d389d9..d533a562 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,132 @@ defaults delete com.zachlatta.freeflow context_request_timeout_seconds +## Configuring Without the Settings Window + +Every setting FreeFlow exposes in Settings can also be written from a shell, so a machine can be set up from dotfiles, a setup script, or a Nix or Homebrew module instead of by clicking through the UI. + +Settings live in two places: + +- **macOS defaults**, under the `com.zachlatta.freeflow` domain +- **`~/Library/Application Support/FreeFlow/.settings`**, a JSON file holding API credentials, kept at mode `600` + +FreeFlow reads its settings once at launch and does not watch for changes, so **quit FreeFlow before writing and start it afterwards**. Writing while it is running has no effect and can be overwritten. + +```bash +defaults write com.zachlatta.freeflow preserve_exact_wording -bool true +defaults write com.zachlatta.freeflow post_processing_model -string "openai/gpt-oss-120b" +defaults write com.zachlatta.freeflow shortcut_start_delay -float 0.15 +``` + +
+ All available keys + +**Transcription** + +| Key | Type | Notes | +|---|---|---| +| `transcription_model` | string | e.g. `whisper-large-v3-turbo` | +| `transcription_language` | string | empty for auto-detect | +| `output_language` | string | empty to keep the spoken language | +| `selected_microphone_id` | string | empty for the system default | +| `realtime_streaming_enabled` | bool | | +| `realtime_streaming_model` | string | | + +**Cleanup and context** + +| Key | Type | Notes | +|---|---|---| +| `post_processing_model` | string | | +| `post_processing_fallback_model` | string | used when the primary model fails | +| `context_model` | string | model for nearby app context | +| `custom_system_prompt` | string | empty for the built-in prompt | +| `custom_context_prompt` | string | empty for the built-in prompt | +| `custom_vocabulary` | string | newline-separated terms | +| `instruction_execution_guard_enabled` | bool | | +| `preserve_exact_wording` | bool | | +| `context_screenshot_max_dimension` | integer | | + +**Edit Mode** + +| Key | Type | Notes | +|---|---|---| +| `command_mode_enabled` | bool | | +| `command_mode_style` | string | automatic or manual | +| `command_mode_manual_modifier` | string | modifier for manual mode | + +**Behaviour** + +| Key | Type | Notes | +|---|---|---| +| `preserve_clipboard` | bool | restore the clipboard after pasting | +| `keep_dictation_in_clipboard_history` | bool | | +| `press_enter_voice_command_enabled` | bool | | +| `dictation_audio_interruption_enabled` | bool | | +| `shortcut_start_delay` | float | seconds | +| `hotkey_option` | string | | + +**Appearance and sound** + +| Key | Type | Notes | +|---|---|---| +| `show_menu_bar_icon` | bool | | +| `use_compact_overlay` | bool | | +| `overlay_display_id` | integer | `0` for the active display | +| `alert_sounds_enabled` | bool | | +| `sound_volume` | float | `0` to `1` | + +**Timeouts** are documented under [Using a Local Model](#using-a-local-model): `transcription_timeout_seconds`, `post_processing_timeout_seconds` and `context_request_timeout_seconds`. + +
+ +### Shortcuts and voice macros + +These are stored as JSON. Write them as a plain string: + +```bash +defaults write com.zachlatta.freeflow hold_shortcut -string \ + '{"modifiers":0,"kind":"modifierKey","keyCode":63,"keyDisplay":"Fn","preset":"fn"}' + +defaults write com.zachlatta.freeflow voice_macros -string \ + '[{"command":"my address","payload":"221B Baker Street, London"}]' +``` + +The keys are `hold_shortcut`, `toggle_shortcut`, `copy_again_shortcut`, their `saved_*_custom_shortcut` counterparts, and `voice_macros`. + +A voice macro's `id` is generated when you leave it out, so you only need `command` and `payload`. + +The simplest way to find the JSON for a shortcut is to set it once in Settings and read it back. FreeFlow writes these keys as binary data, which `defaults read` prints as hex, so decode it: + +```bash +defaults export com.zachlatta.freeflow - \ + | plutil -extract hold_shortcut raw -o - - \ + | base64 --decode +``` + +A value you wrote yourself with `-string` is stored as text, so plain `defaults read com.zachlatta.freeflow hold_shortcut` shows it as-is. + +### API credentials + +These are not stored in `defaults`. Write `~/Library/Application Support/FreeFlow/.settings` instead, and keep it owner-readable only so your key is not world-readable: + +Create the file with restrictive permissions *before* writing the key to it, so it is never briefly readable by other local users: + +```bash +SETTINGS=~/Library/Application\ Support/FreeFlow/.settings + +mkdir -p "$(dirname "$SETTINGS")" +(umask 077; : > "$SETTINGS") + +cat > "$SETTINGS" <<'JSON' +{ + "groq_api_key": "gsk_...", + "api_base_url": "https://api.groq.com/openai/v1" +} +JSON +``` + +The recognised keys are `groq_api_key`, `api_base_url`, `transcription_api_url` and `transcription_api_key`. Omit any you do not need. + ## License Licensed under the MIT license. diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 0626d972..6be3e845 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -13,6 +13,25 @@ struct VoiceMacro: Codable, Identifiable, Equatable { var id: UUID = UUID() var command: String var payload: String + + init(id: UUID = UUID(), command: String, payload: String) { + self.id = id + self.command = command + self.payload = payload + } + + /// Generates `id` when it is absent, so macros can be written by hand. + /// + /// The default value above does not help here: Swift's synthesized decoder + /// ignores it and fails with `keyNotFound` on a missing `id`, which would + /// silently leave a hand-written macro list empty. Encoding still writes + /// `id`, so round-tripping through the app is unchanged. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + command = try container.decode(String.self, forKey: .command) + payload = try container.decode(String.self, forKey: .payload) + } } struct PrecomputedMacro { @@ -704,7 +723,7 @@ final class AppState: ObservableObject, @unchecked Sendable { : soundVolume > 0 let initialMacros: [VoiceMacro] - if let data = UserDefaults.standard.data(forKey: "voice_macros"), + if let data = AppState.jsonData(forKey: "voice_macros"), let decoded = try? JSONDecoder().decode([VoiceMacro].self, from: data) { initialMacros = decoded } else { @@ -924,8 +943,23 @@ final class AppState: ObservableObject, @unchecked Sendable { ) } + /// Reads a setting the app stores as JSON-encoded `Data`, falling back to a + /// plain JSON string under the same key. + /// + /// The fallback exists so these settings can be scripted. `defaults write` + /// cannot produce a `Data` value without hex-encoding the payload first, so + /// without this the shortcut and macro keys are impractical to set from a + /// shell script, a dotfiles repo, or a Nix module. Writes still use `Data`, + /// so the stored format is unchanged. + static func jsonData(forKey key: String) -> Data? { + if let data = UserDefaults.standard.data(forKey: key) { + return data + } + return UserDefaults.standard.string(forKey: key)?.data(using: .utf8) + } + private static func loadShortcut(forKey key: String) -> StoredShortcutLoadResult { - guard let data = UserDefaults.standard.data(forKey: key) else { + guard let data = jsonData(forKey: key) else { return StoredShortcutLoadResult(binding: nil, hadStoredValue: false, didNormalize: false) } guard let decoded = try? JSONDecoder().decode(ShortcutBinding.self, from: data) else {