Skip to content

Make settings configurable from the command line - #289

Open
ChaoticQubit wants to merge 4 commits into
zachlatta:mainfrom
ChaoticQubit:scriptable-settings
Open

Make settings configurable from the command line#289
ChaoticQubit wants to merge 4 commits into
zachlatta:mainfrom
ChaoticQubit:scriptable-settings

Conversation

@ChaoticQubit

@ChaoticQubit ChaoticQubit commented Aug 7, 2026

Copy link
Copy Markdown

Makes every FreeFlow setting reachable from a shell, so a machine can be configured from dotfiles, a setup script, or a Nix or Homebrew module instead of by clicking through Settings.

Most settings already worked this way, since they live in UserDefaults:

defaults write com.zachlatta.freeflow preserve_exact_wording -bool true
defaults write com.zachlatta.freeflow post_processing_model -string "openai/gpt-oss-120b"

Three things stopped that from being a complete story.

1. Shortcuts and voice macros were unreachable, and failed silently

hold_shortcut, toggle_shortcut, copy_again_shortcut, the three saved_*_custom_shortcut keys and voice_macros are stored as JSON-encoded Data. defaults cannot write a Data value without hex-encoding the payload first:

$ defaults write com.zachlatta.freeflow hold_shortcut '{"keyCode":54,...}'
Could not parse: {"keyCode":54,...}.  Try single-quoting it.

Forcing it with -string stores the value, but loadShortcut reads it with UserDefaults.standard.data(forKey:), which returns nil for a string. The result is worse than an error: the binding falls back to the default and the default is then written back over the user's value, so a scripted setting silently disappears.

loadShortcut and the voice_macros read now go through a helper that falls back to string(forKey:). Writes still use Data, so the stored format is unchanged and existing installs are unaffected. Hex-encoded -data values keep working too.

2. A hand-written voice macro could not decode at all

VoiceMacro.id has a default value, but Swift's synthesized decoder ignores defaults and fails with keyNotFound when the key is absent. Since the failure just yields an empty array, a hand-written macro list silently produced no macros:

no id  : FAILED -> DecodingError.keyNotFound: Key 'id' not found in keyed decoding container. Path: [0].
with id: DECODED 1 macro

id is now decoded with decodeIfPresent and falls back to a fresh UUID(), so only command and payload are required. Explicit ids are still honoured and encode still writes id, so stored macros round-trip unchanged.

3. Nothing documented any of this

Added a README section covering all 45 user-configurable keys with their types, the shortcut and macro JSON, and the .settings file that holds API credentials. It also states the constraint that matters most in practice: FreeFlow reads settings once at launch and does not watch for changes, so you have to quit before writing and start afterwards.

Verification

Tested against a real build using the FreeFlow Dev bundle, so the separate com.zachlatta.freeflow.dev domain kept my own settings out of it. The dev domain was backed up and restored afterwards.

A/B, writing hold_shortcut as a plain JSON string for Right Command while the built-in default is Fn:

Build Result after launch
unpatched keyCode: 63 (Fn) as Data - string discarded, default written over it
patched keyCode: 54 (Right Command) - string honoured

The VoiceMacro decoding change is covered by a harness built from the struct as it appears in AppState.swift, checking that a macro without id decodes and gets a generated UUID, that an explicit id is preserved, and that encode still emits id.

The credentials file permissions were checked by reproducing the old sequence (644 with the key already on disk before chmod) against the new one (600 throughout).

The Fn JSON in the README is the app's own output, read back after letting FreeFlow write its default, rather than something I hand-wrote.

Not included

I deliberately did not add a config-file layer or live reloading. macOS already provides the declarative layer through defaults and managed preferences, and live reload would need care around the didSet write-back racing external edits. Happy to look at either if you'd want them.

ChaoticQubit added 2 commits August 6, 2026 22:09
Shortcuts and voice macros are stored as JSON-encoded Data. 'defaults
write' cannot produce a Data value without hex-encoding the payload
first, so these settings were effectively unreachable from a shell
script, a dotfiles repo, or a Nix module. Worse, a plain string was
discarded silently: data(forKey:) returned nil, the binding fell back to
the default, and the default was written back over the user's value.

Read through a helper that falls back to string(forKey:). Writes still
use Data, so the stored format is unchanged and this is backwards
compatible in both directions.
@github-actions github-actions Bot added the size/m label Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Configuration Support

