app_bundle
app_bundle
¶
Build a minimal .app bundle for background dictation.
Why a bundle at all, when the code is plain Python: macOS attributes TCC permissions to a bundle, and several of them cannot be granted without one.
- Microphone requires
NSMicrophoneUsageDescriptionin an Info.plist. A bare interpreter launched by launchd has no Info.plist, so the request cannot even be made — the app is handed silence forever. That is exactly the[no audio captured]loop the LaunchAgent hit. - The Settings panes then list a generic "Python" (or nothing at all), which is both confusing and fragile: every venv's interpreter looks alike.
- An ad-hoc signature gives the bundle a stable identity, so the grants survive restarts.
The bundle is a thin wrapper: Contents/MacOS/<exe> is a shell script that
execs the current interpreter on python -m diapason.cli dictate. No
compilation, no Xcode, no Rust — the Python stays the source of truth.
Functions:¶
default_bundle_path
¶
build_info_plist
¶
The Info.plist contents. Kept pure so tests can assert on it.
Source code in src/diapason/desktop/app_bundle.py
build_launcher_script
¶
The Contents/MacOS script. Execs the interpreter, replacing the shell.
exec matters: launchd tracks the process it started, so the Python
must become that process rather than be a child of a shell that exits.
It deliberately runs from $HOME and never cds into the project.
The project may live under a TCC-protected folder (~/Downloads, ~/Desktop,
~/Documents): the app has no file access there, so the cd fails and
every later shell call inherits a broken cwd
(getcwd: Operation not permitted). The interpreter already knows where
the package is — the working directory is irrelevant to dictation.
workdir is accepted and ignored, so callers need not special-case it.
Source code in src/diapason/desktop/app_bundle.py
build
¶
build(
dest: Path | None = None,
*,
python: str | None = None,
workdir: str | None = None,
) -> Path
Create (or replace) the .app bundle. Returns its path.