Repository navigation
feat(#748): desktop production deployment - #760
marcvergees wants to merge 8 commits into
Conversation
- SQLite as the desktop database (WAL mode, check_same_thread=False) - Alembic: render_as_batch=True for SQLite ALTER TABLE support - Celery in-process eager mode (memory:// broker) when FIREFORM_DESKTOP=true - PyInstaller entry point: app/desktop.py (uvicorn on 127.0.0.1:8000) - PyInstaller spec: fireform-backend.spec (bundles alembic, assets; excludes torch/cv2/scipy) - CI: .github/workflows/build-desktop-binaries.yml - matrix build on macOS/Ubuntu/Windows - CI: tests.yml - drop postgres service (tests use SQLite in-memory); alembic check via SQLite - config.py: FIREFORM_DESKTOP flag, DATA_DIR resolution, WHISPER_ENABLED, DB_ECHO defaults - paths.py: allow file ops under DATA_DIR (uploads stored in userData) - purge.py: safe-delete allows files under DATA_DIR - system.py: health check returns healthy when WHISPER_ENABLED=false - init_db.py: ALEMBIC_INI resolved from BASE_DIR (PyInstaller-safe) - docs/DESKTOP.md: full desktop deployment guide (arch, data paths, CI pipeline, dev setup)
|
Hey @marcvergees, really nice work on this. I built both PRs into an actual FireForm.app to try it, and a few things keep it from running yet:
The fix I found for it was changing the packaged window which is blank on fe #65, Also needs a quick rebase on development (small conflict in celery.py). Happy to help on any of it, excited to get this shipped! |
- SQLite as the desktop database (WAL mode, check_same_thread=False) - Alembic: render_as_batch=True for SQLite ALTER TABLE support - Celery in-process eager mode (memory:// broker) when FIREFORM_DESKTOP=true - PyInstaller entry point: app/desktop.py (uvicorn on 127.0.0.1:8000) - PyInstaller spec: fireform-backend.spec (bundles alembic, assets; excludes torch/cv2/scipy) - CI: .github/workflows/build-desktop-binaries.yml - matrix build on macOS/Ubuntu/Windows - CI: tests.yml - drop postgres service (tests use SQLite in-memory); alembic check via SQLite - config.py: FIREFORM_DESKTOP flag, DATA_DIR resolution, WHISPER_ENABLED, DB_ECHO defaults - paths.py: allow file ops under DATA_DIR (uploads stored in userData) - purge.py: safe-delete allows files under DATA_DIR - system.py: health check returns healthy when WHISPER_ENABLED=false - init_db.py: ALEMBIC_INI resolved from BASE_DIR (PyInstaller-safe) - docs/DESKTOP.md: full desktop deployment guide (arch, data paths, CI pipeline, dev setup)
- init_db: set alembic script_location from BASE_DIR so migrations work regardless of the process cwd (fixes binary launch from any directory) - paths: add make_storable_path() — project-relative inside PROJECT_ROOT, absolute otherwise (safe for desktop DATA_DIR outside PyInstaller tmpdir) - form: write filled PDFs to DATA_DIR/outputs/ in desktop mode so they survive app restarts; use make_storable_path for DB storage - template: use make_storable_path in save_uploaded_pdf and make_fillable; guard make_fillable in desktop mode with a 503 AppError instead of a crash (ultralytics/YOLO is not bundled in the binary) - spec: add celery.loaders.app, celery.loaders.base, celery.app.events, app.tasks.report, app.api.routes.reports to hiddenimports
…ary build - fireform-backend.spec: bundle assets/icon.icns in binary - fireform-backend.spec: include reportlab hiddenimports and services - assets: add FireForm flame icon in .icns, .ico, and .png formats
…release 504 timeouts - setup-uv was downloading uv binaries directly from GitHub releases, causing CI job failures when GitHub releases CDN returned HTTP 504 - uses setup-python@v5 with pip caching, bootstraps uv/pip via PyPI, and falls back to pip install if needed
|
Hi @marcvergees, Over all PR description looks good to me, I have few questions: Configuration in productionHere I can see that in desktop mode SQLite is used by default when We won't be shipping a I think we need two things here:
|
Closes #748
Overview
This PR implements the backend half of the local-first desktop production deployment for FireForm.
It makes the FastAPI application fully self-contained: a single PyInstaller binary starts the
server, initialises and migrates a SQLite database, and runs all background work in-process —
no Docker, no PostgreSQL, no Redis, no Celery worker required on the user's machine.
Server and Docker deployments are completely unaffected. Desktop mode is opt-in via a single
environment variable (
FIREFORM_DESKTOP=true). Every decision below was made with this hardconstraint in mind.
Decision Log
1. CI Release Assets (Approach A) over shipping a pre-built binary in the repo
Decision: PyInstaller binaries are built in CI on every
v*tag push and attached to theGitHub Release. The
fireform-frontendrelease workflow downloads the matching platform binarybefore running
electron-builder.Rationale:
history permanently.
any tagged backend binary without a full monorepo sync.
binaries to the same release keeps everything in one place.
Python build tooling, which is a worse separation of concerns.
Where:
.github/workflows/build-desktop-binaries.yml2. SQLite over PostgreSQL for desktop; PostgreSQL remains for server
Decision: When
FIREFORM_DESKTOP=true(orDATABASE_URLis not set), the backend defaultsto
sqlite:///{DATA_DIR}/fireform.db. PostgreSQL is still used for all Docker/server deployments.Rationale:
pg_ctllifecycle, WAL housekeeping, and an OS-level service on every target platform. Thisis the problem we are explicitly trying to avoid.
and is effectively impossible to corrupt during a clean shutdown.
SQL. Switching the connection string is the only change required at the ORM layer.
conftest.py). The existing postgres CI service was deadweight — it is removed in this PR.
ALTER TABLE … ALTER COLUMN/DROP COLUMN,which SQLite does not support natively. This is solved by setting
render_as_batch=Trueinalembic/env.py, which rewrites those statements as table-copy-and-swap.What we did NOT do: migrate the Docker stack to SQLite. It runs PostgreSQL as before.
Where:
app/core/config.py(URL defaulting),app/db/database.py(WAL + foreign keyspragmas),
alembic/env.py(batch mode).3. Remove Redis; replace Celery worker with in-process eager mode
Decision: In desktop mode there is no Redis broker and no Celery worker process. Celery is
configured with
task_always_eager=Trueand amemory://broker so tasks run synchronouslyin the same process that enqueues them.
Rationale:
rejected for PostgreSQL.
enough to block on or are already async (the LLM streaming route does not use Celery at all).
calling the task function directly while keeping all the application code unchanged.
submission purge is not critical for single-user local use. A future iteration can use
APScheduleror athreading.Timerif retention becomes important.Where:
app/core/celery.py,app/core/config.py(CELERY_BROKER_URLdefault).4. PyInstaller as the packaging tool
Decision: The FastAPI application is compiled into a single executable using PyInstaller
with a hand-written spec file (
fireform-backend.spec).Rationale:
all dependencies, and the application code. No Python installation required on the user's
machine.
less stable for FastAPI + SQLAlchemy.
torch,torchvision,ultralytics,cv2, andscipy— these arepresent in
.venv(pulled in by other optional services) but are never imported by the FastAPIapp itself. Excluding them reduces the binary from ~600 MB to ~68 MB.
alembic/,alembic.ini,data/inputs/file_template_manual.pdf,app/services/form_filler/prompt.txt) are bundled via thedataslist in the spec.sys._MEIPASSdetection inconfig.pyensuresBASE_DIRresolves to the bundle root atruntime, so all relative-path lookups work identically inside and outside the binary.
Where:
fireform-backend.spec,app/core/config.py(frozen detection),app/desktop.py(entry point).
5.
FIREFORM_DESKTOPas the single feature flagDecision: A single boolean env var (
FIREFORM_DESKTOP) gates all desktop-specific behaviour.It defaults to
false;app/desktop.pysets it totruebefore importing the app.Rationale:
DATABASE_URL,CELERY_BROKER_URL,WHISPER_ENABLED, etc. The derived defaults flow from that one decision point..env.exampleclean — desktop users don't set this; server operators neverneed to know it exists.
FIREFORM_DESKTOP=trueto simulate desktop mode.FIREFORM_DESKTOP:DATABASE_URLdefault → SQLiteCELERY_BROKER_URLdefault → empty string → in-process eager modeWHISPER_ENABLEDdefault →falseDB_ECHOdefault →false(quieter logs in a GUI context)Where:
app/core/config.py.6. OS data paths via
FIREFORM_DATA_DIR(passed from Electron)Decision: The backend does not resolve OS-specific paths itself. Electron calls
app.getPath('userData')and passes the result asFIREFORM_DATA_DIR. The backend stores allmutable data under that directory.
Rationale:
app.getPath('userData')is the cross-platform, OS-sanctioned location for per-userapplication data. It maps to:
~/Library/Application Support/FireForm%LOCALAPPDATA%\FireForm~/.config/FireFormcleaner than duplicating platform detection in Python.
DATA_DIRis created byconfig.pyon import (mkdir(parents=True, exist_ok=True)), sothe backend is resilient to first-run scenarios where the directory does not yet exist.
paths.pyandpurge.pywere updated to treatDATA_DIRas a trusted root alongsidePROJECT_ROOT, so path-safety checks do not block operations on user data files.Where:
app/core/config.py(DATA_DIR,DATA_DIR.mkdir),app/core/paths.py,app/tasks/purge.py, Electronsrc/main/index.ts.7. Whisper disabled in desktop mode
Decision: Voice transcription is unavailable in the desktop build. The health check returns
healthy(not degraded) whenWHISPER_ENABLED=false.Rationale:
binary would add ~1 GB of model weights and GPU/CPU inference libraries to the bundle.
criteria do not mention it).
degraded state, which would confuse users and monitoring.
alongside the binary and setting
WHISPER_ENABLED=true WHISPER_HOST=http://....Where:
app/core/config.py(WHISPER_ENABLED),app/api/routes/system.py(health check).8. Alembic migrations run at startup;
render_as_batch=Truefor SQLiteDecision:
app/db/init_db.pyrunsalembic upgrade headevery time the backend starts.Alembic is configured with
render_as_batch=Truefor SQLite connections.Rationale:
updates without requiring the user to run a CLI command.
head, Alembic does nothing.render_as_batch=Trueis Alembic's documented solution for SQLite's lack of nativeALTER TABLE … ALTER COLUMN. It rewrites those operations as:This is fully transparent to the application.
ALEMBIC_INIis resolved fromBASE_DIR(not__file__) so it works both from the repoand from inside the PyInstaller bundle.
Where:
alembic/env.py,app/db/init_db.py.9. CI: drop the postgres service from
tests.ymlDecision: The
services: postgres:block is removed from.github/workflows/tests.yml.The alembic migration check now targets a SQLite database.
Rationale:
conftest.py. The postgres service wasnever used by any test — it was a resource that spun up and was immediately ignored.
alembic upgrade head && alembic check) is now run against afresh SQLite file, which is what the desktop binary will actually use.
Where:
.github/workflows/tests.yml.Files Changed
app/desktop.pyFIREFORM_DESKTOP=true, starts uvicorn on127.0.0.1fireform-backend.spec.github/workflows/build-desktop-binaries.ymlv*tagsapp/core/config.pyFIREFORM_DESKTOPflag; SQLite default URL;DATA_DIRauto-create;WHISPER_ENABLED;DB_ECHO; Celery broker defaultsapp/db/database.pyPRAGMA foreign_keys=ONvia SQLAlchemy eventapp/core/celery.pymemory://broker,task_always_eager=True) whenFIREFORM_DESKTOPalembic/env.pyrender_as_batch=is_sqlitein both offline and online migration pathsapp/core/paths.pyDATA_DIRadded to allowed path roots for file-safety checksapp/tasks/purge.py_safe_delete_fileallows files underDATA_DIRapp/api/routes/system.pyWHISPER_ENABLED=falseapp/db/init_db.pyALEMBIC_INIresolved fromBASE_DIR(PyInstaller-safe).github/workflows/tests.ymldocs/DESKTOP.md.gitignorebuild/,dist/,.pyinstaller_cache/,*.spec.bakVerification
What This Does NOT Change
make fireform,make up) — untouched.