diff --git a/.github/workflows/update-t3code.yml b/.github/workflows/update-t3code.yml index 0e9dc46..bab3e9a 100644 --- a/.github/workflows/update-t3code.yml +++ b/.github/workflows/update-t3code.yml @@ -52,6 +52,8 @@ jobs: branch: automation/update-t3code-${{ steps.release.outputs.result }} delete-branch: true draft: true + # Bot-created PRs don't notify anyone unless a person is requested. + reviewers: TB516 commit-message: "chore(flatpak): update T3 Code to ${{ steps.release.outputs.result }}" title: Update T3 Code to ${{ steps.release.outputs.result }} body: | diff --git a/flatpak/com.t3tools.t3code.metainfo.xml b/flatpak/com.t3tools.t3code.metainfo.xml index 9678671..16196dc 100644 --- a/flatpak/com.t3tools.t3code.metainfo.xml +++ b/flatpak/com.t3tools.t3code.metainfo.xml @@ -19,9 +19,18 @@ com.t3tools.t3code.desktop https://t3.codes + + https://github.com/pingdotgg/t3code/releases/tag/v0.1.1 + +

Update to T3 Code 0.1.1 with Electron 44.9.1, which no longer trusts + the compromised eMudhra intermediate certificates.

+

Reclaim database space from Settings without installing a command-line + launcher. Update track changes are disabled because Flatpak manages updates.

