Open History to see the last commit
1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Random from "effect/Random";
6
7import * as DesktopEnvironment from "./DesktopEnvironment.ts";
8import { makeComponentLogger } from "./DesktopObservability.ts";
9
10// AppImage mounts change each run; the launcher must mount the image itself.
11const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim");
12
13export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI.";
14
15const SERVER_ENTRY = "apps/server/dist/bin.mjs";
16
17const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
18/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */
19const cmdText = (value: string) => value.replaceAll("%", "%%");
20const cmdWord = (value: string) => `"${cmdText(value)}"`;
21
22const MOVED = "T3 Code has moved or been removed. Open the app once to update this command.";
23
24export type CliShimTarget =
25 | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string }
26 | { readonly kind: "direct"; readonly executable: string; readonly entry: string }
27 | { readonly kind: "windows"; readonly executable: string; readonly entry: string };
28
29/** Embed this install's home and executable path so `sudo`, which clears the environment, uses the same install. */
30export const renderCliShim = (input: {
31 readonly target: CliShimTarget;
32 readonly shimPath: string;
33 readonly t3Home: string;
34}) => {
35 const { target } = input;
36 if (target.kind === "windows") {
37 const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) =>
38 [...value].some((character) => character.codePointAt(0)! > 0x7f),
39 );
40 const restore = utf8 ? ["chcp %t3_codepage% >nul"] : [];
41 return [
42 "@echo off",
43 `rem ${MARKER}`,
44 // Delayed expansion would rewrite `!name!` in paths and arguments.
45 "setlocal EnableExtensions DisableDelayedExpansion",
46 // Ctrl-C skips codepage restoration, so switch to UTF-8 only for non-ASCII install paths.
47 ...(utf8
48 ? [
49 `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`,
50 "chcp 65001 >nul",
51 ]
52 : []),
53 `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`,
54 `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`,
55 'set "ELECTRON_RUN_AS_NODE=1"',
56 // Electron's Windows executable starts without stdin; this tells the CLI to reopen the console.
57 'set "T3CODE_DESKTOP_CLI_LAUNCHER=1"',
58 // A goto, not a parenthesized block: "Program Files (x86)" would close the block early.
59 `if exist ${cmdWord(target.executable)} goto run`,
60 ...restore,
61 `echo ${MOVED} 1>&2`,
62 "exit /b 127",
63 ":run",
64 `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`,
65 'set "t3_exit=%ERRORLEVEL%"',
66 ...restore,
67 "exit /b %t3_exit%",
68 "",
69 ].join("\r\n");
70 }
71 const header = [
72 "#!/bin/sh",
73 `# ${MARKER}`,
74 `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`,
75 `home=${shellWord(input.t3Home)}`,
76 'export T3CODE_HOME="${T3CODE_HOME:-$home}"',
77 "export ELECTRON_RUN_AS_NODE=1",
78 `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`,
79 'if [ ! -x "$app" ]; then',
80 ` echo "${MOVED}" >&2`,
81 " exit 127",
82 "fi",
83 ];
84 if (target.kind === "direct") {
85 return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n");
86 }
87 const run = (root: string) =>
88 `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`;
89 return [
90 ...header,
91 // Extract-and-run would make the runtime start the app instead of mounting it.
92 "unset APPIMAGE_EXTRACT_AND_RUN",
93 // Its own session keeps a group signal (Ctrl-C, SSH hangup) from unmounting it under the
94 // running server; the traps unmount it after. Without setsid the extract fallback runs.
95 "work=$(mktemp -d) || exit 1",
96 'setsid "$app" --appimage-mount >"$work/mount" 2>/dev/null &',
97 "mounter=$!",
98 `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`,
99 "trap 'exit 130' INT TERM HUP",
100 "mount=",
101 'while kill -0 "$mounter" 2>/dev/null; do',
102 ' mount=$(head -n 1 "$work/mount")',
103 ' [ -d "$mount" ] && break',
104 " mount=",
105 " sleep 0.05",
106 "done",
107 // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works.
108 'if [ -z "$mount" ]; then',
109 ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }',
110 ' mount="$work/squashfs-root"',
111 "fi",
112 // The runtime sets this when it launches the app; the CLI skips its path-keyed compile cache on it.
113 'export APPIMAGE="$app"',
114 run("$mount"),
115 "",
116 ].join("\n");
117};
118
119export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) =>
120 environment.path.join(
121 environment.baseDir,
122 "bin",
123 environment.platform === "win32" ? "t3.cmd" : "t3",
124 );
125
126export const install = Effect.gen(function* () {
127 const environment = yield* DesktopEnvironment.DesktopEnvironment;
128 if (!environment.isPackaged) return Option.none<string>();
129 const fs = yield* FileSystem.FileSystem;
130 const path = environment.path;
131 const windows = environment.platform === "win32";
132 const shimPath = launcherPath(environment);
133 const entry = path.join(environment.serverRoot, SERVER_ENTRY);
134 const target: CliShimTarget = windows
135 ? { kind: "windows", executable: process.execPath, entry }
136 : Option.match(environment.appImagePath, {
137 onSome: (appImage) => ({
138 kind: "appimage" as const,
139 appImage,
140 executableName: path.basename(process.execPath),
141 }),
142 onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }),
143 });
144 const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir });
145
146 return yield* Effect.gen(function* () {
147 const existing = yield* fs.readFileString(shimPath).pipe(Effect.option);
148 if (Option.isSome(existing) && !existing.value.includes(MARKER)) {
149 yield* logWarning("leaving a t3 launcher the app did not write", { shimPath });
150 return Option.none<string>();
151 }
152 if (Option.getOrUndefined(existing) !== content) {
153 yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true });
154 // Unique per call: backend startup and the first-launch command install can race here.
155 const staging = `${shimPath}.${process.pid}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`;
156 yield* fs.writeFileString(staging, content, { mode: 0o755 });
157 yield* fs.rename(staging, shimPath);
158 yield* logInfo("installed t3 launcher", { shimPath });
159 }
160 return Option.some(shimPath);
161 }).pipe(
162 Effect.catchCause((cause) =>
163 logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe(
164 Effect.as(Option.none<string>()),
165 ),
166 ),
167 Effect.withSpan("desktop.cliShim.install"),
168 );
169});