An unofficial Flatpak package for T3 Code. The desktop runs inside
Flatpak and starts its matching T3 server on the host, where coding agents
can use your projects and development tools.
+
Headless browser features, including HTML preview screenshots and some
+ recording tasks, download and run Chromium on the host. Your system must
+ provide Chromium's runtime libraries, including NSS, GLib, accessibility,
+ graphics, and ALSA libraries. Systems that restrict unprivileged user
+ namespaces through AppArmor may also require a manually configured
+ AppArmor profile to allow Chromium's sandbox to run.
com.t3tools.t3code.desktophttps://t3.codes
+
+ 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 158df89..4a427cb 100644
--- a/flatpak/modules/t3code.yml
+++ b/flatpak/modules/t3code.yml
@@ -45,13 +45,19 @@ test-commands:
pnpm --filter @t3tools/desktop exec vp test run
--exclude src/wsl/DesktopWslEnvironment.test.ts
- pnpm --filter @t3tools/desktop typecheck
- - pnpm --filter t3 exec vp test run src/process/externalLauncher.test.ts
+ - >-
+ pnpm --filter t3 exec vp test run
+ src/process/externalLauncher.test.ts
+ src/cloud/selfUpdate.test.ts
+ src/environment/ServerEnvironment.test.ts
+ src/preview/PreviewBrowserHost.test.ts
+ src/preview/ServerBrowserStream.test.ts
- pnpm --filter t3 typecheck
sources:
- type: git
url: https://github.com/pingdotgg/t3code.git
- tag: v0.0.45
- commit: 6c8fed35dded9ff71c5b46807125457acbb76be6
+ tag: v0.1.0
+ commit: 81493efb71e8e8244f95d3e9f71d8369654ab151
x-checker-data:
type: json
url: https://api.github.com/repos/pingdotgg/t3code/releases/latest
@@ -68,6 +74,9 @@ sources:
- ../../patches/0005-flatpak-build-artifact.patch
- ../../patches/0006-flatpak-document-portal-paths.patch
- ../../patches/0007-flatpak-editor-discovery.patch
+ - ../../patches/0008-flatpak-cli-launcher.patch
+ - ../../patches/0009-flatpak-server-updates.patch
+ - ../../patches/0010-flatpak-browser-requirements.patch
use-git: true
- type: dir
path: ../package
diff --git a/flatpak/package/t3code b/flatpak/package/t3code
index b32b7c1..5f98666 100755
--- a/flatpak/package/t3code
+++ b/flatpak/package/t3code
@@ -25,7 +25,7 @@ esac
# then pass only PATH back to the host server through flatpak-spawn.
host_path=$(
/usr/bin/flatpak-spawn --host "${SHELL:-/bin/sh}" -ilc 'printf %s "$PATH"' \
- 3>&- 4>&- 5>&- 2>/dev/null || true
+ 3>&- 4>&- 5>&- 6>&- 7>&- 2>/dev/null || true
)
if [ -n "$host_path" ]; then
export T3CODE_FLATPAK_HOST_PATH="$host_path"
diff --git a/patches/0001-flatpak-desktop-integration.patch b/patches/0001-flatpak-desktop-integration.patch
index 23f4b5a..f2f9dc6 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 773ea245..8eb05c26 100644
+index 773ea2450..8eb05c265 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 773ea245..8eb05c26 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 95922bdf..a77901ce 100644
+index c4adf8f3c..06a5cb46d 100644
--- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts
+++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts
@@ -1,3 +1,5 @@
@@ -20,7 +20,7 @@ index 95922bdf..a77901ce 100644
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
-@@ -86,10 +88,11 @@ export function resolveEarlyLinuxElectronOptions(
+@@ -158,10 +160,11 @@ export function resolveEarlyLinuxElectronOptions(
): EarlyLinuxElectronOptions {
const preference = resolveEarlyLinuxPasswordStorePreference(input);
const isDevelopment = isDevelopmentEnvironment(input.env);
@@ -35,11 +35,11 @@ index 95922bdf..a77901ce 100644
preference,
env: input.env,
diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts
-index b5aabf25..1ddedc22 100644
+index fb6c5a8b1..b8be6acf4 100644
--- a/apps/desktop/src/app/DesktopEnvironment.test.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.test.ts
-@@ -40,6 +40,18 @@ const makeEnvironment = (
- DesktopEnvironment.DesktopEnvironment.pipe(Effect.provide(makeEnvironmentLayer(overrides, env)));
+@@ -39,6 +39,18 @@ const makeEnvironment = (
+ ) => DesktopEnvironment.DesktopEnvironment.pipe(Effect.provide(layerEnvironment(overrides, env)));
describe("DesktopEnvironment", () => {
+ it.effect("uses a distinct Flatpak display name", () =>
@@ -58,10 +58,10 @@ index b5aabf25..1ddedc22 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 beb09bd5..17053c3c 100644
+index f697e0456..bb0a28ece 100644
--- a/apps/desktop/src/app/DesktopEnvironment.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.ts
-@@ -177,10 +177,14 @@ const make = Effect.fn("desktop.environment.make")(function* (
+@@ -175,10 +175,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 beb09bd5..17053c3c 100644
const displayName = branding.displayName;
const stateDir = resolveDesktopStateDir({
baseDir,
-@@ -242,8 +246,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
+@@ -238,8 +242,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
),
@@ -89,9 +89,9 @@ index beb09bd5..17053c3c 100644
+ linuxWmClass: flatpakId ?? (isDevelopment ? "t3code-dev" : "t3code"),
linuxApplicationsDir,
appImagePath: config.appImagePath,
- userDataDirName,
+ defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
-index 893c1aae..9eb024a2 100644
+index cbabe14dd..209853d37 100644
--- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
+++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
@@ -1,4 +1,4 @@
@@ -117,10 +117,10 @@ index 893c1aae..9eb024a2 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 f01e81d8..9dcf500e 100644
+index e67cdcefe..f61264212 100644
--- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts
+++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts
-@@ -9,6 +9,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
+@@ -9,6 +9,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import * as DesktopAssets from "./DesktopAssets.ts";
@@ -141,10 +141,10 @@ index f01e81d8..9dcf500e 100644
}
yield* writeDesktopEntry;
diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
-index 7e859aaf..c13bd25f 100644
+index dd15f879f..08731decb 100644
--- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
+++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
-@@ -52,6 +52,28 @@ vi.mock("node:fs", () => ({
+@@ -58,6 +58,28 @@ vi.mock("node:child_process", () => ({
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
describe("DesktopPreReadyPlatform", () => {
@@ -163,7 +163,7 @@ index 7e859aaf..c13bd25f 100644
+ }).pipe(
+ Effect.provide(
+ DesktopPreReadyPlatform.layer.pipe(
-+ Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
++ Layer.provide(Layer.succeed(HostProcess.Platform, "linux")),
+ ),
+ ),
+ Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
@@ -174,25 +174,25 @@ index 7e859aaf..c13bd25f 100644
appendSwitchMock.mockReset();
getSwitchValueMock.mockReset();
diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts
-index 9b73a5d8..321fc028 100644
+index b021ae02e..b434b2a77 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";
import * as Electron from "electron";
- import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+ import * as HostProcess from "@t3tools/shared/HostProcess";
+import { flatpakAppId } from "./Flatpak.ts";
import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts";
import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts";
import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts";
-@@ -61,50 +62,52 @@ export const make = Effect.gen(function* () {
+@@ -64,50 +65,52 @@ export const make = Effect.gen(function* () {
if (linux !== null) {
// The portal also requires a valid desktop entry. An AppImage update may
// have removed the executable referenced by the previous launch's entry.
- try {
- const applicationsDir = NodePath.posix.join(
- process.env.XDG_DATA_HOME?.trim() ||
-- NodePath.posix.join(NodeOS.homedir(), ".local", "share"),
+- NodePath.posix.join(homeDirectory, ".local", "share"),
- "applications",
- );
- NodeFS.mkdirSync(applicationsDir, { recursive: true });
@@ -220,7 +220,7 @@ index 9b73a5d8..321fc028 100644
+ try {
+ const applicationsDir = NodePath.posix.join(
+ process.env.XDG_DATA_HOME?.trim() ||
-+ NodePath.posix.join(NodeOS.homedir(), ".local", "share"),
++ NodePath.posix.join(homeDirectory, ".local", "share"),
+ "applications",
+ );
+ NodeFS.mkdirSync(applicationsDir, { recursive: true });
@@ -282,7 +282,7 @@ index 9b73a5d8..321fc028 100644
// before any asynchronous work can initialize it with Electron's default.
diff --git a/apps/desktop/src/app/Flatpak.ts b/apps/desktop/src/app/Flatpak.ts
new file mode 100644
-index 00000000..006f94e0
+index 000000000..006f94e00
--- /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 01189df..e52c082 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 c2012299..4edb253f 100644
+index b306975be..7f70a3cfc 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
@@ -1,5 +1,8 @@
@@ -9,9 +9,9 @@ index c2012299..4edb253f 100644
+import { flatpakBackendConfig } from "./flatpakBackend.ts";
+
import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
- import * as Context from "effect/Context";
- import * as Crypto from "effect/Crypto";
-@@ -567,7 +570,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
+ import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
+ import * as Clock from "effect/Clock";
+@@ -584,7 +587,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
...buildObservabilityFragment(input.observabilitySettings),
};
@@ -20,7 +20,7 @@ index c2012299..4edb253f 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,
-@@ -593,6 +596,15 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
+@@ -612,6 +615,15 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
captureOutput: true,
preflightFailure: Option.none(),
} satisfies DesktopBackendManager.DesktopBackendStartConfig;
@@ -38,10 +38,10 @@ index c2012299..4edb253f 100644
diff --git a/apps/desktop/src/backend/flatpakBackend.test.ts b/apps/desktop/src/backend/flatpakBackend.test.ts
new file mode 100644
-index 00000000..4839d211
+index 000000000..cfc536d2e
--- /dev/null
+++ b/apps/desktop/src/backend/flatpakBackend.test.ts
-@@ -0,0 +1,78 @@
+@@ -0,0 +1,86 @@
+import { assert, describe, it } from "@effect/vitest";
+import * as Option from "effect/Option";
+
@@ -62,10 +62,13 @@ index 00000000..4839d211
+ host: "127.0.0.1",
+ t3Home: "/home/alice/.var/app/com.t3tools.t3code/data/t3",
+ desktopBootstrapToken: "test-token",
++ shellEnvironmentPrepared: true,
+ tailscaleServeEnabled: false,
+ tailscaleServePort: 443,
+ desktopTelemetryFd: 4,
+ desktopTelemetryControlFd: 5,
++ desktopBrowserFd: 6,
++ desktopBrowserControlFd: 7,
+ resourceMonitorPath: "/app/resources/resource-monitor/t3-resource-monitor",
+ },
+ bootstrapDelivery: "fd3",
@@ -89,6 +92,8 @@ index 00000000..4839d211
+ "--forward-fd=3",
+ "--forward-fd=4",
+ "--forward-fd=5",
++ "--forward-fd=6",
++ "--forward-fd=7",
+ `--directory=${runtime}`,
+ `--env=T3CODE_HOME=${config.bootstrap.t3Home}`,
+ `--env=T3CODE_FLATPAK_SERVER=${runtime}/t3code`,
@@ -105,6 +110,9 @@ index 00000000..4839d211
+ assert.equal(result.bootstrap.desktopBootstrapToken, "test-token");
+ assert.equal(result.bootstrap.desktopTelemetryFd, 4);
+ assert.equal(result.bootstrap.desktopTelemetryControlFd, 5);
++ assert.isFalse(result.bootstrap.shellEnvironmentPrepared);
++ assert.equal(result.bootstrap.desktopBrowserFd, 6);
++ assert.equal(result.bootstrap.desktopBrowserControlFd, 7);
+ assert.equal(
+ result.bootstrap.resourceMonitorPath,
+ `${runtime}/resources/resource-monitor/t3-resource-monitor`,
@@ -122,10 +130,10 @@ index 00000000..4839d211
+});
diff --git a/apps/desktop/src/backend/flatpakBackend.ts b/apps/desktop/src/backend/flatpakBackend.ts
new file mode 100644
-index 00000000..76d37f9e
+index 000000000..e912b2119
--- /dev/null
+++ b/apps/desktop/src/backend/flatpakBackend.ts
-@@ -0,0 +1,52 @@
+@@ -0,0 +1,56 @@
+import * as Option from "effect/Option";
+
+import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
@@ -149,6 +157,8 @@ index 00000000..76d37f9e
+ // Keep entryPath in the sandbox for preflight checks; pass host paths to the server.
+ const bootstrap = {
+ ...config.bootstrap,
++ // The sandbox skips shell hydration; the host server must prepare its own environment.
++ shellEnvironmentPrepared: false,
+ resourceMonitorPath: `${runtimeDir}/resources/resource-monitor/t3-resource-monitor`,
+ };
+ return {
@@ -160,6 +170,8 @@ index 00000000..76d37f9e
+ "--forward-fd=3",
+ "--forward-fd=4",
+ "--forward-fd=5",
++ "--forward-fd=6",
++ "--forward-fd=7",
+ `--directory=${runtimeDir}`,
+ `--env=T3CODE_HOME=${bootstrap.t3Home}`,
+ `--env=T3CODE_FLATPAK_SERVER=${runtimeDir}/t3code`,
diff --git a/patches/0003-flatpak-host-environment.patch b/patches/0003-flatpak-host-environment.patch
index 54e1ae9..b2aa5e4 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 6a369a45..6ebdaf06 100644
+index 6efe81757..c453e6274 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
-@@ -100,6 +100,21 @@ function runShellEnvironment(input: {
+@@ -120,6 +120,21 @@ function runShellEnvironment(input: {
}
describe("DesktopShellEnvironment", () => {
@@ -21,22 +21,22 @@ index 6a369a45..6ebdaf06 100644
+ }).pipe(Effect.tap(() => Effect.sync(() => assert.equal(env.PATH, "/app/bin:/usr/bin"))));
+ });
+
- it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>
+ it.effect("rejects PATH output from a failed shell and tries the next candidate", () =>
Effect.gen(function* () {
- const env: NodeJS.ProcessEnv = {
+ 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 704b061d..71b33253 100644
+index 2b41b6ea7..dfe68ac88 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts
-@@ -8,6 +8,7 @@ import * as Schema from "effect/Schema";
- import * as ChildProcess from "effect/unstable/process/ChildProcess";
- import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+@@ -10,6 +10,7 @@ import * as Stream from "effect/Stream";
+ import * as ChildProcess from "effect/process/ChildProcess";
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import { flatpakAppId } from "../app/Flatpak.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
type EnvironmentPatch = Record;
-@@ -500,6 +501,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
+@@ -515,6 +516,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
const installShellEnvironment = (
config: ShellEnvironmentConfig,
): Effect.Effect => {
@@ -45,7 +45,7 @@ index 704b061d..71b33253 100644
return installWindowsEnvironment(config);
}
diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
-index 9745662c..39c39503 100644
+index 26ad0f1c4..fc5f324df 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -1,4 +1,4 @@
@@ -84,7 +84,7 @@ index 9745662c..39c39503 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 245925a6..3aac79ec 100644
+index 77069d3ba..03ff73f2a 100644
--- a/apps/desktop/src/updates/DesktopUpdates.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.ts
@@ -1,3 +1,4 @@
@@ -92,7 +92,7 @@ index 245925a6..3aac79ec 100644
import {
DESKTOP_UPDATE_RESTART_MARKER_FILE,
DesktopUpdateChannelSchema,
-@@ -254,6 +255,9 @@ function getAutoUpdateDisabledReason(args: {
+@@ -321,6 +322,9 @@ function getAutoUpdateDisabledReason(args: {
disabledByEnv: boolean;
hasUpdateFeedConfig: boolean;
}): string | null {
diff --git a/patches/0006-flatpak-document-portal-paths.patch b/patches/0006-flatpak-document-portal-paths.patch
index 4ebb83e..83bdffb 100644
--- a/patches/0006-flatpak-document-portal-paths.patch
+++ b/patches/0006-flatpak-document-portal-paths.patch
@@ -1,5 +1,5 @@
diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts
-index 2ed5a1f2f..321003f9f 100644
+index 2ed5a1f2f..aa1b59fde 100644
--- a/apps/desktop/src/electron/ElectronDialog.test.ts
+++ b/apps/desktop/src/electron/ElectronDialog.test.ts
@@ -3,7 +3,7 @@ import * as Cause from "effect/Cause";
@@ -92,7 +92,7 @@ index 2ed5a1f2f..321003f9f 100644
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 182803337..baa503e20 100644
+index b48953f11..2e868e089 100644
--- a/apps/desktop/src/electron/ElectronDialog.ts
+++ b/apps/desktop/src/electron/ElectronDialog.ts
@@ -1,3 +1,7 @@
@@ -103,7 +103,7 @@ index 182803337..baa503e20 100644
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-@@ -6,6 +9,58 @@ import * as Schema from "effect/Schema";
+@@ -6,6 +10,58 @@ import * as Schema from "effect/Schema";
import * as Electron from "electron";
@@ -162,7 +162,7 @@ index 182803337..baa503e20 100644
export class ElectronDialogPickFolderError extends Schema.TaggedError()(
"ElectronDialogPickFolderError",
{
-@@ -136,7 +191,21 @@ export const make = ElectronDialog.of({
+@@ -138,7 +194,21 @@ export const make = ElectronDialog.of({
if (result.canceled) {
return Option.none();
}
diff --git a/patches/0007-flatpak-editor-discovery.patch b/patches/0007-flatpak-editor-discovery.patch
index c908089..ae209c4 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 aab4d78f..0230bc5a 100644
+index 63e89541e..4609b1f6d 100644
--- a/apps/server/src/process/externalLauncher.test.ts
+++ b/apps/server/src/process/externalLauncher.test.ts
-@@ -159,6 +159,55 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
+@@ -210,6 +210,55 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
@@ -25,7 +25,7 @@ index aab4d78f..0230bc5a 100644
+ return editors;
+ }).pipe(
+ Effect.provide(
-+ testLayer({
++ layerTest({
+ platform: "linux",
+ env: { PATH: binDir },
+ onSpawn: (command) => spawned.push(command),
@@ -59,10 +59,10 @@ index aab4d78f..0230bc5a 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 ab287e4d..20be8a1c 100644
+index 3f2dab8f4..5e2c9e15b 100644
--- a/apps/server/src/process/externalLauncher.ts
+++ b/apps/server/src/process/externalLauncher.ts
-@@ -125,6 +125,67 @@ const CommandLookupEnvConfig = Config.all({
+@@ -146,6 +146,67 @@ const CommandLookupEnvConfig = Config.all({
const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
@@ -130,7 +130,7 @@ index ab287e4d..20be8a1c 100644
function parseTargetPathAndPosition(target: string): Option.Option {
const match = TARGET_WITH_POSITION_PATTERN.exec(target);
if (!match?.[1] || !match[2]) {
-@@ -417,6 +478,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
+@@ -446,6 +507,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> {
const available: EditorId[] = [];
@@ -138,7 +138,7 @@ index ab287e4d..20be8a1c 100644
for (const editor of EDITORS) {
if (editor.commands === null) {
-@@ -427,7 +489,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
+@@ -456,7 +518,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
}
const command = yield* resolveEditorCommand(editor, env);
@@ -147,7 +147,7 @@ index ab287e4d..20be8a1c 100644
available.push(editor.id);
}
}
-@@ -529,8 +591,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
+@@ -558,8 +620,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
new file mode 100644
index 0000000..1ed022b
--- /dev/null
+++ b/patches/0008-flatpak-cli-launcher.patch
@@ -0,0 +1,101 @@
+diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts
+index b2305fe98..14d6857e3 100644
+--- a/apps/desktop/src/app/DesktopCliCommand.test.ts
++++ b/apps/desktop/src/app/DesktopCliCommand.test.ts
+@@ -1,5 +1,5 @@
+ import * as NodeServices from "@effect/platform-node/NodeServices";
+-import { afterEach, beforeEach, expect, it } from "@effect/vitest";
++import { afterEach, beforeEach, expect, it, vi } from "@effect/vitest";
+ import * as Effect from "effect/Effect";
+ import * as FileSystem from "effect/FileSystem";
+ import * as Option from "effect/Option";
+@@ -95,6 +95,36 @@ afterEach(() => {
+ });
+
+ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
++ it.effect("hides Flatpak CLI setup and leaves launchers and host command links untouched", () =>
++ Effect.gen(function* () {
++ vi.stubEnv("FLATPAK_ID", "com.t3tools.t3code");
++ const fs = yield* FileSystem.FileSystem;
++ const path = yield* Path.Path;
++ const home = yield* fs.makeTempDirectoryScoped();
++ const baseDir = path.join(home, "flatpak-data");
++ const link = path.join(home, ".local", "bin", "t3");
++ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
++ yield* fs.writeFileString(link, "existing host command");
++ const command = yield* commandIn({ home, baseDir });
++ expect(yield* command.state).toEqual({
++ supported: false,
++ installedPath: null,
++ onPath: false,
++ });
++ expect((yield* Effect.flip(command.install)).message).toContain("Flatpak");
++ expect((yield* Effect.flip(command.uninstall)).message).toContain("Flatpak");
++ const shim = yield* DesktopCliShim.install.pipe(
++ Effect.provideService(
++ DesktopEnvironment.DesktopEnvironment,
++ environmentFor(path, { home, baseDir }),
++ ),
++ );
++ expect(Option.isNone(shim)).toBe(true);
++ expect(yield* fs.exists(path.join(baseDir, "bin", "t3"))).toBe(false);
++ expect(yield* fs.readFileString(link)).toBe("existing host command");
++ }).pipe(Effect.scoped, Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))),
++ );
++
+ it.effect("links the launcher onto PATH and removes only that link", () =>
+ 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
+--- a/apps/desktop/src/app/DesktopCliCommand.ts
++++ b/apps/desktop/src/app/DesktopCliCommand.ts
+@@ -9,6 +9,7 @@ import * as Stream from "effect/Stream";
+ import * as ChildProcess from "effect/process/ChildProcess";
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+
++import { flatpakAppId } from "./Flatpak.ts";
+ import * as DesktopCliShim from "./DesktopCliShim.ts";
+ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
+
+@@ -76,6 +77,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;
++ if (flatpakAppId(process.env, environment.platform) !== null) {
++ const unsupported = { supported: false, installedPath: null, onPath: false } as const;
++ const unavailable = Effect.fail(
++ new DesktopCliCommandError({ message: "The Flatpak package does not install a t3 command." }),
++ );
++ return DesktopCliCommand.of({
++ state: Effect.succeed(unsupported),
++ install: unavailable,
++ uninstall: unavailable,
++ });
++ }
+ const fs = yield* FileSystem.FileSystem;
+ 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
+--- a/apps/desktop/src/app/DesktopCliShim.ts
++++ b/apps/desktop/src/app/DesktopCliShim.ts
+@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
+ import * as FileSystem from "effect/FileSystem";
+ import * as Option from "effect/Option";
+
++import { flatpakAppId } from "./Flatpak.ts";
+ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
+ import { makeComponentLogger } from "./DesktopObservability.ts";
+
+@@ -140,7 +141,9 @@ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment[
+ */
+ export const install = Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+- if (!environment.isPackaged) return Option.none();
++ if (!environment.isPackaged || flatpakAppId(process.env, environment.platform) !== null) {
++ return Option.none();
++ }
+ const fs = yield* FileSystem.FileSystem;
+ const path = environment.path;
+ const windows = environment.platform === "win32";
diff --git a/patches/0009-flatpak-server-updates.patch b/patches/0009-flatpak-server-updates.patch
new file mode 100644
index 0000000..d09d921
--- /dev/null
+++ b/patches/0009-flatpak-server-updates.patch
@@ -0,0 +1,132 @@
+diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts
+index 443fad607..8a13118a4 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* (
+ });
+
+ it.layer(NodeServices.layer)("server self update", (it) => {
++ it.effect("rejects Flatpak updates and commits before invoking any updater", () =>
++ Effect.gen(function* () {
++ const { selfUpdate, order } = yield* makeHarness({
++ mode: "desktop",
++ desktopAppUpdate: {
++ available: true,
++ run: () => Effect.die("must not start a desktop update"),
++ commit: () => Effect.die("must not commit a desktop update"),
++ },
++ }).pipe(
++ Effect.provideService(HostProcess.Environment, {
++ T3CODE_FLATPAK_SERVER: "/installed/flatpak/t3code",
++ }),
++ );
++ const error = yield* Effect.flip(selfUpdate.update({ targetVersion: "1.1.0" }));
++ expect(error.reason).toContain("Flatpak");
++ const commitError = yield* Effect.flip(selfUpdate.commitDesktopUpdate("request"));
++ expect(commitError.reason).toContain("Flatpak");
++ expect(order).toEqual([]);
++ }).pipe(Effect.scoped),
++ );
++
+ it.effect("marks running threads at the boot-service handoff", () =>
+ 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
+--- 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* () {
+ 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* () {
+ const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
+ "cloud.server_self_update.update",
+ )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
++ if (flatpakManaged) {
++ return yield* failWith(
++ "Updates are managed by Flatpak. Update the Flatpak package on this machine.",
++ );
++ }
+ 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* () {
+ return ServerSelfUpdate.of({
+ update,
+ commitDesktopUpdate: (requestId, onHandoffAccepted) =>
+- desktopAppUpdate.commit(requestId, onHandoffAccepted),
++ flatpakManaged
++ ? Effect.fail(
++ failWith("Updates are managed by Flatpak. Update the Flatpak package on this machine."),
++ )
++ : desktopAppUpdate.commit(requestId, onHandoffAccepted),
+ });
+ });
+
+diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts
+index a77802357..43b6f793b 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) => {
+ yield* fs.symlink(entry, `${prefix}/bin/t3`);
+ const config = yield* makeServerConfig(baseDir);
+ yield* fs.makeDirectory(config.stateDir, { recursive: true });
+- for (const mode of ["web", "desktop"] as const) {
++ for (const { mode, flatpakManaged } of [
++ { mode: "web", flatpakManaged: false },
++ { mode: "desktop", flatpakManaged: false },
++ { mode: "desktop", flatpakManaged: true },
++ ] as const) {
+ 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) => {
+ Effect.provide(
+ ServerEnvironment.layer.pipe(
+ Layer.provide(layerEmptySecretStore),
+- Layer.provide(ServerConfig.layer({ ...config, mode })),
++ Layer.provide(ServerConfig.layer({ ...config, mode, desktopTelemetryControlFd: 5 })),
+ ),
+ ),
+ Effect.provideService(HostProcess.Arguments, ["node", entry]),
+ Effect.provideService(HostProcess.IsExecutable, false),
+ Effect.provideService(HostProcess.Platform, "linux"),
+- Effect.provideService(HostProcess.Environment, {}),
++ Effect.provideService(HostProcess.Environment, {
++ ...(flatpakManaged ? { T3CODE_FLATPAK_SERVER: "/installed/flatpak/t3code" } : {}),
++ }),
+ );
+ expect(descriptor.capabilities.serverInstallation).toEqual(
+ mode === "web" ? { kind: "npm-global", prefix } : undefined,
+@@ -118,6 +124,9 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
+ expect(descriptor.capabilities.serverSelfUpdate).toBe(
+ mode === "web" ? undefined : "desktop-managed",
+ );
++ expect(descriptor.capabilities.desktopAppUpdate).toBe(
++ mode === "desktop" && !flatpakManaged ? true : undefined,
++ );
+ expect(descriptor.capabilities).toMatchObject({
+ settledWorktreeCleanup: true,
+ storageCleanupRun: true,
+diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
+index 8e635f981..989a8b63d 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.
++ const flatpakManaged = Boolean((yield* HostProcess.Environment).T3CODE_FLATPAK_SERVER);
+ const desktopAppUpdate =
+- serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined;
++ !flatpakManaged &&
++ serverSelfUpdate === "desktop-managed" &&
++ serverConfig.desktopTelemetryControlFd !== undefined;
+
+ const descriptor: ExecutionEnvironmentDescriptor = {
+ environmentId,
diff --git a/patches/0010-flatpak-browser-requirements.patch b/patches/0010-flatpak-browser-requirements.patch
new file mode 100644
index 0000000..2e9fdf1
--- /dev/null
+++ b/patches/0010-flatpak-browser-requirements.patch
@@ -0,0 +1,187 @@
+diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts
+index 5ae0a7075..36351fd9b 100644
+--- a/apps/server/src/preview/PreviewBrowserHost.test.ts
++++ b/apps/server/src/preview/PreviewBrowserHost.test.ts
+@@ -73,6 +73,21 @@ const diagnose = (input: {
+ };
+
+ describe("diagnoseLaunchFailure", () => {
++ it.effect.each([
++ { output: SANDBOX_ABORT, ldd: "", instruction: "AppArmor profile" },
++ { output: LOADER_ERROR, ldd: LDD_IN_BARE_DEBIAN, instruction: "libglib-2.0.so.0" },
++ ])("gives Flatpak users manual setup instructions for $instruction", (input) =>
++ Effect.gen(function* () {
++ const { error } = yield* diagnose({ platform: "linux", ...input }).pipe(
++ Effect.provideService(HostProcess.Environment, {
++ T3CODE_FLATPAK_SERVER: "/installed/flatpak/t3code",
++ }),
++ );
++ expect(error?.message).toContain(input.instruction);
++ expect(error?.message).not.toContain("t3 browser setup");
++ }),
++ );
++
+ it.effect("turns Chrome's sandbox abort into the AppArmor steps", () =>
+ 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
+--- a/apps/server/src/preview/PreviewBrowserHost.ts
++++ b/apps/server/src/preview/PreviewBrowserHost.ts
+@@ -71,18 +71,24 @@ export const DEBIAN_PACKAGES: ReadonlyArray> = [
+
+ export class PreviewBrowserSandboxError extends Schema.TaggedError()(
+ "PreviewBrowserSandboxError",
+- { setupCommand: Schema.String },
++ { setupCommand: Schema.NullOr(Schema.String) },
+ ) {
+ override get message(): string {
++ if (this.setupCommand === null) {
++ return "This host blocks the sandbox T3's headless browser needs. Configure an AppArmor profile on the host to allow unprivileged user namespaces for T3's downloaded chrome-headless-shell. The Flatpak package does not change host security configuration.";
++ }
+ return `This host blocks the sandbox T3's browser runs in (AppArmor on Ubuntu 23.10+). Run \`${this.setupCommand}\` on the host once to allow it, then try again.`;
+ }
+ }
+
+ export class PreviewBrowserLibrariesError extends Schema.TaggedError()(
+ "PreviewBrowserLibrariesError",
+- { setupCommand: Schema.String, libraries: Schema.Array(Schema.String) },
++ { setupCommand: Schema.NullOr(Schema.String), libraries: Schema.Array(Schema.String) },
+ ) {
+ override get message(): string {
++ if (this.setupCommand === null) {
++ return `This host is missing libraries T3's headless browser needs (${this.libraries.join(", ")}). Install them using your host's package management tools, then try again. The Flatpak package does not install host libraries.`;
++ }
+ 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
+ readonly output: string;
+ readonly setupCommand: string;
+ }) {
+- const { setupCommand } = input;
++ const setupCommand = (yield* HostProcess.Environment).T3CODE_FLATPAK_SERVER
++ ? null
++ : input.setupCommand;
+ if (input.output.includes(NO_SANDBOX_SIGNATURE)) {
+ return new PreviewBrowserSandboxError({ setupCommand });
+ }
+diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts
+index 87789692e..259aab1fd 100644
+--- a/apps/server/src/preview/ServerBrowser.ts
++++ b/apps/server/src/preview/ServerBrowser.ts
+@@ -2477,7 +2477,9 @@ const make = Effect.gen(function* () {
+ (yield* PreviewBrowserHost.sandboxBlocked)
+ ) {
+ yield* Effect.logWarning(
+- `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`,
++ new PreviewBrowserHost.PreviewBrowserSandboxError({
++ setupCommand: (yield* HostProcess.Environment).T3CODE_FLATPAK_SERVER ? null : setupCommand,
++ }).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
+--- 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
+ const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup));
+
+ it.effect.each([
++ {
++ error: new PreviewBrowserHost.PreviewBrowserSandboxError({ setupCommand: null }),
++ need: "sandbox",
++ },
++ {
++ error: new PreviewBrowserHost.PreviewBrowserLibrariesError({
++ setupCommand: null,
++ libraries: ["libnss3.so"],
++ }),
++ need: "libraries",
++ },
+ {
+ error: new PreviewBrowserHost.PreviewBrowserSandboxError({
+ setupCommand: "sudo t3 browser setup",
+@@ -319,7 +330,7 @@ it.effect.each([
+ }),
+ need: "libraries",
+ },
+-])("tells viewers the command that sets up the host ($need)", ({ error, need }) =>
++])("reports host setup with a command only when one is available ($need)", ({ error, need }) =>
+ Effect.gen(function* () {
+ const browser = ServerBrowser.ServerBrowser.of({
+ clearProfile: () => Effect.void,
+@@ -359,7 +370,7 @@ it.effect.each([
+ expect(code).toBe(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE);
+ expect(yield* decodeHostSetup(reason)).toEqual({
+ need,
+- command: "sudo t3 browser setup",
++ command: error.setupCommand ?? "",
+ });
+ }).pipe(Effect.scoped),
+ );
+diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts
+index b2960c240..87d0e1570 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. */
+ const hostSetup = (cause: unknown): PreviewStreamHostSetup | undefined =>
+ isSandboxError(cause)
+- ? { need: "sandbox", command: cause.setupCommand }
++ ? { need: "sandbox", command: cause.setupCommand ?? "" }
+ : isLibrariesError(cause)
+- ? { need: "libraries", command: cause.setupCommand }
++ ? { need: "libraries", command: cause.setupCommand ?? "" }
+ : undefined;
+ 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
+--- a/apps/web/src/browser/ServerBrowserSurface.tsx
++++ b/apps/web/src/browser/ServerBrowserSurface.tsx
+@@ -892,7 +892,9 @@ export function ServerBrowserSurface(props: {
+
+ {previewStreamHostSetupMessage(hostSetup)}
+
+-
++ {hostSetup.command ? (
++
++ ) : null}
+
+diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts
+index 34a378697..6b995950b 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 => {
+ };
+
+ /** 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) => {
++ if (!setup.command) {
++ return setup.need === "sandbox"
++ ? "This server's headless browser needs an AppArmor profile configured on its host. Configure it manually, then try again."
++ : "This server's headless browser needs additional libraries on its host. Install the missing libraries with the host's package management tools, then try again.";
++ }
++ return setup.need === "sandbox"
+ ? "This server's host blocks the sandbox its browser runs in. Run this once on the host, then try again:"
+ : "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
+--- a/packages/contracts/src/preview.ts
++++ b/packages/contracts/src/preview.ts
+@@ -188,7 +188,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. */
+ command: Schema.String,
+ });
+ export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type;