| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | |
| 6 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 7 | import { 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. |
| 15 | const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); |
| 16 | |
| 17 | export 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). */ |
| 20 | const SERVER_ENTRY = "apps/server/dist/bin.mjs"; |
| 21 | |
| 22 | const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; |
| 23 | /** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */ |
| 24 | const cmdText = (value: string) => value.replaceAll("%", "%%"); |
| 25 | const cmdWord = (value: string) => `"${cmdText(value)}"`; |
| 26 | |
| 27 | const MOVED = "The Knife has moved or been removed. Open the app once to update this command."; |
| 28 | |
| 29 | export 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 | */ |
| 41 | export 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. */ |
| 129 | export 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 | */ |
| 141 | export 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 | }); |