Layer / File(s) Summary
JSON configuration loading
Sources/AppState.swift
AppState loads shortcut and voice macro configuration from Data or UTF-8 JSON strings in UserDefaults. Writes remain Data-encoded.
Command-line configuration documentation
README.md
Documents macOS defaults, launch-time settings, shortcut and voice macro JSON, and API credential files with restrictive permissions.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: marcbodea, ojhurst

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: configuring FreeFlow settings from the command line through UserDefaults.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
Sources/AppState.swift (1)

927-943: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression tests for both storage representations.

Test valid JSON stored as Data and as a UTF-8 String for shortcuts and voice macros. Add malformed-input coverage to preserve the intended fallback behavior.

This follows the new Data/string configuration contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppState.swift` around lines 927 - 943, Extend the regression tests
for shortcut and voice-macro loading to cover valid JSON stored both as
UserDefaults Data and as a UTF-8 String, using the existing test helpers and
configuration keys. Add malformed-input cases for each representation and assert
the established fallback behavior, while preserving the existing valid Data-path
expectations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 204-208: Update the README shortcut lookup instructions to reflect
that hold_shortcut is stored as Data and defaults read returns hexadecimal
property-list bytes, not copyable JSON. Add a decode step or clearly explain how
to convert the output before using it with -string, referencing the persistence
behavior in AppState.swift.
- Around line 116-120: Update the README settings documentation to cover both
credential locations: the default FreeFlow path and the bundle-name-derived path
used by builds such as “FreeFlow Dev.” At each relevant setup or access step,
document both paths or use a shell variable derived from the current
CFBundleName instead of assuming only `FreeFlow/.settings`.
- Around line 214-223: Update the README credential setup commands so the
settings file is protected before JSON is written: set a restrictive umask and
create or truncate ~/Library/Application\ Support/FreeFlow/.settings with mode
600 before the cat write. Preserve the existing file path, credentials, and
final configuration while eliminating the permissive creation window.
- Around line 198-200: Update the README voice macro JSON example to include the
required id field alongside command and payload, ensuring it can be decoded by
JSONDecoder into VoiceMacro without being discarded.

---

Nitpick comments:
In `@Sources/AppState.swift`:
- Around line 927-943: Extend the regression tests for shortcut and voice-macro
loading to cover valid JSON stored both as UserDefaults Data and as a UTF-8
String, using the existing test helpers and configuration keys. Add
malformed-input cases for each representation and assert the established
fallback behavior, while preserving the existing valid Data-path expectations.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 44a3202e-635f-47b0-a2b8-e34f35466a4d

📥 Commits

Reviewing files that changed from the base of the PR and between e63a232 and b5126ef.

📒 Files selected for processing (2)
  • README.md
  • Sources/AppState.swift

Comment thread README.md
Comment thread README.md
Comment thread README.md Outdated
Comment thread README.md
ChaoticQubit added 2 commits August 6, 2026 22:20
Swift's synthesized decoder ignores the default value on 'id' and fails
with keyNotFound when the key is absent, so a hand-written macro list
decoded to nothing at all - silently, since the failure just yields an
empty array.

Decode 'id' with decodeIfPresent and fall back to a fresh UUID. Encoding
still writes 'id', so stored macros round-trip unchanged.
Reading a shortcut back with 'defaults read' prints the property-list
data representation as hex rather than the JSON to copy, so document the
decode. Create the credentials file with a restrictive umask before
writing to it, rather than chmod-ing afterwards when the key is already
on disk.
@ChaoticQubit

Copy link
Copy Markdown
Author

Thanks, good round. Three of the four were valid and are fixed; one I've left with a reason in-thread.

Voice macro id (696984f) - the significant one. A hand-written macro list failed to decode entirely, silently yielding no macros. I fixed the decoder rather than the README example, since requiring hand-generated UUIDs in a dotfiles repo works against the point of this PR. Worth flagging that my original verification did not catch this: I checked the JSON still had command/payload after the app ran, which only proved the key hadn't been overwritten, not that it had parsed.

defaults read prints hex (5c6f3fc) - correct, the README now documents a plutil | base64 --decode pipeline.

Credentials file permissions (5c6f3fc) - correct, reproduced the window at 644 with the key already written. Now created empty under umask 077 first.

Both .settings paths - accurate but deliberately omitted; reasoning in-thread. Short version: the alternate path only exists for source builds, and this README has no build-from-source section, so it would be the file's only mention of dev builds.

The PR description is updated, including a correction to the verification section that previously overstated what the macro test had shown.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant