From bac90fa9ff1ae4ec55d3b5268667242de7971a2d Mon Sep 17 00:00:00 2001 From: TB516 <77514919+TB516@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:43:14 +0000 Subject: [PATCH 1/2] chore(flatpak): update T3 Code to v0.1.2 --- flatpak/com.t3tools.t3code.metainfo.xml | 4 ++++ flatpak/modules/t3code.yml | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/flatpak/com.t3tools.t3code.metainfo.xml b/flatpak/com.t3tools.t3code.metainfo.xml index 16196dc..cb09829 100644 --- a/flatpak/com.t3tools.t3code.metainfo.xml +++ b/flatpak/com.t3tools.t3code.metainfo.xml @@ -19,6 +19,10 @@ com.t3tools.t3code.desktop https://t3.codes + + https://github.com/pingdotgg/t3code/releases/tag/v0.1.2 + + https://github.com/pingdotgg/t3code/releases/tag/v0.1.1 diff --git a/flatpak/modules/t3code.yml b/flatpak/modules/t3code.yml index 3c6c6a6..0cb4e91 100644 --- a/flatpak/modules/t3code.yml +++ b/flatpak/modules/t3code.yml @@ -58,8 +58,8 @@ test-commands: sources: - type: git url: https://github.com/pingdotgg/t3code.git - tag: v0.1.1 - commit: fe27e8e3dc12dcbb108e9a28fd9b8cb65f34ae92 + tag: v0.1.2 + commit: 044f07257c9684ae4543bd1e1ebf9c394e9e3830 x-checker-data: type: json url: https://api.github.com/repos/pingdotgg/t3code/releases/latest From abd67332271c86219d3446ac2587e9b9687701e6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 22 Oct 2026 20:40:07 -0400 Subject: [PATCH 2/2] Adapt Flatpak integration to T3 Code v0.1.2 Drop the temporary Electron patch now that upstream ships Electron 44.9.3, and rebase the remaining patches onto upstream's pruned test suite. Install pnpm 12 as its pinned native binary, and run the desktop typecheck through its new Vite+ task. Offer the local database repairs that `t3 doctor` provides on other installs: reclaim space until automatic shrinking is on, and delete the database kept from before 0.1.0. The local row has no dismiss and goes away once both are done. --- flatpak/com.t3tools.t3code.metainfo.xml | 6 +- flatpak/modules/pnpm.yml | 15 +- flatpak/modules/t3code.yml | 3 +- flatpak/package/host-server.sh | 9 +- .../0001-flatpak-desktop-integration.patch | 28 +- patches/0002-flatpak-host-backend.patch | 6 +- patches/0003-flatpak-host-environment.patch | 24 +- patches/0005-flatpak-build-artifact.patch | 2 +- .../0006-flatpak-document-portal-paths.patch | 57 +- patches/0007-flatpak-editor-discovery.patch | 18 +- patches/0008-flatpak-cli-launcher.patch | 6 +- patches/0009-flatpak-server-updates.patch | 26 +- .../0010-flatpak-browser-requirements.patch | 33 +- .../0011-flatpak-database-maintenance.patch | 729 +++++++++++------- patches/0012-flatpak-update-channel.patch | 12 +- patches/0013-electron-security-update.patch | 85 -- 16 files changed, 591 insertions(+), 468 deletions(-) delete mode 100644 patches/0013-electron-security-update.patch diff --git a/flatpak/com.t3tools.t3code.metainfo.xml b/flatpak/com.t3tools.t3code.metainfo.xml index cb09829..e44ba49 100644 --- a/flatpak/com.t3tools.t3code.metainfo.xml +++ b/flatpak/com.t3tools.t3code.metainfo.xml @@ -21,7 +21,11 @@ https://github.com/pingdotgg/t3code/releases/tag/v0.1.2 - + +

Update to T3 Code 0.1.2, which ships Electron 44.9.3 upstream.

+

Settings now offers to reclaim the local database's space until automatic + shrinking is on, and can delete the database kept from before 0.1.0.

