Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
411fbe7
fix(desktop): surface Tailscale Serve setup failures with admin link
GenKerensky Jul 26, 2026
fac2abb
fix(desktop): roll back Tailscale Serve if settings persist fails
GenKerensky Jul 26, 2026
8669215
fix(desktop): keep Tailscale Serve configure cause on error
GenKerensky Jul 26, 2026
3aff1be
fix(desktop): relaunch AppImage via \$APPIMAGE path
GenKerensky Jul 26, 2026
359cf2c
fix(desktop): release single-instance lock before relaunch
GenKerensky Jul 26, 2026
c54e447
fix(desktop): delay AppImage re-exec to avoid FUSE remount race
GenKerensky Jul 26, 2026
2117b71
fix(desktop): show Tailscale HTTPS toggle without network access
GenKerensky Jul 26, 2026
546da78
fix(desktop): resolve Tailscale HTTPS on user opt-in only
GenKerensky Jul 26, 2026
c03ac41
fix(desktop): address review findings on Tailscale Serve toast + rela…
GenKerensky Jul 26, 2026
f47de2b
fix(desktop): tear down Serve eagerly and confirm the AppImage re-exec
GenKerensky Jul 26, 2026
4913c3b
fix(web): show progress on the Tailscale HTTPS toggle and errors in i…
GenKerensky Jul 26, 2026
032a8cb
fix(web): report Tailscale Serve failures under the Tailscale HTTPS row
GenKerensky Jul 26, 2026
b5cef43
fix(web): keep the setup URL visible in the Tailscale Serve error
GenKerensky Jul 26, 2026
5bccc30
fix: address code review — switch state, teardown warning, dead fallback
GenKerensky Jul 26, 2026
581469f
fix(desktop): drop inherited fds so the outgoing AppImage mount can u…
GenKerensky Jul 28, 2026
fabcf9b
fix(desktop): address reviewer findings on the AppImage relaunch helper
GenKerensky Jul 28, 2026
4331179
fix(desktop): stop the relaunch log from being able to block the rela…
GenKerensky Jul 28, 2026
8adbefb
fix(desktop): treat an already-unbound Serve as a successful disable
GenKerensky Jul 28, 2026
6e67f00
fix(desktop): roll the Serve binding back on interrupt and defect too
GenKerensky Jul 28, 2026
54bc913
fix(desktop): reconcile Serve error mapping and main's ElectronApp mock
GenKerensky Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
}),
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
releaseSingleInstanceLock: Effect.void,
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
Expand Down
172 changes: 172 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.relaunch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// @effect-diagnostics nodeBuiltinImport:off - Skip predicate runs at collection time, outside any Effect runtime.
import * as NodeFS from "node:fs";

import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import {
buildAppImageRelaunchShellCommand,
posixShellSingleQuote,
resolveDesktopRelaunchPlan,
} from "./resolveDesktopRelaunchOptions.ts";
import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts";

describe("resolveDesktopRelaunchPlan", () => {
it.effect("schedules a delayed AppImage re-exec with flag argv only", () =>
Effect.sync(() => {
const plan = resolveDesktopRelaunchPlan({
appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage",
execPath: "/tmp/.mount_t3_codXXXX/t3code",
argv: [
"/tmp/.mount_t3_codXXXX/t3code",
"--no-sandbox",
"/tmp/.mount_t3_codXXXX/resources/app.asar",
],
});

assert.deepEqual(plan, {
kind: "appimage-delayed",
appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage",
args: ["--no-sandbox"],
delayMs: 1_000,
});
}),
);

it.effect("trims APPIMAGE and falls back to electron relaunch when empty", () =>
Effect.sync(() => {
assert.deepEqual(
resolveDesktopRelaunchPlan({
appImagePath: " /opt/T3-Code.AppImage ",
execPath: "/tmp/.mount/app",
argv: ["/tmp/.mount/app"],
}),
{
kind: "appimage-delayed",
appImagePath: "/opt/T3-Code.AppImage",
args: [],
delayMs: 1_000,
},
);

assert.deepEqual(
resolveDesktopRelaunchPlan({
appImagePath: " ",
execPath: "/usr/bin/t3-code",
argv: ["/usr/bin/t3-code", "--flag"],
}),
{
kind: "electron",
execPath: "/usr/bin/t3-code",
args: ["--flag"],
},
);
}),
);

it.effect("preserves packaged non-AppImage exec path and argv", () =>
Effect.sync(() => {
const plan = resolveDesktopRelaunchPlan({
appImagePath: null,
execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code",
argv: ["/Applications/T3 Code.app/Contents/MacOS/T3 Code", "--inspect"],
});

assert.deepEqual(plan, {
kind: "electron",
execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code",
args: ["--inspect"],
});
}),
);
});

