---
title: Portable startup splash
---

# Portable startup splash

## What it is

When you double-click `Omniscio-Portable-<VERSION>.exe`, **two invisible phases happen** before the real Omniscio window appears. The startup splash makes both of them visible so you can tell the app is doing work — not frozen. (The instant-launch **zip** distribution skips Phase A entirely — see "Two distribution formats" below — so it only ever shows the Phase-B boot splash.)

1. **Extraction phase (tens of seconds, EVERY launch — varies by disk).** The portable `.exe` is a self-extracting bundle from electron-builder. The ~340 MB compressed `.exe` unpacks its full ~1.6 GB payload (Electron runtime + Omniscio code + bundled models) into a temp directory before any of Omniscio's own code runs. This is **not a one-time first-launch cost** — the NSIS portable wrapper (`portable.nsi`) does an unconditional `RMDir /r` + re-extract on _every_ launch and deletes the temp copy again on exit, so the full unpack happens each time you double-click the `.exe`. (This is the entire reason the instant-launch zip exists — see "Two distribution formats" below.) During this phase **Omniscio has not started yet**, so no Omniscio window can possibly appear — only electron-builder's built-in portable wrapper can show anything. We give it a static **`build/splash.bmp`** (400×220, 24-bit, dark theme, brand-matched: title "Omniscio" with an indigo accent rule, then "Unpacking files, please wait" and "The app will open automatically") via the `portable.splashImage` field in `package.json`. It deliberately has **no animation, no spinner, no dots** — the wrapper can only paint one static image, so anything that looks like a loader (we previously used three dots) reads as frozen/broken; a static accent rule reads as an intentional design element. **It MUST be a `.bmp`** — the NSIS portable template (`portable.nsi`) copies the file to `$PLUGINSDIR\splash.bmp` and loads it with the `BgImage` plugin, which decodes only real BMP data. A PNG (even one renamed `.bmp`) fails to load and the extraction splash silently shows nothing, with no build error. It pops up on the user's primary monitor, stays until the wrapper hands off to Electron, then vanishes — no spinner, no status text, no elapsed counter, because the wrapper is a C++ stub that can only display a single image.
2. **Boot phase (~2–10 s, varies by SQLite + auth init).** Once Electron starts, Omniscio's own main process runs through its startup pipeline: load `config.json`, open `mission-control.db`, replay crash recovery, refresh auth tokens, create the main window. The main window stays hidden until `ready-to-show` fires, so without intervention the user would stare at an empty desktop for several more seconds. The boot splash is a small **frameless BrowserWindow** (380×470, dark theme, indigo spinner) that opens as the very first thing inside `app.whenReady()` — _before_ database init, auth, recovery, or main-window creation. The boot sequence then **waits (briefly, capped) for the splash to actually paint** before running that blocking work: the DB / migrations / reconcile / recovery passes are synchronous and hold the main thread, and the splash's own `ready-to-show`→`.show()` is itself a main-thread task — so without this wait the splash couldn't appear until boot was nearly done (the "splash shows up ~60 s in" bug on a large database). It shows:
   - **Logo**: the Omniscio tower-badge logo (the hi-res master, downsized to 240×240 and embedded inline as a base64 `data:` image) sits above the title so the boot window is brand-matched to the rest of Omniscio. If the logo asset is ever missing or unreadable the splash still renders — just without the image (see the fail-safe note under "Why it works this way").
   - **Title**: "Omniscio"
   - **Spinner**: 26-px indigo ring rotating at 0.8 s/turn
   - **Phase checklist**: five boot phases — _Loading settings → Preparing your data → Restoring your work → Signing in → Opening the app_ — each a row showing **✓** when done, a pulsing **●** while active, or **○** until reached. The checklist only ever moves forward, so a conditional/skipped step (disk compaction, or recovery when there's nothing to recover) auto-completes the instant a later phase lights up. A row is named for the whole phase, never for one step inside it, because the row is what you read for as long as any of its steps runs — "Preparing your data" covers the database work and the project-storage tidy-up that ends that phase.
   - **Step line**: live text naming the specific sub-step inside the active phase — _"Reading your preferences..."_, _"Opening your database..."_, _"Applying the latest updates..."_, _"Restoring previous sessions..."_, _"Signing you in..."_, _"Almost ready..."_. During database upgrades it shows a live **_"Applying update N of M..."_** counter (pushed free-form from the ledger migration runner) so a long migration run visibly progresses instead of sitting on one frozen line. An unmapped kebab-case codename (e.g. `contextdock-preload-start`, fired late on a slow boot while the splash is still up) is **suppressed** — it resolves to nothing so the splash keeps the last real step rather than leaking an internal slug, while a free-form string (the live "Backing up database... NN%") passes through verbatim; either way the current phase is kept, so a new milestone never blanks the line. **Nothing but a real progress push can write this line** — not even the reassurance below it.
   - **"Still working" reassurance**: a second, quieter line beneath the step line, and the only thing the splash's own clock is allowed to write. Because the splash is its OWN window with its OWN clock it keeps moving even when the main process is blocked solid on a single long step (a slow migration, a silent git-storage compaction, the 60 s+ renderer warm-up): once the current step has been quiet past a threshold, this line escalates a reassuring message — _"Still working — this can take a little while..." → "Still working — larger projects can take several minutes..." → "Still working — a large project can take up to 45 minutes. You can stop it at any time..."_ during the data phase, or _"Still working — this usually only takes a moment..." → "Still working — larger projects take a bit longer..." → "Still working — thanks for your patience..."_ during the long "Opening the app" tail — so a long wait never looks frozen. Every message describes the WAIT and never the work: the step line already names the work, and a message that guesses at it is a message that can name the wrong job. A real status push clears this line and resets its clock, so it never sits beside a newer step, and an ordinary boot shows one line only.

The boot splash **closes the instant the main window's `ready-to-show` event fires** (which is exactly when Omniscio's UI becomes interactive). There's a brief overlap — splash still up, main window starting to paint — which prevents a black-screen flicker between the two.

