Skip to content

§A01. Briefcase Packaging — Native App Builds, File Association, and App Identity

How rehuco uses Briefcase to build rehuco-agent into a native, double-clickable application with OS-registered file association and app identity — the how-to and the hurdles, complementing §16.8 (which records the decision to use Briefcase over PyInstaller and why).

This appendix starts from the macOS half of the file-association spike (#13, macOS; the Windows half closed in #1) and is meant to evolve as production Briefcase config lands in apps/rehuco-agent/ and as Windows and Linux packaging are wired up. Where a detail is still spike-proven rather than production-shipped, it says so.

§A01.1 Status

  • macOS file association + QFileOpenEvent delivery + single-instance routing — proven end to end on current versions by the #13 spike (§A01.6). The rehuco-agent app code it depends on (Application.event()'s QFileOpenEvent branch, ApplicationSingleton) already exists and needs no macOS-specific changes.
  • Windows ProgID / AUMID default-handler + taskbar identity — proven by #1; the dev-time story and its hurdles live in §A05 (the C launcher). Briefcase is the confirmed end-user packager there too.
  • Production Briefcase config in apps/rehuco-agent/pyproject.tomlnot yet landed. It is wider-distribution polish, deferred past the personal critical path (§16.8, plan: deferred). uv tool install covers the author's own machines until then.
  • Linux packaging, code-signing / notarization, auto-update — not yet done (§16.9, §A03.2).

§A01.2 The Briefcase config

Briefcase reads everything from pyproject.toml; no per-OS manifest is hand-maintained. The config below is what the #13 spike used and verified; the production version in rehuco-agent will differ only in names (spikerehuco-agent, .rehuspike.rehu, throwaway bundle ID → production bundle ID).

[tool.briefcase.app.spike.document_type.rehuspike]
description = "Rehuco Spike File"
extension = "rehuspike"
icon = "rehuco-spike"
url = "https://github.com/borco/rehuco"
mime_type = "application/x-rehuco-spike"

[tool.briefcase.app.spike.macOS]
requires = ["std-nslog~=2.0.0"]
# PySide6's macOS wheel is macosx_13_0_universal2; Briefcase's 11.0 default is too low (§A01.5).
min_os_version = "13.0"

From the document_type table Briefcase generates both halves of a macOS document-type declaration, with no hand-edited Info.plist:

  • CFBundleDocumentTypes — so Finder / LaunchServices treat the extension as a document type owned by this app, with the right icon.
  • UTExportedTypeDeclarations — so mdls, Spotlight, and any UTI-aware API resolve the extension to a concrete Uniform Type Identifier (com.<bundle>.<app>.<ext>).

Local (not-yet-on-PyPI) workspace dependencies go into requires as relative paths, since Briefcase builds the bundle by pip-installing into it and cannot see the uv workspace:

requires = ["PySide6>=6.9", "../../packages/borco-core", "../../packages/borco-pyside"]

§A01.3 The app-side wiring it relies on

Briefcase only produces the bundle and its registration; the app must still handle the two macOS delivery mechanics. Both already exist in rehuco-agent and needed no change for macOS.

macOS does not pass a double-clicked path as argv — it arrives as a Cocoa-originated QFileOpenEvent:

from PySide6.QtCore import QEvent
from PySide6.QtGui import QFileOpenEvent
from PySide6.QtWidgets import QApplication

class Application(QApplication):
    def event(self, event: QEvent) -> bool:
        if isinstance(event, QFileOpenEvent):
            self.open_path(event.file())
            return True
        return super().event(event)

main() still also reads sys.argv[1:], for parity with Windows' argv-based forwarding (§5.4) and for python -m ... <path> during development.

Single-instance routing uses the same QLocalServer/QLocalSocket mechanism as every platform (§5.4) — no macOS-specific code. See §A01.6 for when this path actually fires on macOS (it is a fallback there, not the primary route).

from borco_pyside.core.application_singleton import ApplicationSingleton

app = Application(sys.argv)
singleton = ApplicationSingleton()
if not singleton.setup(APP_ID):   # False -> forwarded argv to the existing primary; exit
    sys.exit(0)
singleton.other_instance_run.connect(open_forwarded)

§A01.4 Build and iterate

Icon first. Briefcase's icon = "rehuco-spike" config points at a basename; on macOS it needs a matching .icns next to pyproject.toml. macOS builds one from the .svg master with the platform tools (no third-party dependency). This is not yet a Makefile target — the Windows .ico rule already lives in the Makefile (%.ico: %.svg via magick); the .icns equivalent below should be wired in the same way when production packaging lands (§16.8), rather than run by hand:

magick -background none logo.svg -resize 1024x1024 rehuco-spike-1024.png
mkdir icon.iconset
for size in 16 32 64 128 256 512; do
  sips -z $size $size rehuco-spike-1024.png --out "icon.iconset/icon_${size}x${size}.png"
  double=$((size * 2))
  sips -z $double $double rehuco-spike-1024.png --out "icon.iconset/icon_${size}x${size}@2x.png"
done
iconutil -c icns icon.iconset -o rehuco-spike.icns
rm -rf icon.iconset rehuco-spike-1024.png

Then build:

# One-time: build the .app (downloads Python + PySide6 into the bundle; a few minutes).
briefcase build macOS
# Output: build/<app>/macos/app/<Formal Name>.app

# Iterate on app code (fast; re-syncs src/ into the existing bundle, seconds):
briefcase update macOS

The spike ran this against a throwaway local venv (uv venv, then uv pip install of PySide6, the two borco-* editable packages, the app itself, and briefcase). In production this becomes a Makefile target against the workspace venv.

§A01.5 Hurdles

Recorded in the order they bite when building a Briefcase macOS bundle for a PySide6 app.

min_os_version must be ≥ the PySide6 wheel's floor

Symptom: briefcase build macOS fails during dependency install with No matching distribution found for PySide6>=6.9 / Could not find a version that satisfies the requirement PySide6, even though PySide6 installs fine into a normal venv.

Cause: PySide6's macOS wheel is tagged macosx_13_0_universal2. Briefcase pins the bundle's minimum macOS to its own default (11.0) and asks pip for a wheel compatible with that floor — no 13.0-tagged wheel qualifies, so pip reports "no matching distribution."

Fix: set min_os_version = "13.0" under [tool.briefcase.app.<name>.macOS]. Carry this into rehuco-agent's production config; revisit only if PySide6 lowers its wheel floor.

std-nslog version tracks the template, not an old pin

[tool.briefcase.app.<name>.macOS].requires needs std-nslog (Briefcase's macOS stdout/stderr → unified-log shim). The current Briefcase template (v0.4.3) pins std-nslog~=2.0.0; an earlier Windows-spike commit referenced >=1.0.3. Use the version the active template expects, or the build resolver complains.

Briefcase's license selection keys are strict

briefcase new (and config validation) expect an SPDX-style key like MIT, not a prose string like "MIT license" — the latter fails validation with an "invalid override value" error. Minor, but wastes a scaffolding round-trip if hit.

§A01.6 Verification recipe (terminal-driven, no GUI session)

open and lsregister drive the exact same LaunchServices path Finder uses for a double-click, so the whole flow is verifiable over SSH with no screen attached — how the #13 spike was checked.

# Register the built .app with LaunchServices (Finder does this automatically on first copy/launch):
LSREG=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister
"$LSREG" -f "build/<app>/macos/app/<Formal Name>.app"

# Confirm the UTI is claimed and our .app is the Owner:
"$LSREG" -dump | grep -A6 "com.<bundle>.<app>.<ext>"

# Confirm a fresh file resolves to our UTI:
echo test > /tmp/test.<ext>
mdls -name kMDItemContentType /tmp/test.<ext>   # -> com.<bundle>.<app>.<ext>

# Open it exactly like a Finder double-click would (extension alone, no -a):
open /tmp/test.<ext>

# Watch QFileOpenEvent delivery and single-instance routing live:
log stream --style compact --predicate 'process == "<Formal Name>"'

# Clean up the throwaway registration when done:
"$LSREG" -u "build/<app>/macos/app/<Formal Name>.app"

§A01.7 What the #13 spike confirmed

Tested on Python 3.14.6, PySide6 6.11.1, Briefcase 0.4.3, macOS 26.5.1 (2026-07-02). All three of the spike's acceptance criteria passed:

Requirement Mechanism Result
.app registered as default opener for the extension (UTI + CFBundleDocumentTypes) Briefcase document_type config
Double-click delivers the path via QFileOpenEvent, not argv Application.event() override (already in rehuco_agent/app.py)
Second double-click routes to the existing instance macOS app-uniquing (primary) + ApplicationSingleton/QLocalServer (fallback)

Two behaviours are worth keeping in mind for the production wiring:

  • macOS's own app-uniquing — not ApplicationSingleton — routes a second Finder double-click. LaunchServices sees the bundle is already running and delivers the second file as another QFileOpenEvent to the same process, without launching a competitor. This confirms the §16.8 note that "a bundled .app is already kept single-instance by the OS, so the local-server forwarding mainly earns its keep on Windows/Linux." ApplicationSingleton's QLocalServer fallback still works when exercised directly (invoking the bundle executable, bypassing LaunchServices) — which is exactly the path that matters on Linux and for non-LaunchServices launches.
  • A benign teardown warning on the forwarding path. When the QLocalServer fallback does fire, the exiting secondary logs two QAbstractSocket::waitForBytesWritten() is not allowed in UnconnectedState warnings before primary already running ... forwarded argv and exiting. The forward still succeeds (the primary receives the path), so this is a harmless race in ApplicationSingleton's write-then-disconnect teardown, not a functional bug — noted for whoever next touches that (kept, tested) class.