Skip to content

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 NSSegmentedControl toggles between Sign in and Create account; email + server password; a Working… state while the network call is in flight.
  • Folder pick — an NSOpenPanel sheet 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. An NSTimer refreshes 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_password from 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 the main view 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 the unlock view instead.
  • Writes require app entitlements. keyring.set_password from an unsigned uv run python dev session fails with -25244 errSecMissingEntitlements; only the signed packaged .app can 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.tomd.org/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 _tkinter as a built-in with no separate .so, defeating PyInstaller's Tcl/Tk bundling hook. osascript dialogs 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:

cd desktop && uv run pytest tests/