Skip to content

feat(desktop): build a personal flavor that replaces the release app - #4

Open
mneuhaus wants to merge 1 commit into
marc/mainfrom
marc/desktop-flavor
Open

mneuhaus wants to merge 1 commit into
marc/mainfrom
marc/desktop-flavor

Conversation

@mneuhaus

@mneuhaus mneuhaus commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

A local desktop build had the release identity: the same bundle id, product name and update feed. The next release update replaced the build, and nothing told the two apps apart.

What changed

T3CODE_DESKTOP_FLAVOR=<id> (for example marc) builds a personal app that takes the release app's place on one machine and keeps working on the same data.

Build (scripts/build-desktop-artifact.ts):

  • The bundle id becomes com.t3tools.t3code.<id> and the product name T3 Code (<Id>).
  • The app uses the dev icon and web branding.
  • There is no publish config, so no update feed: the build stays the build it is.
  • The flavor is written to the packaged package.json as t3codeDesktopFlavor.
  • URL schemes stay the release's (t3code, t3code-dev), because the build replaces the release app.

Runtime (apps/desktop):

  • DesktopFlavor.readPackagedDesktopFlavor reads the flavor synchronously at startup, packaged builds only. An async read yields and lets Electron emit ready before the Clerk bridge registers its privileged schemes; that crashed the first build. A malformed field throws.
  • The flavor changes only the branding: window title T3 Code (<Id>) with the Dev stage art.
  • Data (~/.t3), the Electron profile and the keychain entry t3code Safe Storage stay the release install's. The keychain entry is named after the package, which does not change. So the build sees the same threads, settings and encrypted secrets.
  • Shared-data guard. On macOS the release app takes no single-instance lock (@clerk/electron skips it on darwin), and the server does not lock its state directory. Two apps on the same data would run two servers on one database.
    • So a flavored app first checks server-runtime.json via DesktopFlavor.findLiveServerOwner. If a live process other than itself owns the state directory, the app shows an error naming that process and quits.
    • The check is DesktopFlavor.layerSharedDataGuard. main.ts provides it to the Clerk layer chain before DesktopClerk.layer, so it runs synchronously before Clerk sets userData and before anything yields. Chromium never opens the shared profile.

Without the env var, nothing changes.

Known limits

  • The guard only protects in one direction. A release app started while the flavored app runs is not stopped, so the release app should be removed when the flavor replaces it.
  • The build may run newer database migrations than the release app. Today's pending ones (53, 54) are additive, and the migrator skips IDs at or below the latest applied one, so going back to the release keeps working. A future non-additive migration could break switching back until the release catches up.