describe("buildAppImageRelaunchShellCommand", () => {
it.effect("quotes the AppImage path and delays before exec", () =>
Effect.sync(() => {
const command = buildAppImageRelaunchShellCommand({
appImagePath: "/home/user/T3 Code.AppImage",
args: ["--no-sandbox"],
delayMs: 1_000,
});

assert.equal(
command,
`sleep 1 && exec ${posixShellSingleQuote("/home/user/T3 Code.AppImage")} ${posixShellSingleQuote("--no-sandbox")}`,
);

assert.equal(posixShellSingleQuote("it's"), `'it'\\''s'`);
}),
);

it.effect("omits the fd cleanup unless the shell is known to handle it", () =>
Effect.sync(() => {
const base = {
appImagePath: "/opt/T3.AppImage",
args: [],
delayMs: 1_000,
} as const;

// dash reads `exec 10>&-` as a command named "10" and a failed exec kills
// a non-interactive shell, so emitting this for /bin/sh would abort the
// helper before the re-exec and lose the app entirely.
assert.isFalse(buildAppImageRelaunchShellCommand(base).includes("/proc/$$/fd"));

const withCleanup = buildAppImageRelaunchShellCommand({
...base,
closeInheritedFds: true,
});
// Must run before the sleep, so the outgoing mount can be released during it.
assert.isTrue(withCleanup.startsWith("for fd in /proc/$$/fd/*;"), withCleanup);
assert.isTrue(
withCleanup.indexOf("exec $n>&-") < withCleanup.indexOf("sleep 1"),
withCleanup,
);
}),
);

it.effect("records a relaunch failure without letting the log block the exec", () =>
Effect.sync(() => {
const command = buildAppImageRelaunchShellCommand({
appImagePath: "/opt/T3.AppImage",
args: [],
delayMs: 1_000,
logPath: "/home/user/.t3/logs/relaunch.log",
});

// Wrapping the group in `2>>log` would abort the whole helper when the
// log directory is missing, and would also follow a *successful* exec
// into the relaunched app. Guard with a test and append afterwards.
assert.notInclude(command, "} 2>>");
assert.include(command, `[ -x ${posixShellSingleQuote("/opt/T3.AppImage")} ] && exec `);
// A bad log path must not matter once we are past the exec.
assert.isTrue(
command.endsWith(
`>>${posixShellSingleQuote("/home/user/.t3/logs/relaunch.log")} 2>/dev/null`,
),
command,
);
}),
);
});

describe("scheduleAppImageRelaunch", () => {
// Really spawns the helper. The code path is POSIX-only by construction, so
// gate on the shell it needs rather than on a platform name — on a Windows
// dev machine (a supported setup) this would otherwise fail with ENOENT.
it.effect.skipIf(!NodeFS.existsSync("/bin/sh"))(
"resolves only once the detached helper has actually spawned",
() =>
// Resolves on the `spawn` event, well before the helper's sleep elapses.
// The caller depends on this to know it is safe to release the
// single-instance lock and exit rather than vanishing on a failed spawn.
Effect.promise(() =>
scheduleAppImageRelaunch({
kind: "appimage-delayed",
appImagePath: "/bin/true",
args: [],
delayMs: 200,
}),
),
);
});
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => {
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
releaseSingleInstanceLock: Effect.void,
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
Expand Down
65 changes: 59 additions & 6 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";
import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts";

export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<DesktopLifecycleRelaunchError>()(
"DesktopLifecycleRelaunchError",
Expand All @@ -27,6 +29,8 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<Deskt
}
}

export { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";

export type DesktopLifecycleRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopShutdown.DesktopShutdown
Expand Down Expand Up @@ -154,16 +158,65 @@ export const make = DesktopLifecycle.of({
yield* electronApp.exit(75);
return;
}
yield* electronApp.relaunch({
const relaunchPlan = resolveDesktopRelaunchPlan({
// AppImage runtime injects APPIMAGE; see resolveDesktopRelaunchPlan.
appImagePath: process.env.APPIMAGE,
execPath: process.execPath,
args: process.argv.slice(1),
argv: process.argv,
});
yield* logLifecycleInfo("desktop relaunch exec", {
reason,
kind: relaunchPlan.kind,
execPath:
relaunchPlan.kind === "appimage-delayed"
? relaunchPlan.appImagePath
: relaunchPlan.execPath,
argumentCount: relaunchPlan.args.length,
...(relaunchPlan.kind === "appimage-delayed" ? { delayMs: relaunchPlan.delayMs } : {}),
});
if (relaunchPlan.kind === "appimage-delayed") {
// Delayed shell re-exec avoids racing the AppImage FUSE unmount, which
// otherwise surfaces "Cannot mount AppImage, please check your FUSE setup."
// Await the spawn: a failure here must not fall through to exit(0), or
// the app just disappears and never comes back.
yield* Effect.tryPromise({
try: () =>
scheduleAppImageRelaunch(
relaunchPlan,
process.env,
// The re-exec fires after we are gone, so its own failures can
// only surface here.
environment.path.join(environment.logDir, "relaunch.log"),
),
catch: (cause) => new DesktopLifecycleRelaunchError({ reason, cause }),
});
} else {
yield* electronApp.relaunch({
execPath: relaunchPlan.execPath,
args: relaunchPlan.args,
});
}
// Only give up the lock once the relaunch is committed. Neither path has
// started the next process yet (app.relaunch defers its spawn to exit,
// the AppImage helper sleeps first), so releasing here still beats the
// successor's requestSingleInstanceLock().
yield* electronApp.releaseSingleInstanceLock;
yield* electronApp.exit(0);
}).pipe(
Effect.catchCause((cause) => {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
return logLifecycleError(error.message, { error });
}),
Effect.catchCause((cause) =>
Effect.gen(function* () {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
yield* logLifecycleError(error.message, { error });
// By this point `quitting` is set and requestDesktopShutdownAndWait
// has already torn the backend down, so there is no working app left
// to return to. Staying alive would just hold the single-instance
// lock on a dead instance, and every later launch would focus *this*
// window instead of starting a usable one. Hand the lock back and
// exit non-zero so the next launch comes up clean.
yield* electronApp.releaseSingleInstanceLock.pipe(Effect.ignore);
yield* electronApp.exit(1);
}),
),
Effect.forkDetach,
Effect.asVoid,
);
Expand Down
120 changes: 120 additions & 0 deletions apps/desktop/src/app/resolveDesktopRelaunchOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
export interface DesktopElectronRelaunchPlan {
readonly kind: "electron";
readonly execPath: string;
readonly args: string[];
}

export interface DesktopAppImageRelaunchPlan {
readonly kind: "appimage-delayed";
readonly appImagePath: string;
readonly args: string[];
/** Delay before re-exec so the current AppImage can unmount its FUSE image. */
readonly delayMs: number;
}

export type DesktopRelaunchPlan = DesktopElectronRelaunchPlan | DesktopAppImageRelaunchPlan;

export const DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS = 1_000;

/**
* Resolve how the desktop process should restart for the current packaging mode.
*
* AppImage mounts the payload under a temporary directory and sets `APPIMAGE`
* to the outer `.AppImage` path. `process.execPath` / `process.argv` point at
* the mount, which is unmounted when the process exits — so relaunching with
* those paths (or racing a second FUSE mount while the first unmounts) fails
* with "Cannot mount AppImage, please check your FUSE setup."
*
* For AppImage we schedule a delayed re-exec of `$APPIMAGE` after the current
* process exits. Keep only flag-style argv entries (e.g. `--no-sandbox` from
* Gear Lever / Flatpak launches).
*/
export function resolveDesktopRelaunchPlan(input: {
readonly appImagePath?: string | null | undefined;
readonly execPath: string;
readonly argv: readonly string[];
readonly appImageDelayMs?: number;
}): DesktopRelaunchPlan {
const appImagePath = input.appImagePath?.trim();
if (appImagePath) {
return {
kind: "appimage-delayed",
appImagePath,
args: input.argv.slice(1).filter((arg) => arg.startsWith("--")),
delayMs: input.appImageDelayMs ?? DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS,
};
}

return {
kind: "electron",
execPath: input.execPath,
args: input.argv.slice(1),
};
}

/** POSIX-safe single-quoting for `/bin/sh -c` command construction. */
export function posixShellSingleQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}

/**
* Drop file descriptors inherited from the exiting app before re-exec.
*
* Chromium keeps fds open without CLOEXEC (`app.asar`, `icudtl.dat`, the `.pak`
* files, the mount directory itself), and `spawn` cannot close them for us.
* Inherited through this helper's `exec`, they keep the *outgoing* AppImage's
* FUSE mount busy forever: its runtime process parks in `fuse_dev_do_read` and
* never unmounts, so every restart strands a mount and a process.
*
* Only emitted for bash. Chromium's fds are well above 9, and dash — `/bin/sh`
* on Debian and Ubuntu — does not accept multi-digit fd numbers in
* redirections: it reads `exec 10>&-` as running a command named `10`, and a
* failed `exec` terminates a non-interactive shell on the spot. That kills the
* helper before it reaches the re-exec, leaving the app gone for good. `eval`
* does not help, because this is a failed command rather than a parse error,
* so `|| true` never runs.
*/
const CLOSE_INHERITED_FDS_SNIPPET =
'for fd in /proc/$$/fd/*; do n=${fd##*/}; case "$n" in 0|1|2) continue;; esac; ' +
'eval "exec $n>&-" 2>/dev/null || true; done';

export function buildAppImageRelaunchShellCommand(input: {
readonly appImagePath: string;
readonly args: readonly string[];
readonly delayMs: number;
/** Only safe under bash; see {@link CLOSE_INHERITED_FDS_SNIPPET}. */
readonly closeInheritedFds?: boolean;
/** Captures the helper's stderr, so a failed delayed `exec` is diagnosable. */
readonly logPath?: string | undefined;
}): string {
// Whole seconds only: POSIX specifies `sleep` as taking an integer, and a
// /bin/sh whose sleep rejects "1.0" would short-circuit the `&&` and never
// exec — an app that quits and never returns, which is the failure this
// helper exists to avoid. The spawn succeeds either way, so nothing would
// report it.
const sleepSeconds = Math.max(1, Math.ceil(input.delayMs / 1_000));
const quotedPath = posixShellSingleQuote(input.appImagePath);
const quotedArgs = input.args.map(posixShellSingleQuote).join(" ");
const execTarget = quotedArgs.length > 0 ? `${quotedPath} ${quotedArgs}` : quotedPath;
// The exec happens after we have exited, so a missing or non-executable
// AppImage can only be reported by leaving a trace behind. Test for it and
// append a line *after* the exec instead of redirecting the whole group:
//
// - `{ ...; } 2>>log` fails the redirection outright when the log's
// directory is missing or unwritable, and the re-exec then never runs at
// all — trading a diagnosable failure for a guaranteed one.
// - that redirection also survives a *successful* exec, so the relaunched
// app would spend its whole life appending Chromium stderr to an
// unrotated file.
//
// `2>/dev/null` on the append keeps a bad log path from mattering, and the
// exec'd process keeps the stdio it was always meant to have.
const relaunch = input.logPath
? `sleep ${sleepSeconds}; [ -x ${quotedPath} ] && exec ${execTarget}; ` +
`echo ${posixShellSingleQuote(`T3 Code relaunch failed: ${input.appImagePath} is missing or not executable`)} ` +
`>>${posixShellSingleQuote(input.logPath)} 2>/dev/null`
: `sleep ${sleepSeconds} && exec ${execTarget}`;
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
// Close first, then sleep: releasing the fds up front lets the outgoing
// mount unmount *during* the delay rather than after it.
return input.closeInheritedFds ? `${CLOSE_INHERITED_FDS_SNIPPET}; ${relaunch}` : relaunch;
}
Loading
Loading