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 */
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import * as Console from "effect/Console";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20import { Command } from "effect/cli";
21import * as ChildProcess from "effect/process/ChildProcess";
22import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
23
24import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
25import { resolveBaseDir } from "../os-jank.ts";
26import { baseDirFlag } from "./config.ts";
27import { resolveRootCliCommand } from "./invocation.ts";
28
29export 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. */
39const 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 */
59const 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`. */
75const 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. */
85const 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
102const 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
188export const browserCommand = Command.make("browser").pipe(
189 Command.withDescription("Manage T3's headless browser on this host."),
190 Command.withSubcommands([browserSetupCommand]),
191);