1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5
6import * as DesktopEnvironment from "./DesktopEnvironment.ts";
7import { makeComponentLogger } from "./DesktopObservability.ts";
8
9// A desktop install puts no `t3` on PATH, so commands the server asks a person
10// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small
11// launcher for its bundled CLI in the T3 home, which is never on PATH and so
12// never shadows another `t3`, and the server names it by absolute path in those
13// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run,
14// so its launcher mounts the AppImage itself instead of pointing into it.
15const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim");
16
17export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI.";
18
19/** Server entry inside the app, relative to its server root (an asar archive when packaged). */
20const SERVER_ENTRY = "apps/server/dist/bin.mjs";
21
22const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
23/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */
24const cmdText = (value: string) => value.replaceAll("%", "%%");
25const cmdWord = (value: string) => `"${cmdText(value)}"`;
26
27const MOVED = "The Knife has moved or been removed. Open the app once to update this command.";
28
29export type CliShimTarget =
30 | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string }
31 | { readonly kind: "direct"; readonly executable: string; readonly entry: string }
32 | { readonly kind: "windows"; readonly executable: string; readonly entry: string };
33
34/**
35 * The launcher script. Electron runs the server as plain Node with
36 * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive.
37 * The launcher's own path and the app's T3 home are written in, so the command
38 * the server shows is absolute and `sudo`, which clears the environment, still
39 * runs against this install's home.
40 */
41export const renderCliShim = (input: {
42 readonly target: CliShimTarget;
43 readonly shimPath: string;
44 readonly t3Home: string;
45}) => {
46 const { target } = input;
47 if (target.kind === "windows") {
48 const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) =>
49 [...value].some((character) => character.codePointAt(0)! > 0x7f),
50 );
51 const restore = utf8 ? ["chcp %t3_codepage% >nul"] : [];
52 return [
53 "@echo off",
54 `rem ${MARKER}`,
55 // Delayed expansion would rewrite `!name!` in paths and arguments.
56 "setlocal EnableExtensions DisableDelayedExpansion",
57 // The file is UTF-8. Only a non-ASCII path needs the console switched to
58 // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the
59 // switch is left out for the common all-ASCII install.
60 ...(utf8
61 ? [
62 `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`,
63 "chcp 65001 >nul",
64 ]
65 : []),
66 `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`,
67 `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`,
68 'set "ELECTRON_RUN_AS_NODE=1"',
69 // A goto, not a parenthesized block: "Program Files (x86)" would close the block early.
70 `if exist ${cmdWord(target.executable)} goto run`,
71 ...restore,
72 `echo ${MOVED} 1>&2`,
73 "exit /b 127",
74 ":run",
75 `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`,
76 'set "t3_exit=%ERRORLEVEL%"',
77 ...restore,
78 "exit /b %t3_exit%",
79 "",
80 ].join("\r\n");
81 }
82 const header = [
83 "#!/bin/sh",
84 `# ${MARKER}`,
85 `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`,
86 `home=${shellWord(input.t3Home)}`,
87 'export T3CODE_HOME="${T3CODE_HOME:-$home}"',
88 "export ELECTRON_RUN_AS_NODE=1",
89 `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`,
90 'if [ ! -x "$app" ]; then',
91 ` echo "${MOVED}" >&2`,
92 " exit 127",
93 "fi",
94 ];
95 if (target.kind === "direct") {
96 return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n");
97 }
98 const run = (root: string) =>
99 `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`;
100 return [
101 ...header,
102 // Extract-and-run would make the runtime start the app instead of mounting it.
103 "unset APPIMAGE_EXTRACT_AND_RUN",
104 // The AppImage runtime prints its mount point, then stays mounted until
105 // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit.
106 "work=$(mktemp -d) || exit 1",
107 '"$app" --appimage-mount >"$work/mount" 2>/dev/null &',
108 "mounter=$!",
109 `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`,
110 "trap 'exit 130' INT TERM",
111 "mount=",
112 'while kill -0 "$mounter" 2>/dev/null; do',
113 ' mount=$(head -n 1 "$work/mount")',
114 ' [ -d "$mount" ] && break',
115 " mount=",
116 " sleep 0.05",
117 "done",
118 // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works.
119 'if [ -z "$mount" ]; then',
120 ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }',
121 ' mount="$work/squashfs-root"',
122 "fi",
123 run("$mount"),
124 "",
125 ].join("\n");
126};
127
128/** Where the packaged app keeps its launcher: `<T3 home>/bin/t3`, `t3.cmd` on Windows. */
129export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) =>
130 environment.path.join(
131 environment.baseDir,
132 "bin",
133 environment.platform === "win32" ? "t3.cmd" : "t3",
134 );
135
136/**
137 * Writes the packaged app's launcher to `<T3 home>/bin` and returns its path
138 * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout
139 * and get none.
140 */
141export const install = Effect.gen(function* () {
142 const environment = yield* DesktopEnvironment.DesktopEnvironment;
143 if (!environment.isPackaged) return Option.none<string>();
144 const fs = yield* FileSystem.FileSystem;
145 const path = environment.path;
146 const windows = environment.platform === "win32";
147 const shimPath = launcherPath(environment);
148 const entry = path.join(environment.serverRoot, SERVER_ENTRY);
149 const target: CliShimTarget = windows
150 ? { kind: "windows", executable: process.execPath, entry }
151 : Option.match(environment.appImagePath, {
152 onSome: (appImage) => ({
153 kind: "appimage" as const,
154 appImage,
155 executableName: path.basename(process.execPath),
156 }),
157 // macOS and .deb installs live at a fixed path, so the launcher runs the app directly.
158 onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }),
159 });
160 const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir });
161
162 return yield* Effect.gen(function* () {
163 const existing = yield* fs.readFileString(shimPath).pipe(Effect.option);
164 if (Option.isSome(existing) && !existing.value.includes(MARKER)) {
165 // Someone else's file; leave it, and let commands fall back to plain `t3`.
166 yield* logWarning("leaving a t3 launcher the app did not write", { shimPath });
167 return Option.none<string>();
168 }
169 if (Option.getOrUndefined(existing) !== content) {
170 yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true });
171 // Written beside the launcher and renamed over it, so a running `t3` never reads half a file.
172 const staging = `${shimPath}.${process.pid}.tmp`;
173 yield* fs.writeFileString(staging, content, { mode: 0o755 });
174 yield* fs.rename(staging, shimPath);
175 yield* logInfo("installed t3 launcher", { shimPath });
176 }
177 return Option.some(shimPath);
178 }).pipe(
179 // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`.
180 Effect.catchCause((cause) =>
181 logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe(
182 Effect.as(Option.none<string>()),
183 ),
184 ),
185 Effect.withSpan("desktop.cliShim.install"),
186 );
187});