Skip to content
Open
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
126 changes: 126 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,132 @@ defaults delete com.zachlatta.freeflow context_request_timeout_seconds

</details>

## 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`

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
```

<details>
<summary>All available keys</summary>

**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`.

</details>

### 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"}]'
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
38 changes: 36 additions & 2 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down