|
| 1 | +export interface DesktopElectronRelaunchPlan { |
| 2 | + readonly kind: "electron"; |
| 3 | + readonly execPath: string; |
| 4 | + readonly args: string[]; |
| 5 | +} |
| 6 | + |
| 7 | +export interface DesktopAppImageRelaunchPlan { |
| 8 | + readonly kind: "appimage-delayed"; |
| 9 | + readonly appImagePath: string; |
| 10 | + readonly args: string[]; |
| 11 | + /** Delay before re-exec so the current AppImage can unmount its FUSE image. */ |
| 12 | + readonly delayMs: number; |
| 13 | +} |
| 14 | + |
| 15 | +export type DesktopRelaunchPlan = DesktopElectronRelaunchPlan | DesktopAppImageRelaunchPlan; |
| 16 | + |
| 17 | +export const DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS = 1_000; |
| 18 | + |
| 19 | +/** |
| 20 | + * Resolve how the desktop process should restart for the current packaging mode. |
| 21 | + * |
| 22 | + * AppImage mounts the payload under a temporary directory and sets `APPIMAGE` |
| 23 | + * to the outer `.AppImage` path. `process.execPath` / `process.argv` point at |
| 24 | + * the mount, which is unmounted when the process exits — so relaunching with |
| 25 | + * those paths (or racing a second FUSE mount while the first unmounts) fails |
| 26 | + * with "Cannot mount AppImage, please check your FUSE setup." |
| 27 | + * |
| 28 | + * For AppImage we schedule a delayed re-exec of `$APPIMAGE` after the current |
| 29 | + * process exits. Keep only flag-style argv entries (e.g. `--no-sandbox` from |
| 30 | + * Gear Lever / Flatpak launches). |
| 31 | + */ |
| 32 | +export function resolveDesktopRelaunchPlan(input: { |
| 33 | + readonly appImagePath?: string | null | undefined; |
| 34 | + readonly execPath: string; |
| 35 | + readonly argv: readonly string[]; |
| 36 | + readonly appImageDelayMs?: number; |
| 37 | +}): DesktopRelaunchPlan { |
| 38 | + const appImagePath = input.appImagePath?.trim(); |
| 39 | + if (appImagePath) { |
| 40 | + return { |
| 41 | + kind: "appimage-delayed", |
| 42 | + appImagePath, |
| 43 | + args: input.argv.slice(1).filter((arg) => arg.startsWith("--")), |
| 44 | + delayMs: input.appImageDelayMs ?? DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS, |
| 45 | + }; |
| 46 | + } |
| 47 | + |
| 48 | + return { |
| 49 | + kind: "electron", |
| 50 | + execPath: input.execPath, |
| 51 | + args: input.argv.slice(1), |
| 52 | + }; |
| 53 | +} |
| 54 | + |
| 55 | +/** POSIX-safe single-quoting for `/bin/sh -c` command construction. */ |
| 56 | +export function posixShellSingleQuote(value: string): string { |
| 57 | + return `'${value.replaceAll("'", `'\\''`)}'`; |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Drop file descriptors inherited from the exiting app before re-exec. |
| 62 | + * |
| 63 | + * Chromium keeps fds open without CLOEXEC (`app.asar`, `icudtl.dat`, the `.pak` |
| 64 | + * files, the mount directory itself), and `spawn` cannot close them for us. |
| 65 | + * Inherited through this helper's `exec`, they keep the *outgoing* AppImage's |
| 66 | + * FUSE mount busy forever: its runtime process parks in `fuse_dev_do_read` and |
| 67 | + * never unmounts, so every restart strands a mount and a process. |
| 68 | + * |
| 69 | + * Only emitted for bash. Chromium's fds are well above 9, and dash — `/bin/sh` |
| 70 | + * on Debian and Ubuntu — does not accept multi-digit fd numbers in |
| 71 | + * redirections: it reads `exec 10>&-` as running a command named `10`, and a |
| 72 | + * failed `exec` terminates a non-interactive shell on the spot. That kills the |
| 73 | + * helper before it reaches the re-exec, leaving the app gone for good. `eval` |
| 74 | + * does not help, because this is a failed command rather than a parse error, |
| 75 | + * so `|| true` never runs. |
| 76 | + */ |
| 77 | +const CLOSE_INHERITED_FDS_SNIPPET = |
| 78 | + 'for fd in /proc/$$/fd/*; do n=${fd##*/}; case "$n" in 0|1|2) continue;; esac; ' + |
| 79 | + 'eval "exec $n>&-" 2>/dev/null || true; done'; |
| 80 | + |
| 81 | +export function buildAppImageRelaunchShellCommand(input: { |
| 82 | + readonly appImagePath: string; |
| 83 | + readonly args: readonly string[]; |
| 84 | + readonly delayMs: number; |
| 85 | + /** Only safe under bash; see {@link CLOSE_INHERITED_FDS_SNIPPET}. */ |
| 86 | + readonly closeInheritedFds?: boolean; |
| 87 | + /** Captures the helper's stderr, so a failed delayed `exec` is diagnosable. */ |
| 88 | + readonly logPath?: string | undefined; |
| 89 | +}): string { |
| 90 | + // Whole seconds only: POSIX specifies `sleep` as taking an integer, and a |
| 91 | + // /bin/sh whose sleep rejects "1.0" would short-circuit the `&&` and never |
| 92 | + // exec — an app that quits and never returns, which is the failure this |
| 93 | + // helper exists to avoid. The spawn succeeds either way, so nothing would |
| 94 | + // report it. |
| 95 | + const sleepSeconds = Math.max(1, Math.ceil(input.delayMs / 1_000)); |
| 96 | + const quotedPath = posixShellSingleQuote(input.appImagePath); |
| 97 | + const quotedArgs = input.args.map(posixShellSingleQuote).join(" "); |
| 98 | + const execTarget = quotedArgs.length > 0 ? `${quotedPath} ${quotedArgs}` : quotedPath; |
| 99 | + // The exec happens after we have exited, so a missing or non-executable |
| 100 | + // AppImage can only be reported by leaving a trace behind. Test for it and |
| 101 | + // append a line *after* the exec instead of redirecting the whole group: |
| 102 | + // |
| 103 | + // - `{ ...; } 2>>log` fails the redirection outright when the log's |
| 104 | + // directory is missing or unwritable, and the re-exec then never runs at |
| 105 | + // all — trading a diagnosable failure for a guaranteed one. |
| 106 | + // - that redirection also survives a *successful* exec, so the relaunched |
| 107 | + // app would spend its whole life appending Chromium stderr to an |
| 108 | + // unrotated file. |
| 109 | + // |
| 110 | + // `2>/dev/null` on the append keeps a bad log path from mattering, and the |
| 111 | + // exec'd process keeps the stdio it was always meant to have. |
| 112 | + const relaunch = input.logPath |
| 113 | + ? `sleep ${sleepSeconds}; [ -x ${quotedPath} ] && exec ${execTarget}; ` + |
| 114 | + `echo ${posixShellSingleQuote(`T3 Code relaunch failed: ${input.appImagePath} is missing or not executable`)} ` + |
| 115 | + `>>${posixShellSingleQuote(input.logPath)} 2>/dev/null` |
| 116 | + : `sleep ${sleepSeconds} && exec ${execTarget}`; |
| 117 | + // Close first, then sleep: releasing the fds up front lets the outgoing |
| 118 | + // mount unmount *during* the delay rather than after it. |
| 119 | + return input.closeInheritedFds ? `${CLOSE_INHERITED_FDS_SNIPPET}; ${relaunch}` : relaunch; |
| 120 | +} |
0 commit comments