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