Validation

  • New tests:
    • DesktopFlavor.test.ts:
      • reading the flavor;
      • missing field or file;
      • unpackaged runs;
      • malformed field;
      • the guard: live owner, stale/missing/unreadable file, own pid.
    • DesktopEnvironment.test.ts: the flavored environment has the release's data dirs and Electron profile; only the branding differs.
    • build-desktop-artifact.test.ts:
      • flavored names and icons;
      • flavored build config: own app id, no publish, same URL schemes as the release.
  • vp test run on these files: all green except skips the primary native probe for cross-architecture Windows payloads. That test fails the same way on unmodified marc/main on this Mac.
  • tsc --noEmit is clean in apps/desktop and scripts. Lint and format are clean.
  • Real macOS arm64 build (T3CODE_DESKTOP_FLAVOR=marc, together with feat(projects): find folders with fuzzy path queries #1 to feat(clients): add an "In 1 month" snooze preset #3):
    • bundle id com.t3tools.t3code.marc, no app-update.yml;
    • the guard found the running release server on the real ~/.t3/userdata;
    • end-to-end in a sandbox (T3CODE_HOME = temp dir with a live owner pid), the app logged the refusal, showed the dialog, created no database and opened no server port.

Model: Claude Opus 5.5 in Claude Code (via T3 Code).

Rebased onto the new orchestrator (2026-10-02)

Rebased onto upstream main 8bc40b4, which includes pingdotgg#2829. It applied cleanly. Upstream moved the Electron profile choice into DesktopUserData (profile t3code-v2) and removed userDataDirName, so the shared-data test now compares data dirs and app-data directory only; the flavor never reaches the profile choice. A sandbox start of the rebuilt app (0.0.45, empty T3CODE_HOME, mock keychain) brought up the V2 server with statev2.sqlite and served the UI.

Rebased onto main cd41c4a (2026-10-07), guard moved

Rebased onto upstream main cd41c4a. A review of the rebase found a bug in the original commit, not caused by the rebase. The shared-data check ran inside DesktopApp.startup, after the async login-shell probe and the settings load. Electron emits ready as soon as startup yields, so the check ran after ready and after DesktopClerk had set the shared userData. It still stopped the second backend, but not Chromium opening the shared profile.

  • The check now lives in DesktopFlavor.layerSharedDataGuard and is provided in main.ts right before DesktopClerk.layer.
  • The reject path stays synchronous: read the runtime file, log to the console, show the error box (safe before ready), quit, interrupt. Same pattern as Clerk's secondary-instance path.
  • DesktopApp.ts is back to upstream; the PR no longer touches it.
  • Three new DesktopFlavor.test.ts cases: live owner (error box, quit, interrupt), missing runtime file, release build never stopped. apps/desktop: 1402 tests pass, typecheck, lint and format are clean.
  • End-to-end with the packaged arm64 build (merged with feat(projects): find folders with fuzzy path queries #1 to feat(clients): add an "In 1 month" snooze preset #3), sandboxed via HOME/CFFIXED_USER_HOME/T3CODE_HOME and --use-mock-keychain:
    • Empty sandbox: app ready, backend ready on the configured port, /.well-known/t3/environment reports protocol 2, the window loads. No registerSchemesAsPrivileged error.
    • Sandbox whose server-runtime.json names a live foreign pid: the only log line is the refusal, the dialog "T3 Code (Marc) cannot start" names the process, no database, no Electron profile, no helper processes, no open port.

🤖 Generated with Claude Code

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Oct 1, 2026
@mneuhaus
mneuhaus force-pushed the marc/desktop-flavor branch 2 times, most recently from fb741f4 to eb0600f Compare October 1, 2026 10:27
@mneuhaus mneuhaus changed the title feat(desktop): build a personal flavor that runs next to the release feat(desktop): build a personal flavor installed next to the release Oct 1, 2026
@mneuhaus
mneuhaus force-pushed the marc/desktop-flavor branch from eb0600f to cc37439 Compare October 1, 2026 10:41
@mneuhaus mneuhaus changed the title feat(desktop): build a personal flavor installed next to the release feat(desktop): build a personal flavor that replaces the release app Oct 1, 2026
@mneuhaus
mneuhaus force-pushed the marc/desktop-flavor branch from cc37439 to 300a6a5 Compare October 2, 2026 21:29
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 300a6a5.

This comment will update automatically after the next completed run.

A local desktop build had the release identity: same bundle id, same
product name and the release update feed, so the next update replaced
the build again, and nothing told the two apps apart.

T3CODE_DESKTOP_FLAVOR=<id> gives a build its own bundle identity:
bundle id com.t3tools.t3code.<id>, product name "T3 Code (<Id>)", the
dev artwork, and no update feed, so it stays the build it is.

The flavor is recorded in the packaged package.json and read
synchronously at startup (an async read lets Electron emit ready before
Clerk registers its schemes). It only changes the branding: window title
"T3 Code (<Id>)" with the Dev stage art. Data, Electron profile and
keychain entry stay the release install's, so the build sees the same
threads, settings and secrets.

On macOS neither the release app (no single-instance lock) nor the
server guards that shared data directory. A flavored app therefore
refuses to start, before ready, while another live server owns it
(server-runtime.json), and says which process to quit.
@mneuhaus
mneuhaus force-pushed the marc/desktop-flavor branch from 300a6a5 to 735f005 Compare October 7, 2026 10:56

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant