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