## Where to find it

You don't. It's purely a startup affordance — there is nothing in the app that opens it, and no
menu, panel or shortcut reaches it. The splash shows itself.

There is **no Settings toggle for the boot splash**. It only ever shows for ~2–10 s during startup, then closes itself. A user-facing toggle would do nothing useful 99.99% of the time.

## How it behaves

### Two distribution formats

Every release ships **two** Windows downloads, both produced by the one `npm run package` run:

- **`Omniscio-Portable-<VER>.exe`** — the single-file self-extractor described above. Double-click and it runs; nothing to unzip. The cost is the extraction phase: it re-unpacks its ~1.6 GB payload to TEMP on **every** launch, so startup is slow and you see the static "Unpacking files" splash each time. Best for handing Omniscio to someone as one foolproof file.
- **`Omniscio-<VER>-unzip-and-run.zip`** — a zip of the already-unpacked app folder (the same files electron-builder writes to `dist/win-unpacked`). Extract it once to a folder, then run `Omniscio.exe` inside. There is **no extraction phase and no Phase-A splash** — Electron starts immediately, so you go straight to the Phase-B boot splash. Best for daily use.

Both are configured in `package.json` via `build.win.target: ["portable", "zip"]`. The zip's filename comes from `build.win.artifactName`; the `.exe`'s comes from the more-specific `build.portable.artifactName` override. Neither format auto-updates (electron-updater is disabled for portable distributions) — a new version is a fresh download that replaces the old one. Both are built and shipped by the GitHub `release.yml` workflow (Actions → Release; dispatched via the `release-amc-portable` skill).

### Why it works this way