+
+
https://github.com/pingdotgg/t3code/releases/tag/v0.1.0 - + https://github.com/pingdotgg/t3code/releases/tag/v0.0.45 diff --git a/flatpak/modules/t3code.yml b/flatpak/modules/t3code.yml index 4a427cb..3c6c6a6 100644 --- a/flatpak/modules/t3code.yml +++ b/flatpak/modules/t3code.yml @@ -52,12 +52,14 @@ test-commands: src/environment/ServerEnvironment.test.ts src/preview/PreviewBrowserHost.test.ts src/preview/ServerBrowserStream.test.ts + src/cli/doctor.test.ts + src/persistence/DatabaseMaintenance.test.ts - pnpm --filter t3 typecheck sources: - type: git url: https://github.com/pingdotgg/t3code.git - tag: v0.1.0 - commit: 81493efb71e8e8244f95d3e9f71d8369654ab151 + tag: v0.1.1 + commit: fe27e8e3dc12dcbb108e9a28fd9b8cb65f34ae92 x-checker-data: type: json url: https://api.github.com/repos/pingdotgg/t3code/releases/latest @@ -77,6 +79,9 @@ sources: - ../../patches/0008-flatpak-cli-launcher.patch - ../../patches/0009-flatpak-server-updates.patch - ../../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 5db3533..434cf17 100755 --- a/flatpak/package/host-server.sh +++ b/flatpak/package/host-server.sh @@ -12,4 +12,10 @@ 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" +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 f2f9dc6..89f75ff 100644 --- a/patches/0001-flatpak-desktop-integration.patch +++ b/patches/0001-flatpak-desktop-integration.patch @@ -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 c4adf8f3c..06a5cb46d 100644 +index 0015e6426..9c792f7ed 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -1,3 +1,5 @@ @@ -20,7 +20,7 @@ index c4adf8f3c..06a5cb46d 100644 import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -@@ -158,10 +160,11 @@ export function resolveEarlyLinuxElectronOptions( +@@ -149,10 +151,11 @@ export function resolveEarlyLinuxElectronOptions( ): EarlyLinuxElectronOptions { const preference = resolveEarlyLinuxPasswordStorePreference(input); const isDevelopment = isDevelopmentEnvironment(input.env); @@ -58,10 +58,10 @@ index fb6c5a8b1..b8be6acf4 100644 Effect.gen(function* () { const environment = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts -index f697e0456..bb0a28ece 100644 +index ecf08255f..04934beb7 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts -@@ -175,10 +175,14 @@ const make = Effect.fn("desktop.environment.make")(function* ( +@@ -167,10 +167,14 @@ const make = Effect.fn("desktop.environment.make")(function* ( input.isPackaged && input.platform === "win32" ? path.join(input.resourcesPath, "server.asar") : appRoot; @@ -77,7 +77,7 @@ index f697e0456..bb0a28ece 100644 const displayName = branding.displayName; const stateDir = resolveDesktopStateDir({ baseDir, -@@ -238,8 +242,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( +@@ -230,8 +234,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", ), @@ -91,16 +91,17 @@ index f697e0456..bb0a28ece 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 cbabe14dd..209853d37 100644 +index 295f63eaf..2e2f315cb 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts -@@ -1,4 +1,4 @@ +@@ -1,5 +1,5 @@ + import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; +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"; -@@ -158,6 +158,15 @@ const emptyRecording = (): RecordedRegistration => ({ +@@ -159,6 +159,15 @@ const emptyRecording = (): RecordedRegistration => ({ }); describe("DesktopLinuxUrlHandler", () => { @@ -117,7 +118,7 @@ index cbabe14dd..209853d37 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 e67cdcefe..f61264212 100644 +index e03418964..bf2c12f4e 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"; @@ -128,7 +129,7 @@ index e67cdcefe..f61264212 100644 import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -@@ -220,7 +221,10 @@ export const make = Effect.gen(function* () { +@@ -292,7 +293,10 @@ export const make = Effect.gen(function* () { ); const register = Effect.gen(function* () { @@ -141,10 +142,10 @@ index e67cdcefe..f61264212 100644 } yield* writeDesktopEntry; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts -index dd15f879f..08731decb 100644 +index d27251d7b..cb5dfad04 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts -@@ -58,6 +58,28 @@ vi.mock("node:child_process", () => ({ +@@ -60,6 +60,28 @@ vi.mock("node:child_process", () => ({ import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; describe("DesktopPreReadyPlatform", () => { @@ -174,7 +175,7 @@ index dd15f879f..08731decb 100644 appendSwitchMock.mockReset(); getSwitchValueMock.mockReset(); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts -index b021ae02e..b434b2a77 100644 +index 58670439a..902fec467 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"; @@ -185,8 +186,8 @@ index b021ae02e..b434b2a77 100644 import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; -@@ -64,50 +65,52 @@ export const make = Effect.gen(function* () { - if (linux !== null) { +@@ -75,47 +76,49 @@ export const make = Effect.gen(function* () { + }); // The portal also requires a valid desktop entry. An AppImage update may // have removed the executable referenced by the previous launch's entry. - try { @@ -214,8 +215,22 @@ index b021ae02e..b434b2a77 100644 - ), - iconPath, - ); -- } catch { -- // Icon installation is optional; registration retries after readiness. +- } catch {} +- } +- NodeFS.writeFileSync( +- NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), +- renderUrlHandlerDesktopEntry({ +- displayName: resolveDesktopAppBranding({ +- isDevelopment: linux.isDevelopment, +- appVersion: Electron.app.getVersion(), +- }).displayName, +- execTarget: process.env.APPIMAGE?.trim() || process.execPath, +- scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), +- ...(iconPath === undefined ? {} : { iconPath }), +- }), +- "utf8", +- ); +- } catch {} + if (flatpakAppId(process.env, platform) === null) { + try { + const applicationsDir = NodePath.posix.join( @@ -242,10 +257,8 @@ index b021ae02e..b434b2a77 100644 + ), + iconPath, + ); -+ } catch { -+ // Icon installation is optional; registration retries after readiness. -+ } - } ++ } catch {} ++ } + NodeFS.writeFileSync( + NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), + renderUrlHandlerDesktopEntry({ @@ -259,27 +272,11 @@ index b021ae02e..b434b2a77 100644 + }), + "utf8", + ); -+ } catch { -+ // The URL handler retries with the full environment and logs failures. - } -- NodeFS.writeFileSync( -- NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), -- renderUrlHandlerDesktopEntry({ -- displayName: resolveDesktopAppBranding({ -- isDevelopment: linux.isDevelopment, -- appVersion: Electron.app.getVersion(), -- }).displayName, -- execTarget: process.env.APPIMAGE?.trim() || process.execPath, -- scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), -- ...(iconPath === undefined ? {} : { iconPath }), -- }), -- "utf8", -- ); -- } catch { -- // The URL handler retries with the full environment and logs failures. - } ++ } catch {} ++ } // Chromium caches its portal registration during startup. Set the identity // before any asynchronous work can initialize it with Electron's default. + 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 diff --git a/patches/0002-flatpak-host-backend.patch b/patches/0002-flatpak-host-backend.patch index e52c082..0154757 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 b306975be..7f70a3cfc 100644 +index dadbe8acc..79e41c7b6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,5 +1,8 @@ @@ -11,7 +11,7 @@ index b306975be..7f70a3cfc 100644 import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; -@@ -584,7 +587,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv +@@ -535,7 +538,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv ...buildObservabilityFragment(input.observabilitySettings), }; @@ -20,7 +20,7 @@ index b306975be..7f70a3cfc 100644 executablePath: process.execPath, // Packaged builds only, so a dev instance never shares the cache with the // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, -@@ -612,6 +615,15 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv +@@ -561,6 +564,15 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv captureOutput: true, preflightFailure: Option.none(), } satisfies DesktopBackendManager.DesktopBackendStartConfig; diff --git a/patches/0003-flatpak-host-environment.patch b/patches/0003-flatpak-host-environment.patch index b2aa5e4..55fa552 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 6efe81757..c453e6274 100644 +index 3adf74bf7..141319707 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts -@@ -120,6 +120,21 @@ function runShellEnvironment(input: { +@@ -126,6 +126,21 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { @@ -21,14 +21,14 @@ index 6efe81757..c453e6274 100644 + }).pipe(Effect.tap(() => Effect.sync(() => assert.equal(env.PATH, "/app/bin:/usr/bin")))); + }); + - 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" }; + // 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", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts -index 2b41b6ea7..dfe68ac88 100644 +index 3fdec7932..1182af006 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts -@@ -10,6 +10,7 @@ import * as Stream from "effect/Stream"; +@@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -36,16 +36,16 @@ index 2b41b6ea7..dfe68ac88 100644 import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; type EnvironmentPatch = Record; -@@ -515,6 +516,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix - const installShellEnvironment = ( - config: ShellEnvironmentConfig, - ): Effect.Effect => { -+ if (flatpakAppId(config.env, config.platform) !== null) return Effect.void; +@@ -513,6 +514,7 @@ const installShellEnvironment = ( + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + > => { ++ if (flatpakAppId(config.env, config.platform) !== null) return Effect.succeed(false); if (config.platform === "win32") { return installWindowsEnvironment(config); } diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts -index 26ad0f1c4..fc5f324df 100644 +index 94bfbe44c..9a069527d 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 26ad0f1c4..fc5f324df 100644 import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; -@@ -21,6 +21,28 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; +@@ -22,6 +22,28 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { @@ -84,7 +84,7 @@ index 26ad0f1c4..fc5f324df 100644 const cause = Cause.combine( Cause.fail(new Error("updater failed")), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts -index 77069d3ba..03ff73f2a 100644 +index ae3c2588f..0c6557464 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,3 +1,4 @@ @@ -92,7 +92,7 @@ index 77069d3ba..03ff73f2a 100644 import { DESKTOP_UPDATE_RESTART_MARKER_FILE, DesktopUpdateChannelSchema, -@@ -321,6 +322,9 @@ function getAutoUpdateDisabledReason(args: { +@@ -316,6 +317,9 @@ function getAutoUpdateDisabledReason(args: { disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { diff --git a/patches/0005-flatpak-build-artifact.patch b/patches/0005-flatpak-build-artifact.patch index 89f45ce..88e2df1 100644 --- a/patches/0005-flatpak-build-artifact.patch +++ b/patches/0005-flatpak-build-artifact.patch @@ -1,8 +1,8 @@ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts -index cb47e40b8..2d54e1ae7 100644 +index d2cff4726..1de29fedc 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts -@@ -3845,9 +3845,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( +@@ -4114,10 +4114,17 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f for (const entry of stageEntries) { const from = path.join(stageDistDir, entry); const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null)); @@ -21,3 +21,4 @@ index cb47e40b8..2d54e1ae7 100644 + } copiedArtifacts.push(to); } + diff --git a/patches/0007-flatpak-editor-discovery.patch b/patches/0007-flatpak-editor-discovery.patch index ae209c4..4c9b488 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 63e89541e..4609b1f6d 100644 +index 0dedda030..abdc4e011 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts -@@ -210,6 +210,55 @@ it.effect("launches an installed editor with platform-safe arguments", () => +@@ -260,6 +260,55 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -59,10 +59,10 @@ index 63e89541e..4609b1f6d 100644 it.effect.skipIf(windowsHost)(`launches Cursor in classic IDE mode on ${platform}`, () => Effect.gen(function* () { diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts -index 3f2dab8f4..5e2c9e15b 100644 +index 2c2485755..02c706422 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts -@@ -146,6 +146,67 @@ const CommandLookupEnvConfig = Config.all({ +@@ -133,6 +133,67 @@ const CommandLookupEnvConfig = Config.all({ const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); @@ -130,7 +130,7 @@ index 3f2dab8f4..5e2c9e15b 100644 function parseTargetPathAndPosition(target: string): Option.Option { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { -@@ -446,6 +507,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" +@@ -430,6 +491,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { const available: EditorId[] = []; @@ -138,7 +138,7 @@ index 3f2dab8f4..5e2c9e15b 100644 for (const editor of EDITORS) { if (editor.commands === null) { -@@ -456,7 +518,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" +@@ -440,7 +502,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" } const command = yield* resolveEditorCommand(editor, env); @@ -147,7 +147,7 @@ index 3f2dab8f4..5e2c9e15b 100644 available.push(editor.id); } } -@@ -558,8 +620,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( +@@ -519,8 +581,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 1ed022b..151de13 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 b2305fe98..14d6857e3 100644 +index 7b44fab01..82b91f7a3 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ @@ -9,7 +9,7 @@ index b2305fe98..14d6857e3 100644 import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -@@ -95,6 +95,36 @@ afterEach(() => { +@@ -93,6 +93,36 @@ afterEach(() => { }); it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { @@ -47,7 +47,7 @@ index b2305fe98..14d6857e3 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 d79cab92f..d80daf5dc 100644 +index 294f8a8b9..b0f2b05db 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"; @@ -58,7 +58,7 @@ index d79cab92f..d80daf5dc 100644 import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -@@ -76,6 +77,17 @@ export class DesktopCliCommand extends Context.Service< +@@ -67,6 +68,17 @@ export class DesktopCliCommand extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -77,7 +77,7 @@ index d79cab92f..d80daf5dc 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 5cd942f66..9fa569993 100644 +index a9d9f04c4..f2c3787f1 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"; @@ -88,8 +88,8 @@ index 5cd942f66..9fa569993 100644 import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -@@ -140,7 +141,9 @@ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment[ - */ +@@ -130,7 +131,9 @@ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment[ + export const install = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (!environment.isPackaged) return Option.none(); diff --git a/patches/0009-flatpak-server-updates.patch b/patches/0009-flatpak-server-updates.patch index d09d921..3e882e7 100644 --- a/patches/0009-flatpak-server-updates.patch +++ b/patches/0009-flatpak-server-updates.patch @@ -1,5 +1,5 @@ diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts -index 443fad607..8a13118a4 100644 +index 3a852c7bc..fd81bd5d1 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* ( @@ -32,18 +32,18 @@ index 443fad607..8a13118a4 100644 Effect.gen(function* () { const events: string[] = []; diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts -index 615351c43..965adca86 100644 +index 10c2a7991..727feb6c7 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts -@@ -185,6 +185,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { +@@ -187,6 +187,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); - const capability: ServerSelfUpdateCapability | null = - serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null; -@@ -196,6 +197,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { + // 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* () { const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { @@ -53,9 +53,9 @@ index 615351c43..965adca86 100644 + ); + } if (capability === "desktop-managed") { - // input.targetVersion is meaningless here: the desktop app's own - // update feed decides what it downloads, and the result carries what -@@ -337,7 +343,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { + if (desktopAppUpdate.available) { + return yield* desktopAppUpdate.run(reportProgress); +@@ -349,7 +355,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { return ServerSelfUpdate.of({ update, commitDesktopUpdate: (requestId, onHandoffAccepted) => @@ -69,7 +69,7 @@ index 615351c43..965adca86 100644 }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts -index a77802357..43b6f793b 100644 +index b17668002..cc21bad86 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) => { @@ -114,13 +114,13 @@ index a77802357..43b6f793b 100644 settledWorktreeCleanup: true, storageCleanupRun: true, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts -index 8e635f981..989a8b63d 100644 +index fbb597056..6b1379d1f 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* () { - // app and its bundled server ship in one artifact, so a present fd means - // the app speaks the requestDesktopUpdate protocol. WSL backends never get - // the fd and correctly do not advertise. + // app and its bundled server ship in one artifact, so a present fd means the + // app speaks the requestDesktopUpdate protocol. + + const flatpakManaged = Boolean((yield* HostProcess.Environment).T3CODE_FLATPAK_SERVER); const desktopAppUpdate = - serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; diff --git a/patches/0010-flatpak-browser-requirements.patch b/patches/0010-flatpak-browser-requirements.patch index 2e9fdf1..b94765f 100644 --- a/patches/0010-flatpak-browser-requirements.patch +++ b/patches/0010-flatpak-browser-requirements.patch @@ -1,8 +1,8 @@ diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts -index 5ae0a7075..36351fd9b 100644 +index 8c6b8b478..99ef56632 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts -@@ -73,6 +73,21 @@ const diagnose = (input: { +@@ -75,6 +75,21 @@ const diagnose = (input: { }; describe("diagnoseLaunchFailure", () => { @@ -25,10 +25,10 @@ index 5ae0a7075..36351fd9b 100644 Effect.gen(function* () { const { error, commands } = yield* diagnose({ diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts -index de136cf41..53ad43441 100644 +index 179ba9a1d..5fe4890e7 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts -@@ -71,18 +71,24 @@ export const DEBIAN_PACKAGES: ReadonlyArray> = [ +@@ -57,18 +57,24 @@ export const DEBIAN_PACKAGES: ReadonlyArray> = [ export class PreviewBrowserSandboxError extends Schema.TaggedError()( "PreviewBrowserSandboxError", @@ -55,7 +55,7 @@ index de136cf41..53ad43441 100644 return `This host is missing libraries T3's browser needs (${this.libraries.join(", ")}). Run \`${this.setupCommand}\` on the host to install them, then try again.`; } } -@@ -104,7 +110,9 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc +@@ -97,7 +103,9 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc readonly output: string; readonly setupCommand: string; }) { @@ -64,13 +64,13 @@ index de136cf41..53ad43441 100644 + ? null + : input.setupCommand; if (input.output.includes(NO_SANDBOX_SIGNATURE)) { - return new PreviewBrowserSandboxError({ setupCommand }); - } + // 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 87789692e..259aab1fd 100644 +index 5b386e5f0..891c19884 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts -@@ -2477,7 +2477,9 @@ const make = Effect.gen(function* () { +@@ -2457,7 +2457,9 @@ const make = Effect.gen(function* () { (yield* PreviewBrowserHost.sandboxBlocked) ) { yield* Effect.logWarning( @@ -80,12 +80,12 @@ index 87789692e..259aab1fd 100644 + }).message, ); } - // The desktop took its page back (closed, swapped, crashed, or devtools opened). + diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts -index 8fc153d9e..e8e4098e2 100644 +index 6b2d24802..7e98804be 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts -@@ -306,6 +306,17 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco +@@ -377,6 +377,17 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup)); it.effect.each([ @@ -103,7 +103,7 @@ index 8fc153d9e..e8e4098e2 100644 { error: new PreviewBrowserHost.PreviewBrowserSandboxError({ setupCommand: "sudo t3 browser setup", -@@ -319,7 +330,7 @@ it.effect.each([ +@@ -390,7 +401,7 @@ it.effect.each([ }), need: "libraries", }, @@ -112,7 +112,7 @@ index 8fc153d9e..e8e4098e2 100644 Effect.gen(function* () { const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, -@@ -359,7 +370,7 @@ it.effect.each([ +@@ -430,7 +441,7 @@ it.effect.each([ expect(code).toBe(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE); expect(yield* decodeHostSetup(reason)).toEqual({ need, @@ -122,11 +122,11 @@ index 8fc153d9e..e8e4098e2 100644 }).pipe(Effect.scoped), ); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts -index b2960c240..87d0e1570 100644 +index ad42eaf5d..02f9dc27b 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts -@@ -203,9 +203,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => - /** Which host setup a launch failure is missing, and the command that fixes it. */ +@@ -212,9 +212,9 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr + const hostSetup = (cause: unknown): PreviewStreamHostSetup | undefined => isSandboxError(cause) - ? { need: "sandbox", command: cause.setupCommand } @@ -138,10 +138,10 @@ index b2960c240..87d0e1570 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 b8f403598..a07843935 100644 +index 27ae15e5e..5081b85b3 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx -@@ -892,7 +892,9 @@ export function ServerBrowserSurface(props: { +@@ -872,7 +872,9 @@ export function ServerBrowserSurface(props: {

