Skip to content

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 NSMicrophoneUsageDescription in 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

default_bundle_path() -> Path

Install under ~/Applications: no admin rights, per-user, stable path.

Source code in src/diapason/desktop/app_bundle.py
def default_bundle_path() -> Path:
    """Install under ~/Applications: no admin rights, per-user, stable path."""
    return Path.home() / "Applications" / f"{BUNDLE_NAME}.app"

build_info_plist

build_info_plist() -> dict

The Info.plist contents. Kept pure so tests can assert on it.

Source code in src/diapason/desktop/app_bundle.py
def build_info_plist() -> dict:
    """The Info.plist contents. Kept pure so tests can assert on it."""
    return {
        "CFBundleIdentifier": BUNDLE_ID,
        "CFBundleName": BUNDLE_NAME,
        "CFBundleDisplayName": BUNDLE_NAME,
        "CFBundleExecutable": EXECUTABLE_NAME,
        "CFBundlePackageType": "APPL",
        "CFBundleInfoDictionaryVersion": "6.0",
        "CFBundleShortVersionString": "1.0",
        "CFBundleVersion": "1",
        # Agent, not a windowed app: no Dock icon, no menu bar takeover.
        "LSUIElement": True,
        "LSMinimumSystemVersion": "11.0",
        # Without this key macOS cannot grant Microphone at all.
        "NSMicrophoneUsageDescription": _MIC_REASON,
        # Shown when the process asks to observe input.
        "NSInputMonitoringUsageDescription": _INPUT_REASON,
    }

build_launcher_script

build_launcher_script(
    python: str, workdir: str = ""
) -> str

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
def build_launcher_script(python: str, workdir: str = "") -> str:
    """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 ``cd``s 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.
    """
    return (
        "#!/bin/sh\n"
        "# Generated by `diapason dictate-service install` — do not edit.\n"
        'cd "$HOME" || exit 1\n'
        f'exec "{python}" -m diapason.cli dictate "$@"\n'
    )

build

build(
    dest: Path | None = None,
    *,
    python: str | None = None,
    workdir: str | None = None,
) -> Path

Create (or replace) the .app bundle. Returns its path.

Source code in src/diapason/desktop/app_bundle.py
def build(
    dest: Path | None = None,
    *,
    python: str | None = None,
    workdir: str | None = None,
) -> Path:
    """Create (or replace) the .app bundle. Returns its path."""
    bundle = Path(dest) if dest else default_bundle_path()
    contents = bundle / "Contents"
    macos = contents / "MacOS"

    if bundle.exists():
        shutil.rmtree(bundle)
    macos.mkdir(parents=True, exist_ok=True)

    with open(contents / "Info.plist", "wb") as fh:
        plistlib.dump(build_info_plist(), fh)

    exe = macos / EXECUTABLE_NAME
    exe.write_text(
        build_launcher_script(
            python or sys.executable,
            workdir or str(Path.cwd()),
        ),
        encoding="utf-8",
    )
    exe.chmod(0o755)

    _codesign(bundle)
    return bundle