- **Two phases, two technologies.** Phase A (extraction) is a static `.bmp` because the wrapper is a C++ self-extracting stub with no scripting surface — and the NSIS `BgImage` plugin it uses to paint the splash decodes only BMP. Phase B (boot) is a real BrowserWindow because we want a live status line and a phase checklist. Trying to share rendering between them was rejected — the wrapper literally cannot run JavaScript.
- **Splash MUST NEVER block startup.** The splash window is created inside a `try { ... } catch { ... }` block that swallows every error and logs a warning. If `BrowserWindow` construction throws (driver issue, GPU init failure, etc.), Omniscio continues as if no splash was requested. The logo read is independently fail-safe too: `loadSplashLogoDataUri()` is wrapped in its own try/catch and returns `''` on any read error (missing file, perms, I/O), and `buildSplashHtml()` omits the `<img>` entirely when the string is empty — so a missing or locked logo file can never throw out of splash creation.
- **Paints before the blocking boot work.** The boot splash is created early, but its `ready-to-show`→`.show()` runs on the main thread — which the synchronous DB / migrations / reconcile / recovery passes immediately seize. So the boot sequence `await`s `awaitSplashFirstPaint(SPLASH_FIRST_PAINT_CEILING_MS)` right after `createSplashWindow()`, giving the splash a guaranteed paint frame before that work; the heavy work then runs behind an already-visible, animating splash (its spinner + "still working" reassurance live in the splash's own renderer process, so they keep moving even while the main thread is busy). The wait is **bounded** by the ceiling and a **no-op when no splash exists** (suppressed harness / create failure), so it can never hang or otherwise gate startup. This mirrors `app/first-paint-signal.ts` (the same pattern for the MAIN window) and is the fix for the "splash shows up ~60 s in" report.
- **90-second watchdog.** If the main window never reaches `ready-to-show` (crash mid-init, hung migration, infinite loop), a `setTimeout` force-closes the splash after 90 s and writes `[splash] watchdog fired after 90000ms` to `startup.log`. The user then sees whatever the broken state actually is (empty desktop, tray-only) instead of staring at a frozen "Loading..." box forever.
- **Inline HTML via `data:` URL.** The splash HTML is produced by the pure `buildSplashHtml(logoDataUri)` function in `splash-window.ts` (logo passed in as an argument; no I/O inside it) and loaded via `data:text/html;charset=utf-8,...`. No external file, no Vite involvement, no extra packaging step. CSP is `default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'` — the **`img-src data:` clause is what permits the inline base64 logo to paint**; drop it and `default-src 'none'` silently blocks the image (no error, no log, no broken-image icon).
- **Security-locked webPreferences.** `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`. Splash is just a status display — no IPC, no preload bridge, no Node access.
- **Progress updates use `JSON.stringify`.** The main process pushes `(phaseIndex, detail)` to the splash via `webContents.executeJavaScript(\`window.\_\_amcSetSplashProgress(${JSON.stringify(phaseIndex)}, ${JSON.stringify(detail)})\`)`. Both args are `JSON.stringify`-escaped, defending against quotes, backslashes, and newlines in raw startup-trace marks. A `phaseIndex`of`-1`means "keep the current phase, just update the detail line" — that's how the live`Backing up database... NN%` text shows without moving the checklist.
- **No persistence, no telemetry.** Splash is purely visual — opens, shows status, closes. Nothing about its lifecycle is written to the DB or the diagnostic logs (the underlying startup-trace marks _are_ logged to `startup.log`; splash just mirrors them visually).

### When it does NOT show

- During `npm test` / `vitest run` (`NODE_ENV=test`)
- During E2E test runs (`AMC_INSTANCE_ID=e2e`)
- During Claude-driven sandbox (`AMC_SANDBOX_HEADLESS=1`)
- When `AMC_DISABLE_SPLASH=1` is set
- In development (`npm run dev`) — `NODE_ENV` defaults to `test` in Vitest, but in dev `npm run dev` sets it to `development` so the splash _does_ show. If that's annoying during HMR cycles, set `AMC_DISABLE_SPLASH=1` in your shell.
- If `new BrowserWindow(...)` throws — caught, logged, swallowed. Startup continues.

## For agents

### Where it lives in the code

- `package.json` → `build.portable.splashImage: "build/splash.bmp"` — wires the static extraction-phase image into electron-builder.
- `build/splash.bmp` (~258 KB, uncompressed 24-bit bitmap) — generated once and committed.
- `scripts/generate-splash-image.js` — one-shot regenerator. Run `node scripts/generate-splash-image.js` after editing the SVG embedded in the script if you want to refresh the static image. Renders the SVG to raw RGBA via `@resvg/resvg-js` (already a runtime dep), then encodes a 24-bit BMP via the shared `scripts/lib/brand-image-encoders.cjs` — **keep the output a `.bmp`** (see "Why it works this way").
- `src/main/services/splash-window.ts` — owns `createSplashWindow`, `updateSplashStatus(rawLabel)`, `closeSplashWindow`, plus the `STARTUP_PHASES` model (single source for the checklist rows, the label→phase mapping via `resolveSplashPhase`, and the friendly `detail` text via `mapStartupLabelToStatus`), the pure `buildSplashHtml(logoDataUri, phases)` markup builder (which serializes the filler config into the inline script), the pure `computeSplashFillerMessage(phaseId, msSinceLastRealStatus)` + its `SPLASH_PHASE_FILLERS` / `SPLASH_GENERIC_FILLER` tables (the time-based "still working" engine, whose messages may only describe the wait — the page's `setReassurance` writes them to the reassurance element and never to the step line), and the fail-safe `loadSplashLogoDataUri(logoPath?)` logo reader. The "Applying update N of M..." migration counter is pushed from `runLedgerMigrations`' display-only `onProgress` hook (`src/main/db/ledger-runner.ts`), threaded through `runMigrations`/`runMigrationsAsync` and `database.ts`'s `initDatabase(Async)`.
- `resources/amc-logo-splash.png` (240×240, ~103 KB) — the boot-splash logo asset, committed (the `resources/` tree is git-tracked and packed into `app.asar`). Regenerated (with every other brand asset) by the brand-icon pipeline — `npm run brand:icons`, from `brand/logo-master.*` (see [brand/README.md](../../brand/README.md)). NOTE: this is the **boot-phase** logo only — it is unrelated to the extraction-phase `build/splash.bmp` above.
- `src/main/index.ts` — calls `createSplashWindow()` as the first act inside `app.whenReady()`, immediately `await`s `awaitSplashFirstPaint(SPLASH_FIRST_PAINT_CEILING_MS)` so the splash paints before the blocking boot work, calls `updateSplashStatus(label)` from inside `startupMark()` (so every existing startup-trace mark is automatically mirrored to the splash), and calls `closeSplashWindow()` at the end of the `mainWindow.on('ready-to-show')` handler.
- `tests/unit/services/splash-window.test.ts` — assertions covering: env gates, security prefs, idempotency, the `STARTUP_PHASES` model (every mark in exactly one phase; friendly detail ≠ raw label), the "no raw label leaks" guarantee (every pre-window mark resolves to its hand-written model detail), the **slug suppression** for unmapped codenames (`contextdock-preload-start` → `''`, no push, while free-form strings like the live backup-% pass through verbatim), the `computeSplashFillerMessage` time-based filler (per-phase escalation thresholds, generic fallback, highest-threshold-wins, null below the first threshold) plus the `buildSplashHtml` filler-config + phase-id embedding and its no-elapsed-counter guarantee, the push protocol (`__amcSetSplashProgress` phase + detail, `-1` keep-phase, both args escaped), last-state replay on `ready-to-show`, JSON.stringify safety, watchdog firing + cancellation, the logo-loader fail-safe (returns `''` never throws), the `img-src data:` CSP clause (both logo / no-logo branches), and the committed-PNG-asset round-trip to a base64 data URI. The migration-counter `onProgress` hook is covered separately in `tests/unit/db/ledger-runner-progress.test.ts`. The two status lines are covered by `tests/unit/services/splash-html-behavior.test.ts`, which RUNS the page in a DOM and is the only spec that can tell the two arrangements apart — it fails if the reassurance is written into the step line, where a string assertion would pass either way.

### The phase model (`STARTUP_PHASES`)

The splash is driven by one ordered array — `STARTUP_PHASES` in `splash-window.ts` — the single source of truth for the checklist rows, the label→phase mapping, AND the friendly detail string per startup mark, so the three can never drift apart. Each phase is `{ id, label, marks: { mark, detail }[] }`:

| Phase | Checklist label     | Example startup marks → step line                                                                                                                                                                                                              |
| ----- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0     | Loading settings    | `config-store-init` → "Reading your preferences...", `integration-registry-check` → "Checking connected integrations..."                                                                                                                       |
| 1     | Preparing your data | `db-init-start` → "Opening your database...", `db-migrations-start` → "Applying the latest updates..." (then the live "Applying update N of M..." counter), `db-compact-start` → "Reclaiming disk space...", `db-init-done` → "Database ready", `git-repack-start` → "Compacting project storage..." (then the compaction's own per-step progress, e.g. "Compacting project storage: Agent Orchestrator...") |
| 2     | Restoring your work | `session-reconcile-start` → "Checking your sessions...", `crash-recovery-start` → "Restoring previous sessions...", `recovery-done` → "Sessions restored"                                                                                      |
| 3     | Signing in          | `auth-init-start` → "Signing you in...", `auth-init-done` → "Signed in"                                                                                                                                                                        |
| 4     | Opening the app     | `window-create-start` → "Opening the main window...", `window-create-done` → "Almost ready..."                                                                                                                                                 |

Two derived lookups read this array (never a second table): `mapStartupLabelToStatus(label)` returns the friendly detail — or, for an unmapped kebab-case slug (an internal codename like `contextdock-preload-start`), `''` so the splash keeps the last real step (the codename is **suppressed**, never shown), while a free-form string like `Backing up database... NN%` passes through verbatim; `resolveSplashPhase(label)` returns the owning phase index — or `-1` ("keep the current phase").

If you add a new startup-trace mark in `src/main/index.ts`: add `{ mark, detail }` to the right phase in `STARTUP_PHASES`, then add the `startupMark('<mark>')` call at the real boot point (additive — never reorder the boot sequence). If the mark fires before the main window shows, a unit test (`no raw label leaks`) FAILS until you've mapped it — the splash must never show an internal codename like `session-reconcile-start`. The disk-compaction strings (`Reclaiming disk space...` / `Disk space reclaimed`) are additionally locked by `database-maintenance-contract.md`; change them in both places.

### Kill switches

The only knobs are kill switches in case it ever gets in your way:

- **`AMC_DISABLE_SPLASH=1`** — env var. Skip the boot splash entirely on this launch. The extraction-phase static image still shows (that's electron-builder, not Omniscio). Useful if the splash window itself ever hangs (it shouldn't — there's a watchdog, see below).
- **`AMC_INSTANCE_ID=e2e`** — set by the E2E test harness. Splash is skipped.
- **`AMC_SANDBOX_HEADLESS=1`** — set by the Claude-driven sandbox. Splash is skipped (the sandbox uses a hidden window already).
- **`NODE_ENV=test`** — Vitest sets this. Splash is skipped during unit tests.

## Related

[windows-installer.md](windows-installer.md) covers the Windows install path a release download runs through, which is what produces the two portable formats above. [startup-trace.md](startup-trace.md) covers the startup log the splash mirrors visually — every status line it shows is a startup mark already written there. And [app-already-running-or-frozen.md](app-already-running-or-frozen.md) covers the other reason no window appears at launch, when an earlier Omniscio instance is still holding the app.