{previewStreamHostSetupMessage(hostSetup)}

@@ -153,13 +153,13 @@ index b8f403598..a07843935 100644 Try again diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts -index 34a378697..6b995950b 100644 +index b71fff335..9d5ab790a 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts -@@ -476,7 +476,13 @@ const decodeHostSetup = (reason: string): PreviewStreamHostSetup => { +@@ -454,7 +454,13 @@ const decodeHostSetup = (reason: string): PreviewStreamHostSetup => { + return { need: "sandbox", command: "sudo npx t3 browser setup" }; }; - /** What a viewer tells the person; `command` is shown beside it, ready to copy. */ -export const previewStreamHostSetupMessage = (setup: PreviewStreamHostSetup) => - setup.need === "sandbox" +export const previewStreamHostSetupMessage = (setup: PreviewStreamHostSetup) => { @@ -173,15 +173,14 @@ index 34a378697..6b995950b 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 802bbd476..126fa9af8 100644 +index 455663155..793b30a9f 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts -@@ -188,7 +188,7 @@ export type PreviewRuntime = typeof PreviewRuntime.Type; +@@ -154,6 +154,7 @@ export type PreviewRuntime = typeof PreviewRuntime.Type; */ export const PreviewStreamHostSetup = Schema.Struct({ need: Schema.Literals(["sandbox", "libraries"]), -- /** For example `sudo npx t3 browser setup`, matching how the server was launched. */ -+ /** For example `sudo npx t3 browser setup`. Empty when host setup must be done manually. */ ++ /** Empty when host setup must be done manually. */ command: Schema.String, }); export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type; diff --git a/patches/0011-flatpak-database-maintenance.patch b/patches/0011-flatpak-database-maintenance.patch new file mode 100644 index 0000000..f678721 --- /dev/null +++ b/patches/0011-flatpak-database-maintenance.patch @@ -0,0 +1,744 @@ +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 +--- /dev/null ++++ b/apps/desktop/src/app/DesktopDatabaseMaintenance.test.ts +@@ -0,0 +1,195 @@ ++import { assert, afterEach, it, vi } from "@effect/vitest"; ++import * as Deferred from "effect/Deferred"; ++import * as Effect from "effect/Effect"; ++import * as Fiber from "effect/Fiber"; ++import * as Layer from "effect/Layer"; ++import * as Option from "effect/Option"; ++import * as Sink from "effect/Sink"; ++import * as Stream from "effect/Stream"; ++import * as ChildProcess from "effect/process/ChildProcess"; ++import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; ++ ++import * as DesktopDatabaseMaintenance from "./DesktopDatabaseMaintenance.ts"; ++import * as DesktopEnvironment from "./DesktopEnvironment.ts"; ++import * as DesktopState from "./DesktopState.ts"; ++import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; ++import * as ElectronDialog from "../electron/ElectronDialog.ts"; ++import * as ElectronWindow from "../electron/ElectronWindow.ts"; ++ ++const runtime = "/installed Flatpak/files/t3code"; ++const baseDir = "/home/alice/.var/app/com.t3tools.t3code/data/t3"; ++afterEach(() => vi.unstubAllEnvs()); ++ ++const harness = Effect.fn("test.databaseMaintenance.harness")(function* ( ++ options: { ++ runtime?: string; ++ response?: number; ++ exitCode?: number; ++ ready?: boolean; ++ confirm?: Effect.Effect; ++ stoppedPid?: number; ++ } = {}, ++) { ++ 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[] = []; ++ let stopped = false; ++ const primary: DesktopBackendPool.DesktopBackendInstance = { ++ id: DesktopBackendPool.PRIMARY_INSTANCE_ID, ++ label: Effect.succeed("Local"), ++ currentConfig: Effect.succeedNone, ++ snapshot: Effect.sync(() => ({ ++ desiredRunning: !stopped, ++ ready: !stopped, ++ activePid: stopped ? Option.fromNullishOr(options.stoppedPid) : Option.some(123), ++ restartAttempt: 0, ++ restartScheduled: false, ++ })), ++ stop: () => ++ Effect.sync(() => { ++ order.push("stop"); ++ stopped = true; ++ }), ++ start: Effect.sync(() => { ++ order.push("start"); ++ stopped = false; ++ }), ++ waitForReady: () => ++ Effect.sync(() => { ++ order.push("ready"); ++ return options.ready ?? true; ++ }), ++ }; ++ const dialog = ElectronDialog.ElectronDialog.of({ ++ pickFolder: () => Effect.die("unexpected folder picker"), ++ pickFiles: () => Effect.die("unexpected file picker"), ++ showMessageBox: (input) => ++ Effect.gen(function* () { ++ const confirm = input.type === "question"; ++ order.push(confirm ? "confirm" : "success"); ++ if (confirm && options.confirm) yield* options.confirm; ++ return { response: confirm ? (options.response ?? 1) : 0, checkboxChecked: false }; ++ }), ++ showErrorBox: (_title, message) => ++ Effect.sync(() => { ++ order.push("error"); ++ errors.push(message); ++ }), ++ }); ++ const spawner = ChildProcessSpawner.make((command) => ++ Effect.sync(() => { ++ assert.isTrue(stopped, "cleanup must not start before the server stops"); ++ order.push("compact"); ++ 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.")); ++ return ChildProcessSpawner.makeHandle({ ++ pid: ChildProcessSpawner.ProcessId(456), ++ stdout, ++ stderr, ++ all: stdout, ++ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode ?? 0)), ++ isRunning: Effect.succeed(false), ++ kill: () => Effect.void, ++ stdin: Sink.drain, ++ getInputFd: () => Sink.drain, ++ getOutputFd: () => Stream.empty, ++ unref: Effect.succeed(Effect.void), ++ }); ++ }), ++ ); ++ const maintenance = yield* DesktopDatabaseMaintenance.make.pipe( ++ Effect.provide( ++ Layer.mergeAll( ++ DesktopBackendPool.layerTest([primary]), ++ DesktopState.layer, ++ Layer.succeed(DesktopEnvironment.DesktopEnvironment, { ++ platform: "linux", ++ baseDir, ++ } as DesktopEnvironment.DesktopEnvironment["Service"]), ++ Layer.succeed(ElectronDialog.ElectronDialog, dialog), ++ Layer.mock(ElectronWindow.ElectronWindow)({ ++ currentMainOrFirst: Effect.succeedNone, ++ }), ++ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ++ ), ++ ), ++ ); ++ return { maintenance, order, errors, commands }; ++}); ++ ++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; ++ assert.deepEqual(order, ["confirm", "stop", "compact", "start", "ready", "success"]); ++ 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), [ ++ "/bin/sh", ++ `${runtime}/host-server.sh`, ++ "--compact-database", ++ ]); ++ assert.include(command.args, `--env=T3CODE_FLATPAK_SERVER=${runtime}/t3code`); ++ assert.include(command.args, `--env=T3CODE_HOME=${baseDir}`); ++ }), ++); ++ ++it.effect("cancelling the confirmation leaves the server running", () => ++ Effect.gen(function* () { ++ const { maintenance, order } = yield* harness({ response: 0 }); ++ yield* maintenance.compact; ++ assert.deepEqual(order, ["confirm"]); ++ }), ++); ++ ++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; ++ 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."); ++ }), ++); ++ ++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; ++ assert.deepEqual(order, ["confirm", "stop", "start", "ready", "error"]); ++ assert.equal(commands.length, 0); ++ }), ++); ++ ++it.effect("ignores a second request while confirmation is open", () => ++ Effect.gen(function* () { ++ const opened = yield* Deferred.make(); ++ const proceed = yield* Deferred.make(); ++ const { maintenance, order } = yield* harness({ ++ confirm: Deferred.succeed(opened, undefined).pipe(Effect.andThen(Deferred.await(proceed))), ++ }); ++ const first = yield* maintenance.compact.pipe(Effect.forkChild); ++ yield* Deferred.await(opened); ++ yield* maintenance.compact; ++ 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), ++); ++ ++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; ++ assert.deepEqual(order, []); ++ }), ++); +diff --git a/apps/desktop/src/app/DesktopDatabaseMaintenance.ts b/apps/desktop/src/app/DesktopDatabaseMaintenance.ts +new file mode 100644 +index 0000000000..c718d5a964 +--- /dev/null ++++ b/apps/desktop/src/app/DesktopDatabaseMaintenance.ts +@@ -0,0 +1,173 @@ ++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 Layer from "effect/Layer"; ++import * as Option from "effect/Option"; ++import * as Ref from "effect/Ref"; ++import * as Schema from "effect/Schema"; ++import * as Stream from "effect/Stream"; ++import * as ChildProcess from "effect/process/ChildProcess"; ++import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; ++ ++import * as DesktopEnvironment from "./DesktopEnvironment.ts"; ++import * as DesktopState from "./DesktopState.ts"; ++import { flatpakAppId } from "./Flatpak.ts"; ++import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; ++import * as ElectronDialog from "../electron/ElectronDialog.ts"; ++import * as ElectronWindow from "../electron/ElectronWindow.ts"; ++ ++class DesktopDatabaseCleanupError extends Schema.TaggedError()( ++ "DesktopDatabaseCleanupError", ++ { message: Schema.String }, ++) {} ++ ++/** Coordinates offline cleanup of the Flatpak desktop's own database. */ ++export class DesktopDatabaseMaintenance extends Context.Service< ++ DesktopDatabaseMaintenance, ++ { ++ readonly supported: boolean; ++ readonly compact: Effect.Effect; ++ } ++>()("@t3tools/desktop/app/DesktopDatabaseMaintenance") {} ++ ++/** @public Service construction is part of the canonical Effect module API. */ ++export const make = Effect.gen(function* () { ++ const environment = yield* DesktopEnvironment.DesktopEnvironment; ++ const pool = yield* DesktopBackendPool.DesktopBackendPool; ++ const dialog = yield* ElectronDialog.ElectronDialog; ++ const windows = yield* ElectronWindow.ElectronWindow; ++ const state = yield* DesktopState.DesktopState; ++ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; ++ const busy = yield* Ref.make(false); ++ const runtime = process.env.T3CODE_FLATPAK_RUNTIME; ++ const supported = ++ flatpakAppId(process.env, environment.platform) !== null && runtime?.startsWith("/") === true; ++ ++ 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; ++ } ++ ++ // 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)) { ++ return yield* Effect.fail( ++ new DesktopDatabaseCleanupError({ ++ message: "The local server did not stop. Database cleanup was not started.", ++ }), ++ ); ++ } ++ 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(); ++ }), ++ ); ++ }).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."}`, ++ ); ++ 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 }); ++}); ++ ++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 +--- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts ++++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts +@@ -84,6 +84,8 @@ import { + uninstallCliCommand, + } from "./methods/cliCommand.ts"; + ++import { getDatabaseMaintenanceSupported, compactDatabase } 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" + yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handle(WebLinksIpc.setReady); + ++ yield* ipc.handleSync(getDatabaseMaintenanceSupported); ++ yield* ipc.handle(compactDatabase); + 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 +--- 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- + 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"; +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 +--- /dev/null ++++ b/apps/desktop/src/ipc/methods/databaseMaintenance.ts +@@ -0,0 +1,23 @@ ++import * as Effect from "effect/Effect"; ++import * as Schema from "effect/Schema"; ++ ++import * as DesktopDatabaseMaintenance from "../../app/DesktopDatabaseMaintenance.ts"; ++import * as IpcChannels from "../channels.ts"; ++import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; ++ ++export const getDatabaseMaintenanceSupported = makeSyncIpcMethod({ ++ channel: IpcChannels.DATABASE_MAINTENANCE_SUPPORTED_CHANNEL, ++ result: Schema.Boolean, ++ handler: Effect.fn("desktop.ipc.databaseMaintenance.supported")(function* () { ++ return (yield* DesktopDatabaseMaintenance.DesktopDatabaseMaintenance).supported; ++ }), ++}); ++ ++export const compactDatabase = makeIpcMethod({ ++ channel: IpcChannels.DATABASE_MAINTENANCE_COMPACT_CHANNEL, ++ payload: Schema.Void, ++ result: Schema.Void, ++ handler: Effect.fn("desktop.ipc.databaseMaintenance.compact")(function* () { ++ yield* (yield* DesktopDatabaseMaintenance.DesktopDatabaseMaintenance).compact; ++ }), ++}); +diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts +index 3056ce1ea2..755abd68b2 100644 +--- a/apps/desktop/src/main.ts ++++ b/apps/desktop/src/main.ts +@@ -40,6 +40,7 @@ import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; + import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; + import * as DesktopClerk from "./app/DesktopClerk.ts"; + import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; ++import * as DesktopDatabaseMaintenance from "./app/DesktopDatabaseMaintenance.ts"; + import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; + import * as DesktopAssets from "./app/DesktopAssets.ts"; + import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; +@@ -209,6 +210,7 @@ const layerDesktopApplication = Layer.mergeAll( + DesktopApplicationMenu.layer, + DesktopLinuxUrlHandler.layer, + DesktopCliCommand.layer, ++ DesktopDatabaseMaintenance.layer, + layerDesktopSsh, + ).pipe( + Layer.provideMerge(layerDesktopSnapShot), +diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts +index bffa02d9ca..7c25290483 100644 +--- a/apps/desktop/src/preload.ts ++++ b/apps/desktop/src/preload.ts +@@ -271,6 +271,13 @@ 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), ++ }, ++ } ++ : {}), + cliCommand: { + 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 +--- 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 * 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 } = {}) => + themeCommand, + traceCommand, + doctorCommand, ++ flatpakCompactDatabaseCommand, + 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 +--- a/apps/server/src/cli/doctor.test.ts ++++ b/apps/server/src/cli/doctor.test.ts +@@ -1,10 +1,21 @@ + import * as NodeServices from "@effect/platform-node/NodeServices"; + import { assert, it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; ++import * as FileSystem from "effect/FileSystem"; ++import * as Path from "effect/Path"; ++import * as SqlClient from "effect/sql/SqlClient"; ++import { Command } from "effect/cli"; ++import * as HostProcess from "@t3tools/shared/HostProcess"; ++import * as NetService from "@t3tools/shared/Net"; ++import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; ++import * as EventStore from "../orchestration-v2/EventStore.ts"; ++import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; ++import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; ++import * as Sqlite from "../persistence/Sqlite.ts"; + import * as Layer from "effect/Layer"; + + import * as BootService from "../cloud/bootService.ts"; +-import { runDoctor } from "./doctor.ts"; ++import { runDoctor, flatpakCompactDatabaseCommand } from "./doctor.ts"; + import { DatabaseMaintenance } from "../persistence/DatabaseMaintenance.ts"; + + it.effect( +@@ -55,3 +66,42 @@ 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", ++ () => ++ Effect.gen(function* () { ++ const fs = yield* FileSystem.FileSystem; ++ const path = yield* Path.Path; ++ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-flatpak-compact-" }); ++ const dbPath = path.join(baseDir, "userdata", "statev2.sqlite"); ++ const legacy = path.join(baseDir, "userdata", "state.sqlite"); ++ yield* Effect.flatMap( ++ ProjectionMaintenance.ProjectionMaintenanceV2, ++ (projections) => projections.rebuild, ++ ).pipe( ++ Effect.provide( ++ ProjectionMaintenance.layer.pipe( ++ Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)), ++ Layer.provideMerge(Sqlite.layerFromPath(dbPath)), ++ ), ++ ), ++ ); ++ 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", ++ }), ++ ); ++ assert.equal(yield* fs.readFileString(legacy), "retained rollback database"); ++ 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); ++ }).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 +--- a/apps/server/src/cli/doctor.ts ++++ b/apps/server/src/cli/doctor.ts +@@ -1,8 +1,10 @@ ++import * as HostProcess from "@t3tools/shared/HostProcess"; + import { DATABASE_COMPACTION_RECOMMENDED_BYTES } from "@t3tools/contracts"; + import * as Console from "effect/Console"; + import * as Effect from "effect/Effect"; + import * as Layer from "effect/Layer"; + import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; ++import * as CliError from "effect/cli/CliError"; + + import * as BootService from "../cloud/bootService.ts"; + import * as DatabaseMaintenance from "../persistence/DatabaseMaintenance.ts"; +@@ -148,3 +150,29 @@ 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", { ++ ...projectLocationFlags, ++}).pipe( ++ Command.unlisted, ++ Command.withHandler((flags) => ++ Effect.gen(function* () { ++ if (!(yield* HostProcess.Environment).T3CODE_FLATPAK_SERVER) { ++ return yield* Effect.fail( ++ new CliError.UserError({ ++ cause: "This action is only available to the Flatpak desktop.", ++ }), ++ ); ++ } ++ const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); ++ const result = yield* Effect.gen(function* () { ++ const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance; ++ return yield* maintenance.compact(config.dbPath); ++ }).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 +--- a/apps/web/src/components/settings/DatabaseStorageNotice.tsx ++++ b/apps/web/src/components/settings/DatabaseStorageNotice.tsx +@@ -1,15 +1,18 @@ + import { useAtomValue } from "@effect/atom-react"; + import { shouldRecommendDatabaseCompaction, type EnvironmentId } from "@t3tools/contracts"; + import * as Schema from "effect/Schema"; ++import { useState } from "react"; + + import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; + import { useLocalStorage } from "../../hooks/useLocalStorage"; ++import { usePrimaryEnvironmentId } from "../../state/environments"; + 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. */ + export function DatabaseStorageNotice({ + environmentId, + label, +@@ -24,30 +27,77 @@ export function DatabaseStorageNotice({ + Schema.Boolean, + ); + const { copyToClipboard } = useCopyToClipboard({ target: "command" }); ++ const primaryEnvironmentId = usePrimaryEnvironmentId(); ++ const maintenance = ++ environmentId === primaryEnvironmentId ? window.desktopBridge?.databaseMaintenance : undefined; ++ const [pending, setPending] = useState(false); ++ const compact = async () => { ++ if (!maintenance || pending) return; ++ setPending(true); ++ try { ++ await maintenance.compact(); ++ } catch (error) { ++ toastManager.add( ++ stackedThreadToast({ ++ type: "error", ++ title: "Could not start database cleanup", ++ description: ++ error instanceof Error ? error.message : "Try again after the local server reconnects.", ++ }), ++ ); ++ } finally { ++ setPending(false); ++ } ++ }; + const status = result._tag === "Success" ? result.value : null; + if (dismissed || status === null || !shouldRecommendDatabaseCompaction(status)) return null; + return ( + + + +- 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 ? ( ++ ++ ) : ( ++ ++ )} + +diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts +index d1f6b34939..02012f6d9b 100644 +--- a/packages/contracts/src/ipc.ts ++++ b/packages/contracts/src/ipc.ts +@@ -1135,6 +1135,10 @@ export interface DesktopBridge { + downloadUpdate: () => Promise; + installUpdate: () => Promise; + onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; ++ /** Offline cleanup of this Flatpak desktop's local database, with confirmation and restart. */ ++ databaseMaintenance?: { ++ compact: () => Promise; ++ }; + cliCommand?: { + getState: () => Promise; + install: () => Promise; diff --git a/patches/0012-flatpak-update-channel.patch b/patches/0012-flatpak-update-channel.patch new file mode 100644 index 0000000..96fa8ed --- /dev/null +++ b/patches/0012-flatpak-update-channel.patch @@ -0,0 +1,68 @@ +diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts +index 9a069527d2..919619b47a 100644 +--- a/apps/desktop/src/updates/DesktopUpdates.test.ts ++++ b/apps/desktop/src/updates/DesktopUpdates.test.ts +@@ -37,6 +37,11 @@ describe("DesktopUpdates", () => { + const result = yield* updates.check("manual"); + assert.isFalse(result.checked); + assert.equal(harness.checkCount(), 0); ++ const settings = yield* DesktopAppSettings.DesktopAppSettings; ++ const before = yield* settings.get; ++ const nextChannel = state.channel === "latest" ? "nightly" : "latest"; ++ assert.deepEqual(yield* updates.setChannel(nextChannel), state); ++ assert.deepEqual(yield* settings.get, before); + }).pipe( + Effect.scoped, + Effect.provide(harness.layer), +diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts +index 0c65574646..94737ae8f2 100644 +--- a/apps/desktop/src/updates/DesktopUpdates.ts ++++ b/apps/desktop/src/updates/DesktopUpdates.ts +@@ -1109,6 +1109,9 @@ export const make = Effect.gen(function* () { + nextChannel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel: nextChannel }); ++ if (flatpakAppId(process.env, environment.platform) !== null) { ++ return yield* Ref.get(updateStateRef); ++ } + const activeAction = yield* tryStartChannelChange; + 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 +--- a/apps/web/src/components/settings/SettingsPanels.tsx ++++ b/apps/web/src/components/settings/SettingsPanels.tsx +@@ -295,6 +295,7 @@ function AboutVersionSection() { + if ( + !bridge || + typeof bridge.setUpdateChannel !== "function" || ++ updateState?.enabled !== true || + channel === selectedUpdateChannel + ) { + return; +@@ -449,10 +450,15 @@ function AboutVersionSection() { + {hasDesktopBridge ? ( + { + handleUpdateChannelChange(value as DesktopUpdateChannel); + }} +@@ -461,7 +467,7 @@ function AboutVersionSection() { + size="sm" + className="w-full sm:w-40" + aria-label="Update track" +- disabled={isChangingUpdateChannel} ++ disabled={isChangingUpdateChannel || updateState?.enabled !== true} + > + + {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} diff --git a/patches/0013-electron-security-update.patch b/patches/0013-electron-security-update.patch new file mode 100644 index 0000000..79e0966 --- /dev/null +++ b/patches/0013-electron-security-update.patch @@ -0,0 +1,85 @@ +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