1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Console from "effect/Console";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import { Command } from "effect/cli";
9import * as ChildProcess from "effect/process/ChildProcess";
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
11
12import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
13import { resolveBaseDir } from "../os-jank.ts";
14import { baseDirFlag } from "./config.ts";
15import { resolveRootCliCommand } from "./invocation.ts";
16
17export class BrowserSetupStepError extends Schema.TaggedError<BrowserSetupStepError>()(
18 "BrowserSetupStepError",
19 { step: Schema.String, detail: Schema.String },
20) {
21 override get message(): string {
22 return `Could not ${this.step}: ${this.detail}`;
23 }
24}
25
26const runStep = Effect.fn("browserSetup.runStep")(function* (
27 step: string,
28 command: string,
29 args: ReadonlyArray<string>,
30) {
31 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32 const exitCode = yield* spawner
33 .exitCode(
34 ChildProcess.make(command, args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }),
35 )
36 .pipe(Effect.mapError((cause) => new BrowserSetupStepError({ step, detail: String(cause) })));
37 if (exitCode !== 0) {
38 return yield* new BrowserSetupStepError({ step, detail: `${command} exited with ${exitCode}` });
39 }
40});
41
42/** Under sudo, an unspecified home uses the invoking user's ~/.t3. */
43const setupBaseDir = Effect.fn("browserSetup.baseDir")(function* (explicit: Option.Option<string>) {
44 const env = yield* HostProcess.Environment;
45 const raw = Option.getOrUndefined(explicit) ?? env.T3CODE_HOME;
46 if (raw !== undefined || env.SUDO_USER === undefined) return yield* resolveBaseDir(raw);
47 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
48 const entry = yield* spawner
49 .string(
50 ChildProcess.make("getent", ["passwd", env.SUDO_USER], { stdin: "ignore", stderr: "ignore" }),
51 )
52 .pipe(Effect.orElseSucceed(() => ""));
53 const home = entry.trim().split(":")[5];
54 const path = yield* Path.Path;
55 return home ? path.join(home, ".t3") : yield* resolveBaseDir(undefined);
56});
57
58const aptOffers = Effect.fn("browserSetup.aptOffers")(function* (name: string) {
59 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
60 const policy = yield* spawner
61 .string(ChildProcess.make("apt-cache", ["policy", name], { stdin: "ignore", stderr: "ignore" }))
62 .pipe(Effect.orElseSucceed(() => ""));
63 const candidate = /Candidate:\s*(\S+)/.exec(policy)?.[1];
64 return candidate !== undefined && candidate !== "(none)";
65});
66
67const installedBrowser = Effect.fn("browserSetup.installedBrowser")(function* (baseDir: string) {
68 const fs = yield* FileSystem.FileSystem;
69 const path = yield* Path.Path;
70 const root = path.join(baseDir, "tools", "chrome-headless-shell");
71 for (const platform of yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []))) {
72 for (const version of yield* fs
73 .readDirectory(path.join(root, platform))
74 .pipe(Effect.orElseSucceed(() => []))) {
75 const executable = path.join(root, platform, version, "chrome-headless-shell");
76 if (yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false))) {
77 return Option.some(executable);
78 }
79 }
80 }
81 return Option.none<string>();
82});
83
84/**
85 * Chrome starts without fonts, but pages then render no text. Without
86 * fontconfig there is no `fc-match` at all, which counts as no font.
87 */
88const fontAvailable = Effect.gen(function* () {
89 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
90 const font = yield* spawner
91 .string(
92 ChildProcess.make("fc-match", ["--format=%{file}", "sans-serif"], {
93 stdin: "ignore",
94 stderr: "ignore",
95 }),
96 )
97 .pipe(
98 Effect.timeout("5 seconds"),
99 Effect.orElseSucceed(() => ""),
100 );
101 return font.trim() !== "";
102}).pipe(Effect.withSpan("browserSetup.fontAvailable"));
103
104const FONT_PACKAGES = ["fontconfig", "fonts-liberation"];
105
106const 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 and fonts.",
109 ),
110 Command.withHandler(({ baseDir }) =>
111 Effect.gen(function* () {
112 if ((yield* HostProcess.Platform) !== "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* HostProcess.UserId) === 0;
117 const env = yield* HostProcess.Environment;
118 const baseDirPath = yield* setupBaseDir(baseDir);
119 // sudo resets the environment, so a chosen home has to be on the command line.
120 const setupCommand = yield* resolveRootCliCommand(
121 PreviewBrowserHost.SETUP_SUBCOMMAND,
122 Option.isSome(baseDir) || env.T3CODE_HOME !== undefined ? ["--base-dir", baseDirPath] : [],
123 );
124 // Root, unless sudo came from another user, is likely the server's own account.
125 const rootWarning =
126 isRoot && (env.SUDO_USER ?? "root") === "root" && !PreviewBrowserHost.sandboxDisabled(env)
127 ? new PreviewBrowserHost.PreviewBrowserRootError().message
128 : undefined;
129
130 const needsProfile = yield* PreviewBrowserHost.sandboxBlocked;
131 const browser = yield* installedBrowser(baseDirPath);
132 const missing = Option.isSome(browser)
133 ? yield* PreviewBrowserHost.missingLibraries(browser.value)
134 : [];
135 const needsFonts = !(yield* fontAvailable);
136 const hasApt = yield* fs.exists("/usr/bin/apt-get").pipe(Effect.orElseSucceed(() => false));
137
138 if (!needsProfile && missing.length === 0 && !needsFonts) {
139 return yield* Console.log(
140 rootWarning ??
141 (Option.isSome(browser)
142 ? "This host is ready for T3's browser."
143 : "Chrome's sandbox is allowed here. T3's browser installs on first use; if it then reports missing libraries, run this again."),
144 );
145 }
146
147 if (!isRoot) {
148 if (needsProfile) {
149 yield* Console.log(
150 `This host blocks the sandbox T3's browser runs in. Setup installs an AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows it.`,
151 );
152 }
153 if (missing.length > 0) {
154 yield* Console.log(`T3's browser is missing ${missing.join(", ")}; setup installs them.`);
155 }
156 if (needsFonts) {
157 yield* Console.log(
158 "T3's browser needs fontconfig and system fonts; setup installs them.",
159 );
160 }
161 return yield* Console.log(`\nThis needs root. Run:\n\n ${setupCommand}\n`);
162 }
163
164 if (needsProfile) {
165 yield* fs
166 .writeFileString(
167 PreviewBrowserHost.APPARMOR_PROFILE_PATH,
168 PreviewBrowserHost.APPARMOR_PROFILE,
169 )
170 .pipe(
171 Effect.mapError(
172 (cause) =>
173 new BrowserSetupStepError({
174 step: `write ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`,
175 detail: cause.message,
176 }),
177 ),
178 );
179 yield* runStep("load the AppArmor profile", "apparmor_parser", [
180 "-r",
181 PreviewBrowserHost.APPARMOR_PROFILE_PATH,
182 ]);
183 yield* Console.log("Allowed Chrome's sandbox for T3's browser.");
184 }
185
186 if ((missing.length > 0 || needsFonts) && !hasApt) {
187 if (missing.length > 0) {
188 yield* Console.log(
189 `T3's browser is missing ${missing.join(", ")}. Install them with your package manager, then run this again.`,
190 );
191 }
192 if (needsFonts) {
193 yield* Console.log(
194 "T3's browser needs fontconfig and system fonts. Install them with your package manager, then run this again.",
195 );
196 }
197 return;
198 }
199 if (missing.length > 0 || needsFonts) {
200 yield* runStep("refresh the package lists", "apt-get", ["update"]);
201 }
202
203 if (missing.length > 0) {
204 const packages = yield* Effect.forEach(PreviewBrowserHost.DEBIAN_PACKAGES, (names) =>
205 Effect.findFirst(names, aptOffers).pipe(Effect.map(Option.getOrElse(() => names[0]!))),
206 );
207 yield* runStep("install the browser's libraries", "apt-get", [
208 "install",
209 "-y",
210 "--no-install-recommends",
211 ...packages,
212 ]);
213 yield* Console.log("Installed the browser's libraries.");
214 }
215
216 if (needsFonts) {
217 yield* runStep("install the browser's fonts", "apt-get", [
218 "install",
219 "-y",
220 "--no-install-recommends",
221 ...FONT_PACKAGES,
222 ]);
223 if (!(yield* fontAvailable)) {
224 return yield* Console.log(
225 "Font packages are installed, but Fontconfig still finds no system font. Check its configuration and run setup again.",
226 );
227 }
228 yield* Console.log(
229 "Installed the browser's fonts. Restart the T3 server if browser tabs were already open.",
230 );
231 }
232
233 yield* Console.log(rootWarning ?? "This host is ready for T3's browser.");
234 }),
235 ),
236);
237
238export const browserCommand = Command.make("browser").pipe(
239 Command.withDescription("Manage T3's headless browser on this host."),
240 Command.withSubcommands([browserSetupCommand]),
241);