+
https://github.com/pingdotgg/t3code/releases/tag/v0.1.1 diff --git a/flatpak/modules/pnpm.yml b/flatpak/modules/pnpm.yml index bc1bb93..89409a8 100644 --- a/flatpak/modules/pnpm.yml +++ b/flatpak/modules/pnpm.yml @@ -3,11 +3,16 @@ buildsystem: simple cleanup: - "*" build-commands: + # pnpm 12 runs as a native binary that finds its bundled node-gyp in the `dist/` next to it. - mkdir -p /app/bin /app/lib/pnpm - - cp -a bin dist package.json LICENSE /app/lib/pnpm/ - - ln -s ../lib/pnpm/bin/pnpm.mjs /app/bin/pnpm - - ln -s ../lib/pnpm/bin/pnpx.mjs /app/bin/pnpx + - cp -a dist package.json /app/lib/pnpm/ + - install -m755 exe/pnpm /app/lib/pnpm/pnpm + - ln -s ../lib/pnpm/pnpm /app/bin/pnpm sources: - type: archive - url: https://registry.npmjs.org/pnpm/-/pnpm-11.10.0.tgz - sha256: 620b6605ea4f62fc56a6d0a98733f479071d0321e03e486b522c7e9a74617431 + url: https://registry.npmjs.org/pnpm/-/pnpm-12.14.0.tgz + sha256: ea206e35474bf87be9564a85ca70ed1c8ebdc45da55aafb0a3bf09d1ba69b179 + - type: archive + url: https://registry.npmjs.org/@pnpm/exe.linux-x64/-/exe.linux-x64-12.14.0.tgz + sha256: 1de98ed2e9621113b0887b06322b461efb9df73d913fc1d151039b10b7117a41 + dest: exe diff --git a/flatpak/modules/t3code.yml b/flatpak/modules/t3code.yml index 0cb4e91..0426846 100644 --- a/flatpak/modules/t3code.yml +++ b/flatpak/modules/t3code.yml @@ -44,7 +44,7 @@ test-commands: env -u FLATPAK_ID pnpm --filter @t3tools/desktop exec vp test run --exclude src/wsl/DesktopWslEnvironment.test.ts - - pnpm --filter @t3tools/desktop typecheck + - pnpm --filter @t3tools/desktop exec vp run typecheck - >- pnpm --filter t3 exec vp test run src/process/externalLauncher.test.ts @@ -81,7 +81,6 @@ sources: - ../../patches/0010-flatpak-browser-requirements.patch - ../../patches/0011-flatpak-database-maintenance.patch - ../../patches/0012-flatpak-update-channel.patch - - ../../patches/0013-electron-security-update.patch use-git: true - type: dir path: ../package diff --git a/flatpak/package/host-server.sh b/flatpak/package/host-server.sh index 434cf17..256320e 100755 --- a/flatpak/package/host-server.sh +++ b/flatpak/package/host-server.sh @@ -12,10 +12,11 @@ unset T3CODE_DESKTOP_HTTPS_ENDPOINTS T3CODE_TAILSCALE_SERVE T3CODE_TAILSCALE_SER cd "$HOME" export ELECTRON_RUN_AS_NODE=1 -# Desktop maintenance runs with the same runtime and sanitized host environment, -# after the desktop has stopped its server. No user-facing CLI is installed. -if [ "${1:-}" = "--compact-database" ]; then - exec "$T3CODE_FLATPAK_SERVER" --require "$T3CODE_FLATPAK_COMPILE_CACHE" "$T3CODE_FLATPAK_ENTRY" flatpak-compact-database --base-dir "$T3CODE_HOME" +# Desktop maintenance runs with the same runtime and sanitized host environment. +# The desktop stops its server first when compacting. No user-facing CLI is installed. +if [ "${1:-}" = "--database-maintenance" ]; then + shift + exec "$T3CODE_FLATPAK_SERVER" --require "$T3CODE_FLATPAK_COMPILE_CACHE" "$T3CODE_FLATPAK_ENTRY" flatpak-database-maintenance --base-dir "$T3CODE_HOME" "$@" fi exec "$T3CODE_FLATPAK_SERVER" --require "$T3CODE_FLATPAK_COMPILE_CACHE" "$T3CODE_FLATPAK_ENTRY" --bootstrap-fd 3 diff --git a/patches/0001-flatpak-desktop-integration.patch b/patches/0001-flatpak-desktop-integration.patch index 89f75ff..be1eaee 100644 --- a/patches/0001-flatpak-desktop-integration.patch +++ b/patches/0001-flatpak-desktop-integration.patch @@ -1,5 +1,5 @@ diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts -index 773ea2450..8eb05c265 100644 +index 773ea24509..8eb05c265a 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -37,6 +37,7 @@ export const DesktopConfig = Config.all({ @@ -11,7 +11,7 @@ index 773ea2450..8eb05c265 100644 devServerUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option), appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts -index 0015e6426..9c792f7ed 100644 +index 0015e64267..9c792f7ed3 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -1,3 +1,5 @@ @@ -35,10 +35,10 @@ index 0015e6426..9c792f7ed 100644 preference, env: input.env, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts -index fb6c5a8b1..b8be6acf4 100644 +index 200745f111..2b9aa94ef0 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts -@@ -39,6 +39,18 @@ const makeEnvironment = ( +@@ -38,6 +38,18 @@ const makeEnvironment = ( ) => DesktopEnvironment.DesktopEnvironment.pipe(Effect.provide(layerEnvironment(overrides, env))); describe("DesktopEnvironment", () => { @@ -54,11 +54,11 @@ index fb6c5a8b1..b8be6acf4 100644 + }), + ); + - it.effect("derives state paths and development identity inside Effect", () => + it.effect("uses the packaged Windows server sidecar as the backend root", () => Effect.gen(function* () { - const environment = yield* makeEnvironment( + const environment = yield* makeEnvironment({ diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts -index ecf08255f..04934beb7 100644 +index ecf08255fe..04934beb7c 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -167,10 +167,14 @@ const make = Effect.fn("desktop.environment.make")(function* ( @@ -91,7 +91,7 @@ index ecf08255f..04934beb7 100644 appImagePath: config.appImagePath, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts -index 295f63eaf..2e2f315cb 100644 +index 854f43c562..9fcc128aac 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,5 +1,5 @@ @@ -100,8 +100,8 @@ index 295f63eaf..2e2f315cb 100644 +import { assert, describe, it, vi } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; - import * as Fiber from "effect/Fiber"; -@@ -159,6 +159,15 @@ const emptyRecording = (): RecordedRegistration => ({ + import * as FileSystem from "effect/FileSystem"; +@@ -157,6 +157,15 @@ const emptyRecording = (): RecordedRegistration => ({ }); describe("DesktopLinuxUrlHandler", () => { @@ -118,7 +118,7 @@ index 295f63eaf..2e2f315cb 100644 const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ displayName: "T3 Code (Nightly)", diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts -index e03418964..bf2c12f4e 100644 +index e03418964e..bf2c12f4ef 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -9,6 +9,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -142,7 +142,7 @@ index e03418964..bf2c12f4e 100644 } yield* writeDesktopEntry; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts -index d27251d7b..cb5dfad04 100644 +index 143a6e5fd8..bf37ac682c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -60,6 +60,28 @@ vi.mock("node:child_process", () => ({ @@ -175,7 +175,7 @@ index d27251d7b..cb5dfad04 100644 appendSwitchMock.mockReset(); getSwitchValueMock.mockReset(); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts -index 58670439a..902fec467 100644 +index 58670439a5..902fec4675 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -9,6 +9,7 @@ import * as Layer from "effect/Layer"; @@ -279,7 +279,7 @@ index 58670439a..902fec467 100644 Electron.app.setDesktopName(linux.linuxDesktopEntryName); diff --git a/apps/desktop/src/app/Flatpak.ts b/apps/desktop/src/app/Flatpak.ts new file mode 100644 -index 000000000..006f94e00 +index 0000000000..006f94e000 --- /dev/null +++ b/apps/desktop/src/app/Flatpak.ts @@ -0,0 +1,5 @@ diff --git a/patches/0002-flatpak-host-backend.patch b/patches/0002-flatpak-host-backend.patch index 0154757..3b886c6 100644 --- a/patches/0002-flatpak-host-backend.patch +++ b/patches/0002-flatpak-host-backend.patch @@ -1,5 +1,5 @@ diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts -index dadbe8acc..79e41c7b6 100644 +index dadbe8acc7..79e41c7b66 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,5 +1,8 @@ @@ -38,7 +38,7 @@ index dadbe8acc..79e41c7b6 100644 diff --git a/apps/desktop/src/backend/flatpakBackend.test.ts b/apps/desktop/src/backend/flatpakBackend.test.ts new file mode 100644 -index 000000000..cfc536d2e +index 0000000000..cfc536d2e7 --- /dev/null +++ b/apps/desktop/src/backend/flatpakBackend.test.ts @@ -0,0 +1,86 @@ @@ -130,7 +130,7 @@ index 000000000..cfc536d2e +}); diff --git a/apps/desktop/src/backend/flatpakBackend.ts b/apps/desktop/src/backend/flatpakBackend.ts new file mode 100644 -index 000000000..e912b2119 +index 0000000000..e912b2119e --- /dev/null +++ b/apps/desktop/src/backend/flatpakBackend.ts @@ -0,0 +1,56 @@ diff --git a/patches/0003-flatpak-host-environment.patch b/patches/0003-flatpak-host-environment.patch index 55fa552..54b022a 100644 --- a/patches/0003-flatpak-host-environment.patch +++ b/patches/0003-flatpak-host-environment.patch @@ -1,8 +1,8 @@ diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts -index 3adf74bf7..141319707 100644 +index 9d12ed5a07..fd0201b2ab 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts -@@ -126,6 +126,21 @@ function runShellEnvironment(input: { +@@ -118,6 +118,21 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { @@ -21,11 +21,11 @@ index 3adf74bf7..141319707 100644 + }).pipe(Effect.tap(() => Effect.sync(() => assert.equal(env.PATH, "/app/bin:/usr/bin")))); + }); + - // Explicit opt-in keeps real shell coverage independent of globally installed tools. - it.effect.runIf(process.env.T3_TEST_NU_PATH !== undefined)( - "hydrates the desktop through a renamed Nushell symlink with native export conversions", + it.effect("rejects PATH output from a failed shell and tries the next candidate", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts -index 3fdec7932..1182af006 100644 +index 3fdec79323..1182af0062 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; @@ -45,7 +45,7 @@ index 3fdec7932..1182af006 100644 return installWindowsEnvironment(config); } diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts -index 94bfbe44c..9a069527d 100644 +index d2f24143fc..3bbff03927 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,4 +1,4 @@ @@ -54,7 +54,7 @@ index 94bfbe44c..9a069527d 100644 import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; -@@ -22,6 +22,28 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; +@@ -21,6 +21,28 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { @@ -80,11 +80,11 @@ index 94bfbe44c..9a069527d 100644 + ); + }); + - it("preserves complete causes for update poller and event failures", () => { - const cause = Cause.combine( - Cause.fail(new Error("updater failed")), + it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => + Effect.gen(function* () { + const linuxState = (packageType: string | undefined) => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts -index ae3c2588f..0c6557464 100644 +index ae3c2588fa..0c65574646 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,3 +1,4 @@ diff --git a/patches/0005-flatpak-build-artifact.patch b/patches/0005-flatpak-build-artifact.patch index 88e2df1..bd98886 100644 --- a/patches/0005-flatpak-build-artifact.patch +++ b/patches/0005-flatpak-build-artifact.patch @@ -1,5 +1,5 @@ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts -index d2cff4726..1de29fedc 100644 +index d2cff47263..1de29fedc6 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4114,10 +4114,17 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f diff --git a/patches/0006-flatpak-document-portal-paths.patch b/patches/0006-flatpak-document-portal-paths.patch index 83bdffb..819e652 100644 --- a/patches/0006-flatpak-document-portal-paths.patch +++ b/patches/0006-flatpak-document-portal-paths.patch @@ -1,33 +1,35 @@ diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts -index 2ed5a1f2f..aa1b59fde 100644 ---- a/apps/desktop/src/electron/ElectronDialog.test.ts +new file mode 100644 +index 0000000000..a9675e5e6f +--- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.test.ts -@@ -3,7 +3,7 @@ import * as Cause from "effect/Cause"; - import * as Effect from "effect/Effect"; - import * as Option from "effect/Option"; - import type { BrowserWindow } from "electron"; --import { beforeEach, vi } from "vite-plus/test"; +@@ -0,0 +1,86 @@ ++import { assert, describe, it } from "@effect/vitest"; ++import * as Effect from "effect/Effect"; ++import * as Option from "effect/Option"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; - - import * as ElectronDialog from "./ElectronDialog.ts"; - -@@ -12,6 +12,9 @@ const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted( - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), - })); -+const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); ++ ++import * as ElectronDialog from "./ElectronDialog.ts"; ++ ++const { showOpenDialogMock, execFileMock } = vi.hoisted(() => ({ ++ showOpenDialogMock: vi.fn(), ++ execFileMock: vi.fn(), ++})); + +vi.mock("node:child_process", () => ({ execFile: execFileMock })); - - vi.mock("electron", () => ({ - dialog: { -@@ -26,8 +29,69 @@ describe("ElectronDialog", () => { - showMessageBoxMock.mockReset(); - showOpenDialogMock.mockReset(); - showErrorBoxMock.mockReset(); ++ ++vi.mock("electron", () => ({ ++ dialog: { ++ showOpenDialog: showOpenDialogMock, ++ }, ++})); ++ ++describe("ElectronDialog", () => { ++ beforeEach(() => { ++ showOpenDialogMock.mockReset(); + execFileMock.mockReset(); - }); - ++ }); ++ + afterEach(() => vi.unstubAllEnvs()); + + it.effect("returns the host path for a Flatpak folder picker selection", () => @@ -87,12 +89,9 @@ index 2ed5a1f2f..aa1b59fde 100644 + assert.include((error.cause as Error).message, "could not resolve"); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); -+ - it.effect("preserves folder picker request context and cause", () => - Effect.gen(function* () { - const cause = new Error("folder picker failed"); ++}); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts -index b48953f11..2e868e089 100644 +index b48953f117..2e868e0893 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -1,3 +1,7 @@ diff --git a/patches/0007-flatpak-editor-discovery.patch b/patches/0007-flatpak-editor-discovery.patch index 4c9b488..8cdcffb 100644 --- a/patches/0007-flatpak-editor-discovery.patch +++ b/patches/0007-flatpak-editor-discovery.patch @@ -1,8 +1,8 @@ diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts -index 0dedda030..abdc4e011 100644 +index 73ff1b067e..3bb0ff2dcb 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts -@@ -260,6 +260,55 @@ it.effect("launches an installed editor with platform-safe arguments", () => +@@ -183,6 +183,55 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -55,11 +55,11 @@ index 0dedda030..abdc4e011 100644 + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + - for (const platform of ["darwin", "linux"] as const) { - it.effect.skipIf(windowsHost)(`launches Cursor in classic IDE mode on ${platform}`, () => - Effect.gen(function* () { + for (const {} of ["darwin", "linux"] as const) { + } + diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts -index 2c2485755..02c706422 100644 +index 1260f2e3fd..a04265ac20 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -133,6 +133,67 @@ const CommandLookupEnvConfig = Config.all({ @@ -130,7 +130,7 @@ index 2c2485755..02c706422 100644 function parseTargetPathAndPosition(target: string): Option.Option { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { -@@ -430,6 +491,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" +@@ -431,6 +492,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { const available: EditorId[] = []; @@ -138,7 +138,7 @@ index 2c2485755..02c706422 100644 for (const editor of EDITORS) { if (editor.commands === null) { -@@ -440,7 +502,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" +@@ -441,7 +503,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" } const command = yield* resolveEditorCommand(editor, env); @@ -147,7 +147,7 @@ index 2c2485755..02c706422 100644 available.push(editor.id); } } -@@ -519,8 +581,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( +@@ -520,8 +582,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( } if (editorDef.commands) { diff --git a/patches/0008-flatpak-cli-launcher.patch b/patches/0008-flatpak-cli-launcher.patch index 151de13..a934e0f 100644 --- a/patches/0008-flatpak-cli-launcher.patch +++ b/patches/0008-flatpak-cli-launcher.patch @@ -1,5 +1,5 @@ diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts -index 7b44fab01..82b91f7a3 100644 +index c9a79a81d3..f48de9686c 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ @@ -47,7 +47,7 @@ index 7b44fab01..82b91f7a3 100644 Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts -index 294f8a8b9..b0f2b05db 100644 +index 294f8a8b98..b0f2b05dba 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -9,6 +9,7 @@ import * as Stream from "effect/Stream"; @@ -77,7 +77,7 @@ index 294f8a8b9..b0f2b05db 100644 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = environment.path; diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts -index a9d9f04c4..f2c3787f1 100644 +index a9d9f04c4e..f2c3787f1a 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; diff --git a/patches/0009-flatpak-server-updates.patch b/patches/0009-flatpak-server-updates.patch index 3e882e7..621dfab 100644 --- a/patches/0009-flatpak-server-updates.patch +++ b/patches/0009-flatpak-server-updates.patch @@ -1,8 +1,8 @@ diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts -index 3a852c7bc..fd81bd5d1 100644 +index 352c89d06f..8e41d7ca64 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts -@@ -128,6 +128,28 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( +@@ -130,6 +130,28 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( }); it.layer(NodeServices.layer)("server self update", (it) => { @@ -32,18 +32,18 @@ index 3a852c7bc..fd81bd5d1 100644 Effect.gen(function* () { const events: string[] = []; diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts -index 10c2a7991..727feb6c7 100644 +index ef717c34bb..4f546a934c 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts -@@ -187,6 +187,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { +@@ -216,6 +216,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); const inFlight = yield* Ref.make(false); + const flatpakManaged = Boolean((yield* HostProcess.Environment).T3CODE_FLATPAK_SERVER); - // A managed server starting means its launcher has settled on a version, so runtimes left by - // earlier updates can go. -@@ -211,6 +212,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { + yield* pruneRuntimesOnStart({ baseDir: serverConfig.baseDir, managed: launcher.managed }).pipe( + Effect.forkScoped, +@@ -231,6 +232,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { @@ -55,7 +55,7 @@ index 10c2a7991..727feb6c7 100644 if (capability === "desktop-managed") { if (desktopAppUpdate.available) { return yield* desktopAppUpdate.run(reportProgress); -@@ -349,7 +355,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { +@@ -369,7 +375,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { return ServerSelfUpdate.of({ update, commitDesktopUpdate: (requestId, onHandoffAccepted) => @@ -69,10 +69,10 @@ index 10c2a7991..727feb6c7 100644 }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts -index b17668002..cc21bad86 100644 +index 7cebccb2a1..8eb81cb64e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts -@@ -96,7 +96,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { +@@ -90,7 +90,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { yield* fs.symlink(entry, `${prefix}/bin/t3`); const config = yield* makeServerConfig(baseDir); yield* fs.makeDirectory(config.stateDir, { recursive: true }); @@ -85,7 +85,7 @@ index b17668002..cc21bad86 100644 const descriptor = yield* Effect.gen(function* () { const environment = yield* ServerEnvironment.ServerEnvironment; return yield* environment.getDescriptor; -@@ -104,13 +108,15 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { +@@ -98,13 +102,15 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { Effect.provide( ServerEnvironment.layer.pipe( Layer.provide(layerEmptySecretStore), @@ -103,7 +103,7 @@ index b17668002..cc21bad86 100644 ); expect(descriptor.capabilities.serverInstallation).toEqual( mode === "web" ? { kind: "npm-global", prefix } : undefined, -@@ -118,6 +124,9 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { +@@ -112,6 +118,9 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(descriptor.capabilities.serverSelfUpdate).toBe( mode === "web" ? undefined : "desktop-managed", ); @@ -114,7 +114,7 @@ index b17668002..cc21bad86 100644 settledWorktreeCleanup: true, storageCleanupRun: true, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts -index fbb597056..6b1379d1f 100644 +index fbb5970560..6b1379d1f6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -202,8 +202,11 @@ export const make = Effect.gen(function* () { diff --git a/patches/0010-flatpak-browser-requirements.patch b/patches/0010-flatpak-browser-requirements.patch index b94765f..aa5caca 100644 --- a/patches/0010-flatpak-browser-requirements.patch +++ b/patches/0010-flatpak-browser-requirements.patch @@ -1,8 +1,17 @@ diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts -index 8c6b8b478..99ef56632 100644 +index b0b52a9362..cf1f0ed6f5 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts -@@ -75,6 +75,21 @@ const diagnose = (input: { +@@ -17,6 +17,8 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) + `; + + // Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted. ++const SANDBOX_ABORT = ++ "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/"; + + // What the real loader prints when the browser's `--version` cannot start it. + const LOADER_ERROR = +@@ -73,6 +75,21 @@ const diagnose = (input: { }; describe("diagnoseLaunchFailure", () => { @@ -21,11 +30,11 @@ index 8c6b8b478..99ef56632 100644 + }), + ); + - it.effect("turns Chrome's sandbox abort into the AppArmor steps", () => + it.effect("names every library the loader cannot find", () => Effect.gen(function* () { - const { error, commands } = yield* diagnose({ + const { error } = yield* diagnose({ diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts -index 179ba9a1d..5fe4890e7 100644 +index 179ba9a1d2..5fe4890e7b 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -57,18 +57,24 @@ export const DEBIAN_PACKAGES: ReadonlyArray> = [ @@ -67,7 +76,7 @@ index 179ba9a1d..5fe4890e7 100644 // Playwright reports Chrome's refusal to sandbox as root as a sandbox failure too. return (yield* HostProcess.UserId) === 0 diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts -index 5b386e5f0..891c19884 100644 +index 5b386e5f07..891c19884d 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2457,7 +2457,9 @@ const make = Effect.gen(function* () { @@ -82,7 +91,7 @@ index 5b386e5f0..891c19884 100644 } diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts -index 6b2d24802..7e98804be 100644 +index 6b2d24802d..7e98804be3 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -377,6 +377,17 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco @@ -122,7 +131,7 @@ index 6b2d24802..7e98804be 100644 }).pipe(Effect.scoped), ); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts -index ad42eaf5d..02f9dc27b 100644 +index ad42eaf5d7..02f9dc27b2 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -212,9 +212,9 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr @@ -138,10 +147,10 @@ index ad42eaf5d..02f9dc27b 100644 const isSandboxError = Schema.is(PreviewBrowserHost.PreviewBrowserSandboxError); const isLibrariesError = Schema.is(PreviewBrowserHost.PreviewBrowserLibrariesError); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx -index 27ae15e5e..5081b85b3 100644 +index c0de4f0d32..6769c49a03 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx -@@ -872,7 +872,9 @@ export function ServerBrowserSurface(props: { +@@ -870,7 +870,9 @@ export function ServerBrowserSurface(props: {

