| 1 | /** |
| 2 | * `t3 browser setup` - prepares a Linux host for T3's headless browser, which |
| 3 | * server browser tabs and HTML render previews share. It is the fix every |
| 4 | * browser host error names, so it does the whole job in one run: |
| 5 | * |
| 6 | * - installs the AppArmor profile that lets Chrome's sandbox run where the host |
| 7 | * restricts unprivileged user namespaces (Ubuntu 23.10+), and |
| 8 | * - installs the Debian packages for any libraries the browser cannot load. |
| 9 | * |
| 10 | * Both need root. Without it, the command prints what it would change and the |
| 11 | * `sudo` line to run. It is safe to run again; it skips what is already done. |
| 12 | */ |
| 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 14 | import * as Console from "effect/Console"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Option from "effect/Option"; |
| 18 | import * as Path from "effect/Path"; |
| 19 | import * as Schema from "effect/Schema"; |
| 20 | import { Command } from "effect/cli"; |
| 21 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 22 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 23 | |
| 24 | import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; |
| 25 | import { resolveBaseDir } from "../os-jank.ts"; |
| 26 | import { baseDirFlag } from "./config.ts"; |
| 27 | import { resolveRootCliCommand } from "./invocation.ts"; |
| 28 | |
| 29 | export class BrowserSetupStepError extends Schema.TaggedError<BrowserSetupStepError>()( |
| 30 | "BrowserSetupStepError", |
| 31 | { step: Schema.String, detail: Schema.String }, |
| 32 | ) { |
| 33 | override get message(): string { |
| 34 | return `Could not ${this.step}: ${this.detail}`; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | /** Runs one setup command, streaming its output, and fails with its exit code. */ |
| 39 | const runStep = Effect.fn("browserSetup.runStep")(function* ( |
| 40 | step: string, |
| 41 | command: string, |
| 42 | args: ReadonlyArray<string>, |
| 43 | ) { |
| 44 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 45 | const exitCode = yield* spawner |
| 46 | .exitCode( |
| 47 | ChildProcess.make(command, args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }), |
| 48 | ) |
| 49 | .pipe(Effect.mapError((cause) => new BrowserSetupStepError({ step, detail: String(cause) }))); |
| 50 | if (exitCode !== 0) { |
| 51 | return yield* new BrowserSetupStepError({ step, detail: `${command} exited with ${exitCode}` }); |
| 52 | } |
| 53 | }); |
| 54 | |
| 55 | /** |
| 56 | * The T3 home to check. Under `sudo` the process home is root's, so an |
| 57 | * unspecified home falls back to the invoking user's `~/.t3`. |
| 58 | */ |
| 59 | const setupBaseDir = Effect.fn("browserSetup.baseDir")(function* (explicit: Option.Option<string>) { |
| 60 | const env = yield* HostProcess.Environment; |
| 61 | const raw = Option.getOrUndefined(explicit) ?? env.T3CODE_HOME; |
| 62 | if (raw !== undefined || env.SUDO_USER === undefined) return yield* resolveBaseDir(raw); |
| 63 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 64 | const entry = yield* spawner |
| 65 | .string( |
| 66 | ChildProcess.make("getent", ["passwd", env.SUDO_USER], { stdin: "ignore", stderr: "ignore" }), |
| 67 | ) |
| 68 | .pipe(Effect.orElseSucceed(() => "")); |
| 69 | const home = entry.trim().split(":")[5]; |
| 70 | const path = yield* Path.Path; |
| 71 | return home ? path.join(home, ".t3") : yield* resolveBaseDir(undefined); |
| 72 | }); |
| 73 | |
| 74 | /** Whether apt has an installable candidate for `name`. */ |
| 75 | const aptOffers = Effect.fn("browserSetup.aptOffers")(function* (name: string) { |
| 76 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 77 | const policy = yield* spawner |
| 78 | .string(ChildProcess.make("apt-cache", ["policy", name], { stdin: "ignore", stderr: "ignore" })) |
| 79 | .pipe(Effect.orElseSucceed(() => "")); |
| 80 | const candidate = /Candidate:\s*(\S+)/.exec(policy)?.[1]; |
| 81 | return candidate !== undefined && candidate !== "(none)"; |
| 82 | }); |
| 83 | |
| 84 | /** The installed browser in this T3 home, if any, to check its libraries. */ |
| 85 | const installedBrowser = Effect.fn("browserSetup.installedBrowser")(function* (baseDir: string) { |
| 86 | const fs = yield* FileSystem.FileSystem; |
| 87 | const path = yield* Path.Path; |
| 88 | const root = path.join(baseDir, "tools", "chrome-headless-shell"); |
| 89 | for (const platform of yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []))) { |
| 90 | for (const version of yield* fs |
| 91 | .readDirectory(path.join(root, platform)) |
| 92 | .pipe(Effect.orElseSucceed(() => []))) { |
| 93 | const executable = path.join(root, platform, version, "chrome-headless-shell"); |
| 94 | if (yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false))) { |
| 95 | return Option.some(executable); |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | return Option.none<string>(); |
| 100 | }); |
| 101 | |
| 102 | const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe( |
| 103 | Command.withDescription( |
| 104 | "Set up this Linux host for T3's browser: allow Chrome's sandbox and install its libraries.", |
| 105 | ), |
| 106 | Command.withHandler(({ baseDir }) => |
| 107 | Effect.gen(function* () { |
| 108 | if ((yield* HostProcess.Platform) !== "linux") { |
| 109 | return yield* Console.log("Nothing to set up: T3's browser runs as is on this system."); |
| 110 | } |
| 111 | const fs = yield* FileSystem.FileSystem; |
| 112 | const isRoot = (yield* HostProcess.UserId) === 0; |
| 113 | const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); |
| 114 | |
| 115 | const needsProfile = yield* PreviewBrowserHost.sandboxBlocked; |
| 116 | const browser = yield* installedBrowser(yield* setupBaseDir(baseDir)); |
| 117 | const missing = Option.isSome(browser) |
| 118 | ? yield* PreviewBrowserHost.missingLibraries(browser.value) |
| 119 | : []; |
| 120 | const hasApt = yield* fs.exists("/usr/bin/apt-get").pipe(Effect.orElseSucceed(() => false)); |
| 121 | |
| 122 | if (!needsProfile && missing.length === 0) { |
| 123 | return yield* Console.log( |
| 124 | Option.isSome(browser) |
| 125 | ? "This host is ready for T3's browser." |
| 126 | : "Chrome's sandbox is allowed here. T3's browser installs on first use; if it then reports missing libraries, run this again.", |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | if (!isRoot) { |
| 131 | if (needsProfile) { |
| 132 | yield* Console.log( |
| 133 | `This host blocks the sandbox T3's browser runs in. Setup installs an AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows it.`, |
| 134 | ); |
| 135 | } |
| 136 | if (missing.length > 0) { |
| 137 | yield* Console.log(`T3's browser is missing ${missing.join(", ")}; setup installs them.`); |
| 138 | } |
| 139 | return yield* Console.log(`\nThis needs root. Run:\n\n ${setupCommand}\n`); |
| 140 | } |
| 141 | |
| 142 | if (needsProfile) { |
| 143 | yield* fs |
| 144 | .writeFileString( |
| 145 | PreviewBrowserHost.APPARMOR_PROFILE_PATH, |
| 146 | PreviewBrowserHost.APPARMOR_PROFILE, |
| 147 | ) |
| 148 | .pipe( |
| 149 | Effect.mapError( |
| 150 | (cause) => |
| 151 | new BrowserSetupStepError({ |
| 152 | step: `write ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`, |
| 153 | detail: cause.message, |
| 154 | }), |
| 155 | ), |
| 156 | ); |
| 157 | yield* runStep("load the AppArmor profile", "apparmor_parser", [ |
| 158 | "-r", |
| 159 | PreviewBrowserHost.APPARMOR_PROFILE_PATH, |
| 160 | ]); |
| 161 | yield* Console.log("Allowed Chrome's sandbox for T3's browser."); |
| 162 | } |
| 163 | |
| 164 | if (missing.length > 0) { |
| 165 | if (!hasApt) { |
| 166 | return yield* Console.log( |
| 167 | `T3's browser is missing ${missing.join(", ")}. Install them with your package manager, then run this again.`, |
| 168 | ); |
| 169 | } |
| 170 | yield* runStep("refresh the package lists", "apt-get", ["update"]); |
| 171 | const packages = yield* Effect.forEach(PreviewBrowserHost.DEBIAN_PACKAGES, (names) => |
| 172 | Effect.findFirst(names, aptOffers).pipe(Effect.map(Option.getOrElse(() => names[0]!))), |
| 173 | ); |
| 174 | yield* runStep("install the browser's libraries", "apt-get", [ |
| 175 | "install", |
| 176 | "-y", |
| 177 | "--no-install-recommends", |
| 178 | ...packages, |
| 179 | ]); |
| 180 | yield* Console.log("Installed the browser's libraries."); |
| 181 | } |
| 182 | |
| 183 | yield* Console.log("This host is ready for T3's browser."); |
| 184 | }), |
| 185 | ), |
| 186 | ); |
| 187 | |
| 188 | export const browserCommand = Command.make("browser").pipe( |
| 189 | Command.withDescription("Manage T3's headless browser on this host."), |
| 190 | Command.withSubcommands([browserSetupCommand]), |
| 191 | ); |