Desktop GUI¶
A small PyObjC + AppKit application that wraps the client in a native macOS window. Packaged via PyInstaller; codesigned with an Apple Development cert. Source at desktop/ in the repo.
The desktop app does not reimplement the client — it loads client/blindproof.py at runtime via importlib.util.spec_from_file_location and reuses the same extract / capture / SnapshotStore / SnapshotHandler / BackendClient / sync_snapshots code path as the CLI.
Window layout¶
A single NSWindow that the Coordinator drives through a sequence of views rather than popping a cascade of modals before presenting anything:
- Welcome — one-paragraph intro and Get started (first launch only).
- New passphrase — two
NSSecureTextFields with inline validation. - Unlock — one
NSSecureTextField, shown to returning authors when the keychain is cold. - Auth — an
NSSegmentedControltoggles between Sign in and Create account; email + server password; a Working… state while the network call is in flight. - Folder pick — an
NSOpenPanelsheet attached to the window (not a floating dialog). - Main — the watcher UI proper: "Watching" header and current folder path with a Change… button; large capture count (seeded from
store.count()on start so it reflects the local store's total); last-capture and last-sync relative times; action row of Sync now / Open dashboard; separator; Generate proof bundle button; footer row of Disconnect account / Quit. AnNSTimerrefreshes the labels every 2 seconds from the controllers.
Closing the window only hides it. The watcher keeps capturing in the background. Clicking the Dock icon re-opens the window. Only Quit (or ⌘Q) actually terminates the process. This matches writer expectations — you close the window and carry on; you quit the app when you're done for the day.
Modules¶
| File | Role |
|---|---|
desktop/app_entry.py |
CLI entry point. |
desktop/blindproof_gui/app.py |
NSApplication / NSWindow setup, Coordinator state machine, main-view builder. |
desktop/blindproof_gui/views.py |
AppKit view builders: WelcomeView, NewPassphraseView, UnlockView, AuthView, FolderPickView. |
desktop/blindproof_gui/controllers.py |
WatcherController, SyncController, BundleController — business logic. |
desktop/blindproof_gui/onboarding.py |
Pure functions: next_step() (CLI compatibility) and pick_view() (drives the in-window flow). |
desktop/blindproof_gui/dialogs.py |
osascript-backed secondary dialogs (proof-bundle details prompt, change-folder picker, disconnect confirm, info alerts). Onboarding no longer uses these. |
desktop/blindproof_gui/_client.py |
Runtime loader for client/blindproof.py. |
WatcherController¶
Wraps a watchdog.observers.Observer around a thin subclass of SnapshotStore whose record() fires a callback — the controller exposes capture_count and last_capture_at to the window.
Also wraps a _MoveAwareHandler(SnapshotHandler) that routes on_moved events through capture. Many editors (TextEdit, iA Writer, vim) save via "write tempfile + rename onto target", which the base handler would silently ignore. This is a must-fix, not an optimisation.
SyncController¶
sync_now() returns a SyncResult(synced, at, error) — exceptions are converted to result values so the window can show "Last sync failed: …" instead of crashing the app.
Onboarding¶
Two pure functions in onboarding.py, easy to unit-test:
def next_step(store_dir: Path) -> str:
# "passphrase" | "enrol" | "ready" — kept for CLI compatibility
def pick_view(*, store_dir, has_watch_folder, welcome_seen) -> str:
# "welcome" | "new_passphrase" | "auth" | "folder_pick" | "main"
pick_view() drives the in-window Coordinator. Its inputs are the presence of salt / backend.json / a watch_folder entry in gui.json, plus a transient welcome_seen flag the coordinator maintains. It is deliberately keyring-unaware — probing the keyring prompts the macOS Keychain Access dialog, so we defer that probe until we genuinely need the derived keys (see below).
The auth view uses an NSSegmentedControl to switch between sign-in and create-account mode. Sign-in calls BackendClient.login() against an existing account; create-account calls BackendClient.enrol(). Both end with save_backend_config() and advance to the next view.
Disconnect & re-link. The Disconnect account button wipes backend.json and quits (after confirm). The next launch routes back to the auth view, where Sign-in re-links to the same backend account using email + password. Salt, keychain entry, and local snapshots are preserved across disconnect — the master key is unchanged, so the local record stays readable.
Keyring: lazy reads, best-effort writes¶
The keyring is the single piece of state the app's onboarding flow handles with care, because both its read and write paths fail in surprising ways:
- Reads prompt the Keychain Access dialog. Calling
keyring.get_passwordfrom inside an AppKit target/action will block the main thread until the user dismisses the prompt. The coordinator therefore calls it exactly once, at the moment themainview is about to be installed. If the keyring has the passphrase, we derive keys and go straight to main. If it's cold, we detour via theunlockview instead. - Writes require app entitlements.
keyring.set_passwordfrom an unsigneduv run pythondev session fails with-25244 errSecMissingEntitlements; only the signed packaged.appcan write to the login keychain. Writes are therefore best-effort — on failure we keep the passphrase in memory for the session and log a stderr warning. The author will be asked for it again on next launch.
PyObjC gotcha: NSObject subclasses at module scope¶
views.py defines its button/segmented-control handler classes (_WelcomeHandler, _NewPassphraseHandler, and so on) at module scope with unique names, not as inner classes in each view's __init__. PyObjC registers NSObject subclasses in a global Objective-C runtime keyed on the Python class name — defining class _Handler(NSObject) inside every view works for the first instantiation but raises objc.error: _Handler is overriding existing Objective-C class the second time. If you add a new view, give its handler a unique module-level class name too.
Dashboard access¶
The GUI does not embed the dashboard. Open dashboard opens the user's default browser at https://blindproof.co.uk/dashboard.
This is a deliberate simplification. Embedding a webview adds a non-trivial surface (Tauri, pywebview, WKWebView bindings) that doesn't pay for itself — the dashboard is read-mostly and the system browser renders it fine. The demo stays focused on capture/sync rather than dashboard chrome.
Generating a proof bundle¶
A Generate proof bundle button on the window runs the full flow and saves the result to ~/Downloads/BlindProof-<date>.zip. Internally, it calls BackendClient.request_proof_bundle() (which hits POST /api/proof-bundle) and writes the returned zip body to disk.
Build¶
cd desktop
uv sync
uv run pyinstaller blindproof.spec
codesign --deep --force --sign "<Apple Development cert ID>" dist/BlindProof.app
open dist/BlindProof.app
The blindproof.spec PyInstaller spec lists client/blindproof.py's stdlib imports (sqlite3, urllib.request, urllib.error, hashlib, hmac, base64, json, threading, uuid, argparse, logging, time, getpass) under hiddenimports because PyInstaller cannot statically follow the runtime importlib.spec_from_file_location load. AppKit, Foundation, objc, and PyObjCTools.AppHelper are listed too.
Python interpreter: must be a framework build¶
uv's prebuilt CPython is standalone (PYTHONFRAMEWORK = no-framework), which causes NSStatusItem and related AppKit menu-bar APIs to silently no-op. The desktop virtualenv is built against /opt/homebrew/opt/[email protected]/bin/python3.12 (Homebrew Python is a framework build). Python.framework ends up bundled at Contents/Frameworks/Python.framework/.
brew install [email protected]
Signing¶
An ad-hoc PyInstaller signature is not enough for reliable local launch on recent macOS — codesign --deep --force --sign "<Apple Development cert ID>" over the bundle replaces it with an Xcode-on-device cert. That's sufficient for the author's own machine; Developer ID + notarisation is V1.
Why PyObjC (and not …)¶
- Tauri: Rust backend; doesn't reuse the Python crypto/sync code, so too much for a one-day demo.
- pywebview: rejected once the embedded-dashboard requirement was dropped.
- py2app: setuptools 79+ rejects its
install_requires; PyInstaller bundled cleanly first try. - Tk: uv-managed CPython links
_tkinteras a built-in with no separate.so, defeating PyInstaller's Tcl/Tk bundling hook.osascriptdialogs work natively, ship with macOS, and need zero hidden imports — they still drive the remaining secondary dialogs (proof-bundle prompt, change-folder picker, info alerts) even after the startup flow moved in-window. - rumps / NSStatusBar menu-bar app: tried first. Behaved correctly in every observable way but never rendered on macOS 15.6 (Sequoia) for non-notarised apps. Pivoted to a Dock-presenting
NSWindow, which renders normally and is clearer for a non-technical audience anyway.
Tests¶
desktop/tests/ holds PyObjC-dependent tests (macOS only — CI skips them for now because PyObjC won't install on Linux runners). Run with:
Swift rewrite — pre-unlock state¶
The desktop client is being rewritten as a native Swift app with a Python sidecar (rationale in swift-decision.md, tracked as issue #2). The new code lives under mac/ and runs alongside the PyObjC build until parity is reached. One behavioural difference is worth recording now because it shows up in the UI.
The new architecture separates the long-running file watcher (a launchd-supervised helper that boots at login) from the UI. The helper can therefore be alive and watching the configured folder before the author has unlocked any keys — on first launch, after a Disconnect, or on any login boot if the Keychain read fails.
In that state the helper drops file events. It does not buffer them, does not record cryptographic commitments without the actual snapshot, does not capture in any other half-form. The UI surfaces the state explicitly with a "Sign in to start capturing" banner; once the author unlocks, captures begin from that moment forward.
The reasoning: every alternative leaks. Buffering paths and reading them later turns captured_at into a lie if the file changed in between. Recording HMAC commitments without the contents promises a level of evidentiary coverage we cannot deliver. Honest is better than partial. The trade-off — that an author who saves a file in the watched folder before signing in loses that capture — is acceptable because the situation is rare, recoverable (the next save will be captured), and self-evidently signalled in the window.
The rule is enforced by the helper, not by the UI. If the Keychain read fails partway through a session, the helper transitions back to drop-mode immediately rather than continuing on stale state.
Once the helper has read the per-account subkeys from the Keychain, captures resume automatically and the banner disappears. See the threat model's "What we trust the macOS Keychain with" for the trust trade-off this implies.
Swift rewrite — unlock and onboarding flow¶
The UI app drives the author through five screens before the main window appears: Welcome, New passphrase, Connect your account (sign in or create, segmented), Choose a folder, and Main. The state machine that picks the current screen is a pure function of persistent + transient state — see AppScreen.pick(_:) in BlindProofKit; it mirrors the PyObjC reference at desktop/blindproof_gui/onboarding.pick_view.
Unlock derivation runs inside the UI's own sidecar process, not the helper's. On submit, the UI's sidecar runs unlock(passphrase, salt_hex) and export_subkeys(handle); the resulting 64 bytes are written to the macOS Keychain under the subkeys account. The helper's independent sidecar picks them up on its next 1 Hz tick via unlock_with_keys(...), transitioning from drop-mode to capture-mode. The passphrase itself never leaves the UI process.
Returning authors with a cold Keychain see the Unlock screen: a single passphrase field. The saved argon2_salt_hex (held in identity.json) is paired with the typed passphrase to re-derive the same subkeys. There is no round-trip to the backend.
Disconnect wipes the bearer token and the subkeys Keychain entries, leaving identity.json (email + salt) and the watch folder on disk. The next launch routes the author to the Sign in tab, pre-filled with the remembered email — one re-auth restores capture against the existing snapshots.
Swift rewrite — launch at login¶
When the author finishes the Auth step (sign in or create account), the UI registers the embedded BlindProofHelper.app as a launchd-supervised LoginItem via SMAppService (macOS 13+). The helper's plist (org.tomd.blindproof.helper.plist, copied into BlindProof.app/Contents/Resources/) sets KeepAlive=true and ThrottleInterval=10, so launchd brings the helper up at every login and restarts it after a crash with backoff.
Registration is idempotent: the coordinator inspects SMAppService.Status first and only calls register() when the agent isn't already enabled (or awaiting the author's approval in System Settings). This means launching the app on a machine where the helper is already registered is a no-op — there is no per-launch flag to maintain.
A Preferences pane (⌘,) shows the current status and a toggle. When macOS gates the agent behind requiresApproval — typically because the author has previously turned off our Login Item, or because the OS is asking them to confirm a fresh registration — the pane shows a yellow banner with an "Open…" button that deep-links to System Settings → Login Items & Extensions via SMAppService.openSystemSettingsLoginItems().
The whole surface lives behind a LoginItemRegistering protocol in BlindProofKit, which the production LoginItem type and the test fakes both conform to. The AppCoordinator and LoginItemController are unit-tested without touching launchd; the LoginItem wrapper itself is exercised by a single status-read test that confirms SMAppService reports .notRegistered / .notFound for an unknown plist.