{previewStreamHostSetupMessage(hostSetup)}

@@ -153,7 +162,7 @@ index 27ae15e5e..5081b85b3 100644 Try again diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts -index b71fff335..9d5ab790a 100644 +index b71fff335f..9d5ab790a2 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -454,7 +454,13 @@ const decodeHostSetup = (reason: string): PreviewStreamHostSetup => { @@ -173,7 +182,7 @@ index b71fff335..9d5ab790a 100644 : "This server's host is missing libraries its browser needs. Run this once on the host, then try again:"; +}; diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts -index 455663155..793b30a9f 100644 +index 455663155c..793b30a9ff 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -154,6 +154,7 @@ export type PreviewRuntime = typeof PreviewRuntime.Type; diff --git a/patches/0011-flatpak-database-maintenance.patch b/patches/0011-flatpak-database-maintenance.patch index f678721..1f6ec8e 100644 --- a/patches/0011-flatpak-database-maintenance.patch +++ b/patches/0011-flatpak-database-maintenance.patch @@ -1,15 +1,18 @@ diff --git a/apps/desktop/src/app/DesktopDatabaseMaintenance.test.ts b/apps/desktop/src/app/DesktopDatabaseMaintenance.test.ts new file mode 100644 -index 0000000000..dc19d91042 +index 0000000000..7799a47cea --- /dev/null +++ b/apps/desktop/src/app/DesktopDatabaseMaintenance.test.ts -@@ -0,0 +1,195 @@ +@@ -0,0 +1,252 @@ ++import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, afterEach, it, vi } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; ++import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; ++import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; @@ -34,13 +37,22 @@ index 0000000000..dc19d91042 + ready?: boolean; + confirm?: Effect.Effect; + stoppedPid?: number; ++ legacyDatabase?: boolean; ++ checkbox?: boolean; + } = {}, +) { ++ const fs = yield* FileSystem.FileSystem; ++ const path = yield* Path.Path; ++ const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-flatpak-state-" }); ++ if (options.legacyDatabase) { ++ yield* fs.writeFileString(path.join(stateDir, "state.sqlite"), "rollback"); ++ } + vi.stubEnv("FLATPAK_ID", "com.t3tools.t3code"); + vi.stubEnv("T3CODE_FLATPAK_RUNTIME", options.runtime ?? runtime); + const order: string[] = []; + const errors: string[] = []; + const commands: ChildProcess.Command[] = []; ++ const confirmations: Electron.MessageBoxOptions[] = []; + let stopped = false; + const primary: DesktopBackendPool.DesktopBackendInstance = { + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, @@ -73,10 +85,14 @@ index 0000000000..dc19d91042 + pickFiles: () => Effect.die("unexpected file picker"), + showMessageBox: (input) => + Effect.gen(function* () { -+ const confirm = input.type === "question"; ++ const confirm = input.buttons !== undefined && input.buttons.length > 1; + order.push(confirm ? "confirm" : "success"); ++ if (confirm) confirmations.push(input); + if (confirm && options.confirm) yield* options.confirm; -+ return { response: confirm ? (options.response ?? 1) : 0, checkboxChecked: false }; ++ return { ++ response: confirm ? (options.response ?? 1) : 0, ++ checkboxChecked: options.checkbox ?? false, ++ }; + }), + showErrorBox: (_title, message) => + Effect.sync(() => { @@ -86,8 +102,7 @@ index 0000000000..dc19d91042 + }); + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { -+ assert.isTrue(stopped, "cleanup must not start before the server stops"); -+ order.push("compact"); ++ order.push(stopped ? "compact" : "live"); + commands.push(command); + const stdout = Stream.make(new TextEncoder().encode("Database: 2 GiB -> 1 GiB.")); + const stderr = Stream.make(new TextEncoder().encode("Not enough disk space.")); @@ -114,6 +129,7 @@ index 0000000000..dc19d91042 + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "linux", + baseDir, ++ stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.succeed(ElectronDialog.ElectronDialog, dialog), + Layer.mock(ElectronWindow.ElectronWindow)({ @@ -123,55 +139,57 @@ index 0000000000..dc19d91042 + ), + ), + ); -+ return { maintenance, order, errors, commands }; ++ return { maintenance, order, errors, commands, confirmations }; +}); + +it.effect( + "stops the server, cleans only its own database, and restarts before reporting success", + () => + Effect.gen(function* () { -+ const { maintenance, order, commands } = yield* harness(); -+ yield* maintenance.compact; ++ const { maintenance, order, commands, confirmations } = yield* harness(); ++ yield* maintenance.run({ reclaimSpace: true }); + assert.deepEqual(order, ["confirm", "stop", "compact", "start", "ready", "success"]); ++ assert.isUndefined(confirmations[0]?.checkboxLabel); + const command = commands[0]!; + assert.equal(command._tag, "StandardCommand"); + if (command._tag !== "StandardCommand") return; + assert.equal(command.command, "/usr/bin/flatpak-spawn"); -+ assert.deepEqual(command.args.slice(-3), [ ++ assert.deepEqual(command.args.slice(-4), [ + "/bin/sh", + `${runtime}/host-server.sh`, -+ "--compact-database", ++ "--database-maintenance", ++ "--compact", + ]); + assert.include(command.args, `--env=T3CODE_FLATPAK_SERVER=${runtime}/t3code`); + assert.include(command.args, `--env=T3CODE_HOME=${baseDir}`); -+ }), ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancelling the confirmation leaves the server running", () => + Effect.gen(function* () { + const { maintenance, order } = yield* harness({ response: 0 }); -+ yield* maintenance.compact; ++ yield* maintenance.run({ reclaimSpace: true }); + assert.deepEqual(order, ["confirm"]); -+ }), ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("restarts after cleanup failure and reports the actual failure", () => + Effect.gen(function* () { + const { maintenance, order, errors } = yield* harness({ exitCode: 1 }); -+ yield* maintenance.compact; ++ yield* maintenance.run({ reclaimSpace: true }); + assert.deepEqual(order, ["confirm", "stop", "compact", "start", "ready", "error"]); + assert.include(errors[0]!, "Not enough disk space."); + assert.include(errors[0]!, "The local server has restarted."); -+ }), ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not run maintenance when the server has not exited", () => + Effect.gen(function* () { + const { maintenance, order, commands } = yield* harness({ stoppedPid: 123 }); -+ yield* maintenance.compact; ++ yield* maintenance.run({ reclaimSpace: true }); + assert.deepEqual(order, ["confirm", "stop", "start", "ready", "error"]); + assert.equal(commands.length, 0); -+ }), ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("ignores a second request while confirmation is open", () => @@ -181,37 +199,78 @@ index 0000000000..dc19d91042 + const { maintenance, order } = yield* harness({ + confirm: Deferred.succeed(opened, undefined).pipe(Effect.andThen(Deferred.await(proceed))), + }); -+ const first = yield* maintenance.compact.pipe(Effect.forkChild); ++ const first = yield* maintenance.run({ reclaimSpace: true }).pipe(Effect.forkChild); + yield* Deferred.await(opened); -+ yield* maintenance.compact; ++ yield* maintenance.run({ reclaimSpace: true }); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(first); + assert.equal(order.filter((entry) => entry === "compact").length, 1); + assert.equal(order.filter((entry) => entry === "confirm").length, 1); -+ }).pipe(Effect.scoped), ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not expose cleanup without a Flatpak runtime", () => + Effect.gen(function* () { + const { maintenance, order } = yield* harness({ runtime: "" }); + assert.isFalse(maintenance.supported); -+ yield* maintenance.compact; ++ yield* maintenance.run({ reclaimSpace: true }); + assert.deepEqual(order, []); -+ }), ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ++); ++ ++it.effect("offers deleting the legacy database, unticked, alongside reclaiming space", () => ++ Effect.gen(function* () { ++ const { maintenance, commands, confirmations } = yield* harness({ ++ legacyDatabase: true, ++ checkbox: true, ++ }); ++ assert.equal(yield* maintenance.legacyDatabaseBytes, "rollback".length); ++ yield* maintenance.run({ reclaimSpace: true }); ++ assert.isString(confirmations[0]?.checkboxLabel); ++ assert.isFalse(confirmations[0]?.checkboxChecked); ++ const command = commands[0]!; ++ assert.equal(command._tag, "StandardCommand"); ++ if (command._tag !== "StandardCommand") return; ++ assert.deepEqual(command.args.slice(-2), ["--compact", "--remove-legacy"]); ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ++); ++ ++it.effect("deletes only the legacy database without stopping the server", () => ++ Effect.gen(function* () { ++ const { maintenance, order, commands } = yield* harness({ legacyDatabase: true }); ++ yield* maintenance.run({ reclaimSpace: false }); ++ assert.deepEqual(order, ["confirm", "live", "success"]); ++ const command = commands[0]!; ++ assert.equal(command._tag, "StandardCommand"); ++ if (command._tag !== "StandardCommand") return; ++ assert.deepEqual(command.args.slice(-2), ["--database-maintenance", "--remove-legacy"]); ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ++); ++ ++it.effect("does nothing when only the legacy database was requested and it is gone", () => ++ Effect.gen(function* () { ++ const { maintenance, order, confirmations } = yield* harness(); ++ assert.isNull(yield* maintenance.legacyDatabaseBytes); ++ yield* maintenance.run({ reclaimSpace: false }); ++ assert.deepEqual(order, []); ++ assert.equal(confirmations.length, 0); ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/app/DesktopDatabaseMaintenance.ts b/apps/desktop/src/app/DesktopDatabaseMaintenance.ts new file mode 100644 -index 0000000000..c718d5a964 +index 0000000000..a407e26ed7 --- /dev/null +++ b/apps/desktop/src/app/DesktopDatabaseMaintenance.ts -@@ -0,0 +1,173 @@ +@@ -0,0 +1,246 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; ++import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; ++import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; @@ -230,12 +289,23 @@ index 0000000000..c718d5a964 + { message: Schema.String }, +) {} + -+/** Coordinates offline cleanup of the Flatpak desktop's own database. */ ++const formatSize = (bytes: number) => `${(bytes / 1024 ** 2).toFixed(0)} MiB`; ++ ++/** ++ * Coordinates the `t3 doctor` database repairs for the Flatpak desktop's own database, ++ * since the Flatpak installs no `t3` command. ++ */ +export class DesktopDatabaseMaintenance extends Context.Service< + DesktopDatabaseMaintenance, + { + readonly supported: boolean; -+ readonly compact: Effect.Effect; ++ /** Size of the database kept from before 0.1.0 for rolling back, or null once it is gone. */ ++ readonly legacyDatabaseBytes: Effect.Effect; ++ /** ++ * Confirms, then reclaims space while the server is stopped and/or deletes the legacy ++ * database. The legacy database is only offered while it exists. ++ */ ++ readonly run: (input: { readonly reclaimSpace: boolean }) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDatabaseMaintenance") {} + @@ -247,176 +317,242 @@ index 0000000000..c718d5a964 + const windows = yield* ElectronWindow.ElectronWindow; + const state = yield* DesktopState.DesktopState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; ++ const fs = yield* FileSystem.FileSystem; ++ const path = yield* Path.Path; + const busy = yield* Ref.make(false); + const runtime = process.env.T3CODE_FLATPAK_RUNTIME; + const supported = + flatpakAppId(process.env, environment.platform) !== null && runtime?.startsWith("/") === true; ++ // Matches the server's DatabaseMaintenance, which keeps it next to statev2.sqlite. ++ const legacyPath = path.join(environment.stateDir, "state.sqlite"); + -+ const compact = Effect.gen(function* () { -+ if (!supported || !runtime || (yield* Ref.get(state.quitting))) return; -+ if (yield* Ref.getAndSet(busy, true)) return; -+ yield* Effect.gen(function* () { -+ const owner = Option.getOrUndefined(yield* windows.currentMainOrFirst); -+ const confirmation = yield* dialog.showMessageBox( -+ { -+ type: "question", -+ title: "Reclaim database space", -+ message: "Reclaim space and restart the local server?", -+ detail: -+ "Finish active work first. T3 Code will temporarily disconnect while its local server stops and database cleanup runs. This can take several minutes and needs temporary disk space. The old rollback database will be kept.", -+ buttons: ["Cancel", "Reclaim space and restart"], -+ defaultId: 0, -+ cancelId: 0, -+ noLink: true, -+ }, -+ owner, -+ ); -+ if (confirmation.response !== 1 || (yield* Ref.get(state.quitting))) return; -+ const primary = yield* pool.primary; -+ const snapshot = yield* primary.snapshot; -+ if (!snapshot.ready) { -+ yield* dialog.showErrorBox( -+ "Database cleanup unavailable", -+ "Wait for the local server to finish starting, then try again.", -+ ); -+ return; -+ } ++ const legacyDatabaseBytes = fs.stat(legacyPath).pipe( ++ Effect.map((info) => Number(info.size)), ++ Effect.orElseSucceed(() => null), ++ ); + -+ // Run the installed package directly on the host, with no CLI launcher or service install. -+ const result = yield* Effect.gen(function* () { -+ yield* primary.stop(); -+ const stopped = yield* primary.snapshot; -+ if (Option.isSome(stopped.activePid)) { ++ // Run the installed package directly on the host, with no CLI launcher or service install. ++ const runHostMaintenance = (flags: ReadonlyArray) => ++ Effect.scoped( ++ Effect.gen(function* () { ++ const child = yield* spawner.spawn( ++ ChildProcess.make( ++ "/usr/bin/flatpak-spawn", ++ [ ++ "--host", ++ "--watch-bus", ++ `--directory=${runtime}`, ++ `--env=T3CODE_HOME=${environment.baseDir}`, ++ `--env=T3CODE_FLATPAK_SERVER=${runtime}/t3code`, ++ `--env=T3CODE_FLATPAK_ENTRY=${runtime}/resources/app.asar/apps/server/dist/bin.mjs`, ++ `--env=T3CODE_FLATPAK_COMPILE_CACHE=${runtime}/resources/app.asar/apps/desktop/dist-electron/compileCache.cjs`, ++ "/bin/sh", ++ `${runtime}/host-server.sh`, ++ "--database-maintenance", ++ ...flags, ++ ], ++ { stdin: "ignore" }, ++ ), ++ ); ++ const [output, errors, code] = yield* Effect.all( ++ [ ++ child.stdout.pipe(Stream.decodeText(), Stream.mkString), ++ child.stderr.pipe(Stream.decodeText(), Stream.mkString), ++ child.exitCode, ++ ], ++ { concurrency: "unbounded" }, ++ ); ++ if (code !== 0) { + return yield* Effect.fail( + new DesktopDatabaseCleanupError({ -+ message: "The local server did not stop. Database cleanup was not started.", ++ message: errors.trim() || output.trim() || "Database cleanup failed.", + }), + ); + } -+ return yield* Effect.scoped( -+ Effect.gen(function* () { -+ const child = yield* spawner.spawn( -+ ChildProcess.make( -+ "/usr/bin/flatpak-spawn", -+ [ -+ "--host", -+ "--watch-bus", -+ `--directory=${runtime}`, -+ `--env=T3CODE_HOME=${environment.baseDir}`, -+ `--env=T3CODE_FLATPAK_SERVER=${runtime}/t3code`, -+ `--env=T3CODE_FLATPAK_ENTRY=${runtime}/resources/app.asar/apps/server/dist/bin.mjs`, -+ `--env=T3CODE_FLATPAK_COMPILE_CACHE=${runtime}/resources/app.asar/apps/desktop/dist-electron/compileCache.cjs`, -+ "/bin/sh", -+ `${runtime}/host-server.sh`, -+ "--compact-database", -+ ], -+ { stdin: "ignore" }, -+ ), -+ ); -+ const [output, errors, code] = yield* Effect.all( -+ [ -+ child.stdout.pipe(Stream.decodeText(), Stream.mkString), -+ child.stderr.pipe(Stream.decodeText(), Stream.mkString), -+ child.exitCode, -+ ], -+ { concurrency: "unbounded" }, -+ ); -+ if (code !== 0) { -+ return yield* Effect.fail( -+ new DesktopDatabaseCleanupError({ -+ message: errors.trim() || output.trim() || "Database cleanup failed.", ++ return output.trim(); ++ }), ++ ); ++ ++ const errorMessage = (cause: Cause.Cause) => { ++ const error = Cause.squash(cause); ++ return error instanceof Error ? error.message : String(error); ++ }; ++ ++ const run = (input: { readonly reclaimSpace: boolean }) => ++ Effect.gen(function* () { ++ if (!supported || !runtime || (yield* Ref.get(state.quitting))) return; ++ if (yield* Ref.getAndSet(busy, true)) return; ++ yield* Effect.gen(function* () { ++ const owner = Option.getOrUndefined(yield* windows.currentMainOrFirst); ++ const legacyBytes = yield* legacyDatabaseBytes; ++ ++ if (!input.reclaimSpace) { ++ if (legacyBytes === null) return; ++ const confirmation = yield* dialog.showMessageBox( ++ { ++ type: "warning", ++ title: "Delete old database", ++ message: "Delete the database from before 0.1.0?", ++ detail: `Frees ${formatSize(legacyBytes)}. Your threads and settings are already in the current database. After this, T3 Code versions before 0.1.0 start with no threads or settings, so you can no longer roll back to them.`, ++ buttons: ["Cancel", "Delete database"], ++ defaultId: 0, ++ cancelId: 0, ++ noLink: true, ++ }, ++ owner, ++ ); ++ if (confirmation.response !== 1 || (yield* Ref.get(state.quitting))) return; ++ // Only versions before 0.1.0 open this file, so the server keeps running. ++ const result = yield* runHostMaintenance(["--remove-legacy"]).pipe(Effect.exit); ++ if (Exit.isFailure(result)) { ++ yield* dialog.showErrorBox("Database cleanup failed", errorMessage(result.cause)); ++ return; ++ } ++ yield* dialog.showMessageBox( ++ { ++ type: "info", ++ title: "Database cleanup complete", ++ message: "Deleted the database from before 0.1.0.", ++ buttons: ["OK"], ++ }, ++ owner, ++ ); ++ return; ++ } ++ ++ const confirmation = yield* dialog.showMessageBox( ++ { ++ type: "question", ++ title: "Reclaim database space", ++ message: "Reclaim space and restart the local server?", ++ detail: ++ "Finish active work first. T3 Code will temporarily disconnect while its local server stops and database cleanup runs. This can take several minutes and needs temporary disk space. Afterwards, the server returns freed space to disk on its own.", ++ buttons: ["Cancel", "Reclaim space and restart"], ++ defaultId: 0, ++ cancelId: 0, ++ noLink: true, ++ ...(legacyBytes === null ++ ? {} ++ : { ++ checkboxLabel: `Also delete the database from before 0.1.0 (${formatSize(legacyBytes)}). T3 Code versions before 0.1.0 will then start with no threads or settings.`, ++ checkboxChecked: false, + }), -+ ); -+ } -+ return output.trim(); -+ }), ++ }, ++ owner, + ); -+ }).pipe( -+ Effect.ensuring( -+ Effect.gen(function* () { -+ if (!(yield* Ref.get(state.quitting))) yield* primary.start; -+ }), -+ ), -+ Effect.exit, -+ ); -+ if (yield* Ref.get(state.quitting)) return; -+ const ready = yield* primary.waitForReady(Duration.seconds(60)); -+ if (Exit.isFailure(result)) { -+ const error = Cause.squash(result.cause); -+ yield* dialog.showErrorBox( -+ "Database cleanup failed", -+ `${error instanceof Error ? error.message : String(error)}\n\n${ready ? "The local server has restarted." : "The local server has not become ready. Close and reopen T3 Code to retry starting it."}`, ++ if (confirmation.response !== 1 || (yield* Ref.get(state.quitting))) return; ++ const primary = yield* pool.primary; ++ const snapshot = yield* primary.snapshot; ++ if (!snapshot.ready) { ++ yield* dialog.showErrorBox( ++ "Database cleanup unavailable", ++ "Wait for the local server to finish starting, then try again.", ++ ); ++ return; ++ } ++ ++ const result = yield* Effect.gen(function* () { ++ yield* primary.stop(); ++ const stopped = yield* primary.snapshot; ++ if (Option.isSome(stopped.activePid)) { ++ return yield* Effect.fail( ++ new DesktopDatabaseCleanupError({ ++ message: "The local server did not stop. Database cleanup was not started.", ++ }), ++ ); ++ } ++ return yield* runHostMaintenance([ ++ "--compact", ++ ...(confirmation.checkboxChecked ? ["--remove-legacy"] : []), ++ ]); ++ }).pipe( ++ Effect.ensuring( ++ Effect.gen(function* () { ++ if (!(yield* Ref.get(state.quitting))) yield* primary.start; ++ }), ++ ), ++ Effect.exit, + ); -+ return; -+ } -+ yield* dialog.showMessageBox( -+ { -+ type: ready ? "info" : "warning", -+ title: "Database cleanup complete", -+ message: ready -+ ? "Database space reclaimed. The local server has restarted." -+ : "Database space reclaimed, but the local server has not become ready.", -+ detail: -+ result.value + -+ (ready ? "" : "\nClose and reopen T3 Code to retry starting the server."), -+ buttons: ["OK"], -+ }, -+ owner, -+ ); -+ }).pipe(Effect.ensuring(Ref.set(busy, false))); -+ }).pipe( -+ Effect.catchCause((cause) => { -+ const error = Cause.squash(cause); -+ return dialog.showErrorBox( -+ "Database cleanup failed", -+ error instanceof Error ? error.message : String(error), -+ ); -+ }), -+ Effect.withSpan("desktop.databaseMaintenance.compact"), -+ ); -+ return DesktopDatabaseMaintenance.of({ supported, compact }); ++ if (yield* Ref.get(state.quitting)) return; ++ const ready = yield* primary.waitForReady(Duration.seconds(60)); ++ if (Exit.isFailure(result)) { ++ yield* dialog.showErrorBox( ++ "Database cleanup failed", ++ `${errorMessage(result.cause)}\n\n${ready ? "The local server has restarted." : "The local server has not become ready. Close and reopen T3 Code to retry starting it."}`, ++ ); ++ return; ++ } ++ yield* dialog.showMessageBox( ++ { ++ type: ready ? "info" : "warning", ++ title: "Database cleanup complete", ++ message: ready ++ ? "Database space reclaimed. The local server has restarted." ++ : "Database space reclaimed, but the local server has not become ready.", ++ detail: ++ result.value + ++ (ready ? "" : "\nClose and reopen T3 Code to retry starting the server."), ++ buttons: ["OK"], ++ }, ++ owner, ++ ); ++ }).pipe(Effect.ensuring(Ref.set(busy, false))); ++ }).pipe( ++ Effect.catchCause((cause) => ++ dialog.showErrorBox("Database cleanup failed", errorMessage(cause)), ++ ), ++ Effect.withSpan("desktop.databaseMaintenance.run"), ++ ); ++ ++ return DesktopDatabaseMaintenance.of({ supported, legacyDatabaseBytes, run }); +}); + +export const layer = Layer.effect(DesktopDatabaseMaintenance, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts -index 2d67aa56e6..cc799ba42c 100644 +index 2d67aa56e6..d1dfb09afe 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts -@@ -84,6 +84,8 @@ import { +@@ -84,6 +84,12 @@ import { uninstallCliCommand, } from "./methods/cliCommand.ts"; -+import { getDatabaseMaintenanceSupported, compactDatabase } from "./methods/databaseMaintenance.ts"; ++import { ++ getDatabaseMaintenanceSupported, ++ getLegacyDatabaseBytes, ++ runDatabaseMaintenance, ++} from "./methods/databaseMaintenance.ts"; + export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* installNotificationBadge(); -@@ -93,6 +95,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" +@@ -93,6 +99,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.complete); yield* ipc.handle(WebLinksIpc.setReady); + yield* ipc.handleSync(getDatabaseMaintenanceSupported); -+ yield* ipc.handle(compactDatabase); ++ yield* ipc.handle(getLegacyDatabaseBytes); ++ yield* ipc.handle(runDatabaseMaintenance); yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts -index 9b11a2283a..5d5a604538 100644 +index 9b11a2283a..ab6a56e583 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts -@@ -125,3 +125,6 @@ export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider- +@@ -125,3 +125,7 @@ export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider- export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; + +export const DATABASE_MAINTENANCE_SUPPORTED_CHANNEL = "desktop:database-maintenance-supported"; -+export const DATABASE_MAINTENANCE_COMPACT_CHANNEL = "desktop:database-maintenance-compact"; ++export const DATABASE_MAINTENANCE_LEGACY_BYTES_CHANNEL = "desktop:database-maintenance-legacy-bytes"; ++export const DATABASE_MAINTENANCE_RUN_CHANNEL = "desktop:database-maintenance-run"; diff --git a/apps/desktop/src/ipc/methods/databaseMaintenance.ts b/apps/desktop/src/ipc/methods/databaseMaintenance.ts new file mode 100644 -index 0000000000..2bb55574f9 +index 0000000000..00cb4b9191 --- /dev/null +++ b/apps/desktop/src/ipc/methods/databaseMaintenance.ts -@@ -0,0 +1,23 @@ +@@ -0,0 +1,33 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + @@ -432,12 +568,22 @@ index 0000000000..2bb55574f9 + }), +}); + -+export const compactDatabase = makeIpcMethod({ -+ channel: IpcChannels.DATABASE_MAINTENANCE_COMPACT_CHANNEL, ++export const getLegacyDatabaseBytes = makeIpcMethod({ ++ channel: IpcChannels.DATABASE_MAINTENANCE_LEGACY_BYTES_CHANNEL, + payload: Schema.Void, ++ result: Schema.NullOr(Schema.Number), ++ handler: Effect.fn("desktop.ipc.databaseMaintenance.legacyDatabaseBytes")(function* () { ++ return yield* (yield* DesktopDatabaseMaintenance.DesktopDatabaseMaintenance) ++ .legacyDatabaseBytes; ++ }), ++}); ++ ++export const runDatabaseMaintenance = makeIpcMethod({ ++ channel: IpcChannels.DATABASE_MAINTENANCE_RUN_CHANNEL, ++ payload: Schema.Struct({ reclaimSpace: Schema.Boolean }), + result: Schema.Void, -+ handler: Effect.fn("desktop.ipc.databaseMaintenance.compact")(function* () { -+ yield* (yield* DesktopDatabaseMaintenance.DesktopDatabaseMaintenance).compact; ++ handler: Effect.fn("desktop.ipc.databaseMaintenance.run")(function* (input) { ++ yield* (yield* DesktopDatabaseMaintenance.DesktopDatabaseMaintenance).run(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts @@ -461,17 +607,19 @@ index 3056ce1ea2..755abd68b2 100644 ).pipe( Layer.provideMerge(layerDesktopSnapShot), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts -index bffa02d9ca..7c25290483 100644 +index bffa02d9ca..495f2ce1b4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts -@@ -271,6 +271,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { +@@ -271,6 +271,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + ...(ipcRenderer.sendSync(IpcChannels.DATABASE_MAINTENANCE_SUPPORTED_CHANNEL) === true + ? { + databaseMaintenance: { -+ compact: () => ipcRenderer.invoke(IpcChannels.DATABASE_MAINTENANCE_COMPACT_CHANNEL), ++ legacyDatabaseBytes: () => ++ ipcRenderer.invoke(IpcChannels.DATABASE_MAINTENANCE_LEGACY_BYTES_CHANNEL), ++ run: (input) => ipcRenderer.invoke(IpcChannels.DATABASE_MAINTENANCE_RUN_CHANNEL, input), + }, + } + : {}), @@ -479,25 +627,25 @@ index bffa02d9ca..7c25290483 100644 getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts -index b0a2ceffef..78198fdc50 100644 +index 2f7c24536c..95fe9f964d 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -1,4 +1,4 @@ -import { doctorCommand } from "./cli/doctor.ts"; -+import { doctorCommand, flatpakCompactDatabaseCommand } from "./cli/doctor.ts"; ++import { doctorCommand, flatpakDatabaseMaintenanceCommand } from "./cli/doctor.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -@@ -87,6 +87,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => +@@ -89,6 +89,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => themeCommand, traceCommand, doctorCommand, -+ flatpakCompactDatabaseCommand, ++ flatpakDatabaseMaintenanceCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/cli/doctor.test.ts b/apps/server/src/cli/doctor.test.ts -index 5743763254..fd7e06c9c4 100644 +index 5743763254..a29fecfdcc 100644 --- a/apps/server/src/cli/doctor.test.ts +++ b/apps/server/src/cli/doctor.test.ts @@ -1,10 +1,21 @@ @@ -519,17 +667,17 @@ index 5743763254..fd7e06c9c4 100644 import * as BootService from "../cloud/bootService.ts"; -import { runDoctor } from "./doctor.ts"; -+import { runDoctor, flatpakCompactDatabaseCommand } from "./doctor.ts"; ++import { runDoctor, flatpakDatabaseMaintenanceCommand } from "./doctor.ts"; import { DatabaseMaintenance } from "../persistence/DatabaseMaintenance.ts"; it.effect( -@@ -55,3 +66,42 @@ it.effect( +@@ -55,3 +66,51 @@ it.effect( assert.deepEqual(calls, ["pause", "compact", "resume"]); }), ); + +it.effect( -+ "the Flatpak desktop command compacts its database without a service and keeps the rollback database", ++ "the Flatpak desktop command compacts without a service and deletes the rollback database only on request", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; @@ -548,26 +696,35 @@ index 5743763254..fd7e06c9c4 100644 + ), + ), + ); -+ yield* fs.writeFileString(legacy, "retained rollback database"); -+ yield* Command.runWith(flatpakCompactDatabaseCommand, { version: "0.0.0" })([ -+ "--base-dir", -+ baseDir, -+ ]).pipe( -+ Effect.provide(NetService.layer), -+ Effect.provideService(HostProcess.Environment, { -+ T3CODE_FLATPAK_SERVER: "/installed/flatpak/t3code", -+ }), ++ yield* Effect.flatMap(SqlClient.SqlClient, (sql) => sql`CREATE TABLE rollback (id)`).pipe( ++ Effect.provide(NodeSqliteClient.layer({ filename: legacy })), + ); -+ assert.equal(yield* fs.readFileString(legacy), "retained rollback database"); ++ const run = (...args: ReadonlyArray) => ++ Command.runWith(flatpakDatabaseMaintenanceCommand, { version: "0.0.0" })([ ++ "--base-dir", ++ baseDir, ++ ...args, ++ ]).pipe( ++ Effect.provide(NetService.layer), ++ Effect.provideService(HostProcess.Environment, { ++ T3CODE_FLATPAK_SERVER: "/installed/flatpak/t3code", ++ }), ++ ); ++ ++ yield* run("--compact"); ++ assert.isTrue(yield* fs.exists(legacy)); + const mode = yield* Effect.flatMap( + SqlClient.SqlClient, + (sql) => sql<{ auto_vacuum: number }>`PRAGMA auto_vacuum`, + ).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true }))); + assert.equal(mode[0]?.auto_vacuum, 2); ++ ++ yield* run("--remove-legacy"); ++ assert.isFalse(yield* fs.exists(legacy)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/doctor.ts b/apps/server/src/cli/doctor.ts -index 850e67daf5..92b639c0d8 100644 +index 850e67daf5..d2a69f67be 100644 --- a/apps/server/src/cli/doctor.ts +++ b/apps/server/src/cli/doctor.ts @@ -1,8 +1,10 @@ @@ -581,14 +738,19 @@ index 850e67daf5..92b639c0d8 100644 import * as BootService from "../cloud/bootService.ts"; import * as DatabaseMaintenance from "../persistence/DatabaseMaintenance.ts"; -@@ -148,3 +150,29 @@ export const doctorCommand = Command.make("doctor", { +@@ -148,3 +150,40 @@ export const doctorCommand = Command.make("doctor", { }), ), ); + -+/** Internal Flatpak desktop action. Never reconciles services or removes the rollback database. */ -+export const flatpakCompactDatabaseCommand = Command.make("flatpak-compact-database", { ++/** ++ * Internal Flatpak desktop action, run by the desktop after its confirmation dialog. ++ * Never reconciles services. `--compact` needs the desktop to have stopped its server. ++ */ ++export const flatpakDatabaseMaintenanceCommand = Command.make("flatpak-database-maintenance", { + ...projectLocationFlags, ++ compact: Flag.Boolean("compact").pipe(Flag.withDefault(false)), ++ removeLegacy: Flag.Boolean("remove-legacy").pipe(Flag.withDefault(false)), +}).pipe( + Command.unlisted, + Command.withHandler((flags) => @@ -601,25 +763,33 @@ index 850e67daf5..92b639c0d8 100644 + ); + } + const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); -+ const result = yield* Effect.gen(function* () { ++ yield* Effect.gen(function* () { + const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance; -+ return yield* maintenance.compact(config.dbPath); ++ if (flags.compact) { ++ const result = yield* maintenance.compact(config.dbPath); ++ yield* Console.log( ++ `Database: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}. ${result.deletedEventCount} superseded events removed.`, ++ ); ++ } ++ if (flags.removeLegacy) { ++ yield* maintenance.removeLegacyDatabase(config.dbPath); ++ yield* Console.log("Deleted the database from before 0.1.0."); ++ } + }).pipe(Effect.provide(DatabaseMaintenance.layer)); -+ yield* Console.log( -+ `Database: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}. ${result.deletedEventCount} superseded events removed.`, -+ ); + }), + ), +); diff --git a/apps/web/src/components/settings/DatabaseStorageNotice.tsx b/apps/web/src/components/settings/DatabaseStorageNotice.tsx -index 637c920f5e..00f726a89f 100644 +index 08ccf816a9..fa8f1d484c 100644 --- a/apps/web/src/components/settings/DatabaseStorageNotice.tsx +++ b/apps/web/src/components/settings/DatabaseStorageNotice.tsx -@@ -1,15 +1,18 @@ +@@ -1,42 +1,123 @@ import { useAtomValue } from "@effect/atom-react"; - import { shouldRecommendDatabaseCompaction, type EnvironmentId } from "@t3tools/contracts"; +-import type { EnvironmentId } from "@t3tools/contracts"; ++import type { DatabaseStorageStatus, EnvironmentId } from "@t3tools/contracts"; ++import { AsyncResult, Atom } from "effect/reactivity"; import * as Schema from "effect/Schema"; -+import { useState } from "react"; ++import { useEffect, useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useLocalStorage } from "../../hooks/useLocalStorage"; @@ -627,27 +797,62 @@ index 637c920f5e..00f726a89f 100644 import { serverEnvironment } from "../../state/server"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Button } from "../ui/button"; - import { SettingsGroup } from "./SettingsGroup"; import { SettingsRow, SettingsSection } from "./settingsLayout"; --/** Suggests `t3 doctor` for a database that still holds at least 1 GiB of freed pages. */ -+/** Offers database maintenance when offline compaction is recommended. */ + const DismissedEnvironmentIds = Schema.Array(Schema.String); ++const NO_LOCAL_DATABASE_STORAGE = Atom.make( ++ AsyncResult.initial(false), ++).pipe(Atom.withLabel("database-storage-notice:no-local-database")); + +-/** Suggests `t3 doctor` on every machine whose database still holds at least 1 GiB of freed pages. */ ++const formatSize = (bytes: number) => `${(bytes / 1024 ** 2).toFixed(0)} MiB`; ++ ++/** ++ * Suggests `t3 doctor` on every machine whose database still holds at least 1 GiB of freed ++ * pages. The Flatpak desktop has no `t3` command, so it offers the same repairs for its own ++ * database until they are done. ++ */ export function DatabaseStorageNotice({ - environmentId, - label, -@@ -24,30 +27,77 @@ export function DatabaseStorageNotice({ - Schema.Boolean, + environments, + }: { + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; + }) { ++ const primaryEnvironmentId = usePrimaryEnvironmentId(); ++ const maintenance = window.desktopBridge?.databaseMaintenance; ++ const local = maintenance ++ ? environments.find((environment) => environment.environmentId === primaryEnvironmentId) ++ : undefined; ++ const remoteEnvironments = environments.filter((environment) => environment !== local); + const recommendations = useAtomValue( + serverEnvironment.databaseCompactionRecommendations( +- JSON.stringify(environments.map((environment) => environment.environmentId)), ++ JSON.stringify(remoteEnvironments.map((environment) => environment.environmentId)), + ), + ); ++ const localStorage = useAtomValue( ++ local ++ ? serverEnvironment.databaseStorage({ environmentId: local.environmentId, input: {} }) ++ : NO_LOCAL_DATABASE_STORAGE, ++ ); ++ const localEnvironmentId = local?.environmentId; ++ const [legacyDatabaseBytes, setLegacyDatabaseBytes] = useState(null); ++ useEffect(() => { ++ if (!maintenance || !localEnvironmentId) return; ++ void maintenance.legacyDatabaseBytes().then(setLegacyDatabaseBytes); ++ }, [maintenance, localEnvironmentId]); + const [dismissed, setDismissed] = useLocalStorage( + "database-storage-notice-dismissed", + [], + DismissedEnvironmentIds, ); const { copyToClipboard } = useCopyToClipboard({ target: "command" }); -+ const primaryEnvironmentId = usePrimaryEnvironmentId(); -+ const maintenance = -+ environmentId === primaryEnvironmentId ? window.desktopBridge?.databaseMaintenance : undefined; + const [pending, setPending] = useState(false); -+ const compact = async () => { ++ const runMaintenance = async (reclaimSpace: boolean) => { + if (!maintenance || pending) return; + setPending(true); + try { -+ await maintenance.compact(); ++ await maintenance.run({ reclaimSpace }); ++ setLegacyDatabaseBytes(await maintenance.legacyDatabaseBytes()); + } catch (error) { + toastManager.add( + stackedThreadToast({ @@ -661,83 +866,69 @@ index 637c920f5e..00f726a89f 100644 + setPending(false); + } + }; - const status = result._tag === "Success" ? result.value : null; - if (dismissed || status === null || !shouldRecommendDatabaseCompaction(status)) return null; ++ ++ const localStatus = localStorage._tag === "Success" ? localStorage.value : null; ++ // Once converted, the server returns freed space to disk on its own. ++ const reclaimSpace = localStatus !== null && !localStatus.incrementalVacuumEnabled; ++ const showLocal = local !== undefined && (reclaimSpace || legacyDatabaseBytes !== null); + const visible = recommendations.filter( + (recommendation) => !dismissed.includes(recommendation.environmentId), + ); +- if (visible.length === 0) return null; ++ if (!showLocal && visible.length === 0) return null; return ( - - - +- ++ {showLocal && ( ++ ++ {reclaimSpace && ++ `${formatSize(localStatus.reclaimableBytes)} of ${formatSize(localStatus.databaseBytes)} can be returned to disk. Reclaiming it once turns on automatic shrinking and restarts the local server.`} ++ {reclaimSpace && legacyDatabaseBytes !== null && " "} ++ {legacyDatabaseBytes !== null && ++ `The database from before 0.1.0 (${formatSize(legacyDatabaseBytes)}) is kept for rolling back.`} ++ + } - description={ -- <> -- Old history is cleaned up automatically, but this database still holds the freed -- space. Run the command on {label} to return it to disk; it pauses and restarts the -- background service. Quit the desktop app first if it hosts this environment. -- {status.compactCommand} -- -+ maintenance ? ( -+ <> -+ The database uses {(status.databaseBytes / 1024 ** 2).toFixed(1)} MiB, with{" "} -+ {(status.reclaimableBytes / 1024 ** 2).toFixed(1)} MiB available to reclaim. -+ Reclaiming space temporarily stops the local server. Finish active work first; -+ cleanup can take several minutes and needs temporary disk space. The server restarts -+ afterward. -+ -+ ) : ( -+ <> -+ Old history is cleaned up automatically, but this database still holds the freed -+ space. Run the command on {label} to return it to disk; it pauses and restarts the -+ background service. Quit the desktop app first if it hosts this environment. -+ {status.compactCommand} -+ -+ ) - } - control={ -
-- -+ {maintenance ? ( -+ -+ ) : ( -+ -+ )} - ++ control={ ++ ++ } ++ /> ++ )} ++ {visible.length > 0 && ( ++ ++ )} + {visible.map(({ environmentId, status }) => ( + Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; -+ /** Offline cleanup of this Flatpak desktop's local database, with confirmation and restart. */ ++ /** The `t3 doctor` database repairs for this Flatpak desktop's local database. */ + databaseMaintenance?: { -+ compact: () => Promise; ++ /** Size of the database kept from before 0.1.0 for rolling back, or null once it is gone. */ ++ legacyDatabaseBytes: () => Promise; ++ /** Confirms, then reclaims space (restarting the server) and/or deletes the legacy database. */ ++ run: (input: { reclaimSpace: boolean }) => Promise; + }; cliCommand?: { getState: () => Promise; diff --git a/patches/0012-flatpak-update-channel.patch b/patches/0012-flatpak-update-channel.patch index 96fa8ed..a60a50d 100644 --- a/patches/0012-flatpak-update-channel.patch +++ b/patches/0012-flatpak-update-channel.patch @@ -1,8 +1,8 @@ diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts -index 9a069527d2..919619b47a 100644 +index 3bbff03927..5787c78380 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts -@@ -37,6 +37,11 @@ describe("DesktopUpdates", () => { +@@ -36,6 +36,11 @@ describe("DesktopUpdates", () => { const result = yield* updates.check("manual"); assert.isFalse(result.checked); assert.equal(harness.checkCount(), 0); @@ -29,10 +29,10 @@ index 0c65574646..94737ae8f2 100644 if (Option.isSome(activeAction)) { return yield* new DesktopUpdateActionInProgressError({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx -index 16c6ff3fb8..ed66cdcb3b 100644 +index c733fae935..a890471f99 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx -@@ -295,6 +295,7 @@ function AboutVersionSection() { +@@ -157,6 +157,7 @@ function AboutVersionSection() { if ( !bridge || typeof bridge.setUpdateChannel !== "function" || @@ -40,7 +40,7 @@ index 16c6ff3fb8..ed66cdcb3b 100644 channel === selectedUpdateChannel ) { return; -@@ -449,10 +450,15 @@ function AboutVersionSection() { +@@ -311,10 +312,15 @@ function AboutVersionSection() { {hasDesktopBridge ? ( { handleUpdateChannelChange(value as DesktopUpdateChannel); }} -@@ -461,7 +467,7 @@ function AboutVersionSection() { +@@ -323,7 +329,7 @@ function AboutVersionSection() { size="sm" className="w-full sm:w-40" aria-label="Update track" diff --git a/patches/0013-electron-security-update.patch b/patches/0013-electron-security-update.patch deleted file mode 100644 index 79e0966..0000000 --- a/patches/0013-electron-security-update.patch +++ /dev/null @@ -1,85 +0,0 @@ -Temporary: bump Electron from 44.4.5 to 44.9.1, which stops trusting the -compromised eMudhra intermediate CAs. Drop this patch once upstream T3 Code -ships Electron 44.9.1 or newer. - -diff --git a/apps/desktop/package.json b/apps/desktop/package.json -index a6fe979100..14dd7e5479 100644 ---- a/apps/desktop/package.json -+++ b/apps/desktop/package.json -@@ -24,7 +24,7 @@ - "@t3tools/tailscale": "workspace:*", - "dbus-next": "0.10.2", - "effect": "catalog:", -- "electron": "44.4.5", -+ "electron": "44.9.1", - "electron-store": "^8.2.0", - "electron-updater": "^6.8.9", - "electron-webauthn": "catalog:", -diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml -index d71e2422a3..32bea9ebfb 100644 ---- a/pnpm-lock.yaml -+++ b/pnpm-lock.yaml -@@ -229,7 +229,7 @@ importers: - dependencies: - '@clerk/electron': - specifier: 0.1.1 -- version: 0.1.1(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) -+ version: 0.1.1(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@clerk/electron-passkeys': - specifier: 0.0.3 - version: 0.0.3 -@@ -264,8 +264,8 @@ importers: - specifier: 4.0.5 - version: 4.0.5 - electron: -- specifier: 44.4.5 -- version: 44.4.5 -+ specifier: 44.9.1 -+ version: 44.9.1 - electron-store: - specifier: ^8.2.0 - version: 8.2.0 -@@ -824,7 +824,7 @@ importers: - version: 6.39.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@clerk/electron': - specifier: 0.1.1 -- version: 0.1.1(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) -+ version: 0.1.1(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@clerk/react': - specifier: 6.17.8 - version: 6.17.8(react-dom@19.3.0(react@19.3.0))(react@19.3.0) -@@ -8719,8 +8719,8 @@ packages: - resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} - engines: {node: '>=8.0.0'} - -- electron@44.4.5: -- resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==} -+ electron@44.9.1: -+ resolution: {integrity: sha512-x/tECFFDa2OFNjMObFH0EGKWrCnFZPOR1wP2iMIVt0STecGl8FmDXdaiaryY0kf8mtw9kSLP2yK8dfdaWkfDmw==} - engines: {node: '>= 22.12.0'} - hasBin: true - -@@ -13898,12 +13898,12 @@ snapshots: - '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 - '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - -- '@clerk/electron@0.1.1(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': -+ '@clerk/electron@0.1.1(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - '@clerk/clerk-js': 6.39.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@clerk/react': 6.17.8(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@clerk/shared': 4.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) -- electron: 44.4.5 -+ electron: 44.9.1 - react: 19.3.0 - tslib: 2.8.1 - optionalDependencies: -@@ -19406,7 +19406,7 @@ snapshots: - transitivePeerDependencies: - - supports-color - -- electron@44.4.5: -+ electron@44.9.1: - dependencies: - '@electron-internal/extract-zip': 1.0.5 - '@electron/get': 5.1.0