single-file-cli 2.7.2 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +8 -2
- package/build.sh +6 -3
- package/deno.json +1 -1
- package/lib/bidi-client.js +1221 -0
- package/lib/bidi.js +141 -0
- package/lib/browser.js +14 -209
- package/lib/cdp-client.js +11 -11
- package/lib/chromium.js +225 -0
- package/lib/constants.js +27 -4
- package/lib/deno-polyfill.js +5 -0
- package/lib/firefox.js +182 -0
- package/lib/single-file-archive.js +5 -5
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/options.js +20 -2
- package/package.json +2 -1
- package/single-file-cli-api.js +11 -5
- package/single-file-launcher.js +3 -3
- package/test/e2e/automation-detection.test.js +4 -3
- package/test/e2e/fidelity.test.js +2 -2
- package/test/e2e/html-at-media-url.test.js +96 -0
- package/test/e2e/password.test.js +47 -0
- package/test/e2e/proxy-auth.test.js +53 -0
- package/test/e2e/service-worker.test.js +2 -2
- package/test/fidelity/browser.js +3 -3
- package/test/target.js +5 -1
package/lib/bidi.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2010-2026 Gildas Lormeau
|
|
3
|
+
* contact : gildas.lormeau <at> gmail.com
|
|
4
|
+
*
|
|
5
|
+
* This file is part of SingleFile.
|
|
6
|
+
*
|
|
7
|
+
* The code in this file is free software: you can redistribute it and/or
|
|
8
|
+
* modify it under the terms of the GNU Affero General Public License
|
|
9
|
+
* (GNU AGPL) as published by the Free Software Foundation, either version 3
|
|
10
|
+
* of the License, or (at your option) any later version.
|
|
11
|
+
*
|
|
12
|
+
* The code in this file is distributed in the hope that it will be useful,
|
|
13
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
|
15
|
+
* General Public License for more details.
|
|
16
|
+
*
|
|
17
|
+
* As additional permission under GNU AGPL version 3 section 7, you may
|
|
18
|
+
* distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
|
|
19
|
+
* AGPL normally required by section 4, provided you include this license
|
|
20
|
+
* notice and a URL through which recipients can access the Corresponding
|
|
21
|
+
* Source.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/* global WebSocket, setTimeout, clearTimeout */
|
|
25
|
+
|
|
26
|
+
const SUCCESS_TYPE = "success";
|
|
27
|
+
const EVENT_TYPE = "event";
|
|
28
|
+
const OPEN_ATTEMPT_TIMEOUT = 500;
|
|
29
|
+
const OPEN_RETRY_DELAY = 200;
|
|
30
|
+
const DEFAULT_COMMAND_TIMEOUT = 30000;
|
|
31
|
+
|
|
32
|
+
export { connect };
|
|
33
|
+
|
|
34
|
+
async function connect(url, { timeout, commandTimeout = DEFAULT_COMMAND_TIMEOUT, isClosed = () => false } = {}) {
|
|
35
|
+
const socket = await openSocket(url, timeout, isClosed);
|
|
36
|
+
const pendingCommands = new Map();
|
|
37
|
+
const listeners = new Map();
|
|
38
|
+
let nextId = 1, closeError;
|
|
39
|
+
socket.addEventListener("close", () => {
|
|
40
|
+
closeError = new Error("The BiDi connection was closed");
|
|
41
|
+
pendingCommands.forEach(({ reject }) => reject(closeError));
|
|
42
|
+
pendingCommands.clear();
|
|
43
|
+
});
|
|
44
|
+
socket.addEventListener("message", event => {
|
|
45
|
+
const message = JSON.parse(event.data);
|
|
46
|
+
if (message.id !== undefined && pendingCommands.has(message.id)) {
|
|
47
|
+
const { resolve, reject } = pendingCommands.get(message.id);
|
|
48
|
+
pendingCommands.delete(message.id);
|
|
49
|
+
if (message.type == SUCCESS_TYPE) {
|
|
50
|
+
resolve(message.result);
|
|
51
|
+
} else {
|
|
52
|
+
const error = new Error(message.message);
|
|
53
|
+
error.code = message.error;
|
|
54
|
+
reject(error);
|
|
55
|
+
}
|
|
56
|
+
} else if (message.type == EVENT_TYPE) {
|
|
57
|
+
const eventListeners = listeners.get(message.method);
|
|
58
|
+
if (eventListeners) {
|
|
59
|
+
Array.from(eventListeners).forEach(listener => listener(message.params));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return { send, addEventListener, removeEventListener, close };
|
|
64
|
+
|
|
65
|
+
function send(method, params = {}, { timeout = commandTimeout } = {}) {
|
|
66
|
+
if (closeError) {
|
|
67
|
+
return Promise.reject(closeError);
|
|
68
|
+
}
|
|
69
|
+
const id = nextId++;
|
|
70
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let timeoutId;
|
|
73
|
+
if (timeout) {
|
|
74
|
+
timeoutId = setTimeout(() => {
|
|
75
|
+
pendingCommands.delete(id);
|
|
76
|
+
reject(new Error(method + " timed out after " + timeout + " ms"));
|
|
77
|
+
}, timeout);
|
|
78
|
+
}
|
|
79
|
+
pendingCommands.set(id, {
|
|
80
|
+
resolve: value => {
|
|
81
|
+
clearTimeout(timeoutId);
|
|
82
|
+
resolve(value);
|
|
83
|
+
},
|
|
84
|
+
reject: error => {
|
|
85
|
+
clearTimeout(timeoutId);
|
|
86
|
+
reject(error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function addEventListener(eventName, listener) {
|
|
93
|
+
if (!listeners.has(eventName)) {
|
|
94
|
+
listeners.set(eventName, new Set());
|
|
95
|
+
}
|
|
96
|
+
listeners.get(eventName).add(listener);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function removeEventListener(eventName, listener) {
|
|
100
|
+
const eventListeners = listeners.get(eventName);
|
|
101
|
+
if (eventListeners) {
|
|
102
|
+
eventListeners.delete(listener);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function close() {
|
|
107
|
+
try {
|
|
108
|
+
socket.close();
|
|
109
|
+
} catch {
|
|
110
|
+
// ignored
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function openSocket(url, timeout, isClosed) {
|
|
116
|
+
const timeoutTime = Date.now() + timeout;
|
|
117
|
+
while (Date.now() < timeoutTime && !isClosed()) {
|
|
118
|
+
const socket = new WebSocket(url);
|
|
119
|
+
const opened = await new Promise(resolve => {
|
|
120
|
+
const timeoutId = setTimeout(() => resolve(false), OPEN_ATTEMPT_TIMEOUT);
|
|
121
|
+
socket.addEventListener("open", () => {
|
|
122
|
+
clearTimeout(timeoutId);
|
|
123
|
+
resolve(true);
|
|
124
|
+
});
|
|
125
|
+
socket.addEventListener("error", () => {
|
|
126
|
+
clearTimeout(timeoutId);
|
|
127
|
+
resolve(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
if (opened) {
|
|
131
|
+
return socket;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
socket.close();
|
|
135
|
+
} catch {
|
|
136
|
+
// ignored
|
|
137
|
+
}
|
|
138
|
+
await new Promise(resolve => setTimeout(resolve, OPEN_RETRY_DELAY));
|
|
139
|
+
}
|
|
140
|
+
throw new Error(isClosed() ? "The browser exited unexpectedly" : "The browser is not responding");
|
|
141
|
+
}
|
package/lib/browser.js
CHANGED
|
@@ -1,38 +1,33 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Copyright 2010-2024 Gildas Lormeau
|
|
3
3
|
* contact : gildas.lormeau <at> gmail.com
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
5
|
* This file is part of SingleFile.
|
|
6
6
|
*
|
|
7
|
-
* The code in this file is free software: you can redistribute it and/or
|
|
8
|
-
* modify it under the terms of the GNU Affero General Public License
|
|
7
|
+
* The code in this file is free software: you can redistribute it and/or
|
|
8
|
+
* modify it under the terms of the GNU Affero General Public License
|
|
9
9
|
* (GNU AGPL) as published by the Free Software Foundation, either version 3
|
|
10
10
|
* of the License, or (at your option) any later version.
|
|
11
|
-
*
|
|
12
|
-
* The code in this file is distributed in the hope that it will be useful,
|
|
13
|
-
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
-
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
|
11
|
+
*
|
|
12
|
+
* The code in this file is distributed in the hope that it will be useful,
|
|
13
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
|
15
15
|
* General Public License for more details.
|
|
16
16
|
*
|
|
17
|
-
* As additional permission under GNU AGPL version 3 section 7, you may
|
|
18
|
-
* distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
|
|
19
|
-
* AGPL normally required by section 4, provided you include this license
|
|
20
|
-
* notice and a URL through which recipients can access the Corresponding
|
|
17
|
+
* As additional permission under GNU AGPL version 3 section 7, you may
|
|
18
|
+
* distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
|
|
19
|
+
* AGPL normally required by section 4, provided you include this license
|
|
20
|
+
* notice and a URL through which recipients can access the Corresponding
|
|
21
21
|
* Source.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
/* global fetch
|
|
24
|
+
/* global fetch */
|
|
25
25
|
|
|
26
|
-
import { BROWSER_PATHS, BROWSER_ARGS } from "./constants.js";
|
|
27
26
|
import { Deno, path } from "./deno-polyfill.js";
|
|
28
27
|
|
|
29
28
|
const NULL_STD_CONFIG = "null";
|
|
30
29
|
const DEBUG_PORT_MIN = 9222;
|
|
31
30
|
const DEBUG_PORT_RANGE = 256;
|
|
32
|
-
const READY_TIMEOUT = 30000;
|
|
33
|
-
const READY_RETRY_DELAY = 250;
|
|
34
|
-
const CLOSE_TIMEOUT = 15000;
|
|
35
|
-
const LOCALHOST = "http://localhost:";
|
|
36
31
|
const PROFILE_LOCK_ENTRY_PREFIX = "Singleton";
|
|
37
32
|
const PROFILE_IGNORED_ENTRY_NAMES = [
|
|
38
33
|
"AutofillAiModelCache",
|
|
@@ -53,13 +48,8 @@ const PROFILE_IGNORED_ENTRY_NAMES = [
|
|
|
53
48
|
"optimization_guide_hint_cache_store",
|
|
54
49
|
"optimization_guide_model_store"
|
|
55
50
|
];
|
|
56
|
-
const PROFILE_INCOMPATIBLE_ARGS = [
|
|
57
|
-
"--no-startup-window",
|
|
58
|
-
"--bwsi",
|
|
59
|
-
"--deny-permission-prompts"
|
|
60
|
-
];
|
|
61
51
|
|
|
62
|
-
const { build,
|
|
52
|
+
const { build, readDir, copyFile, mkdir, Command, errors, remove, stat } = Deno;
|
|
63
53
|
const WATCHDOG_SHELL = "/bin/sh";
|
|
64
54
|
const WATCHDOG_SCRIPT = [
|
|
65
55
|
"trap 'kill -TERM $browser 2>/dev/null' TERM INT",
|
|
@@ -77,116 +67,7 @@ const WATCHDOG_SCRIPT = [
|
|
|
77
67
|
"wait $browser 2>/dev/null"
|
|
78
68
|
].join("\n");
|
|
79
69
|
const { join } = path;
|
|
80
|
-
|
|
81
|
-
export { launchBrowser, createBrowserProfile, getBrowserOptions, copyProfile, pruneProfile, closeBrowser, browserExited };
|
|
82
|
-
|
|
83
|
-
function getBrowserOptions(options) {
|
|
84
|
-
return {
|
|
85
|
-
args: options.browserArgs,
|
|
86
|
-
headless: options.browserHeadless,
|
|
87
|
-
executablePath: options.browserExecutablePath,
|
|
88
|
-
debug: options.browserDebug,
|
|
89
|
-
singleProcess: options.browserSingleProcess,
|
|
90
|
-
width: options.browserWidth,
|
|
91
|
-
height: options.browserHeight,
|
|
92
|
-
userAgent: options.userAgent,
|
|
93
|
-
httpProxyServer: options.httpProxyServer,
|
|
94
|
-
profile: options.browserProfile
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function createBrowserProfile(options = {}) {
|
|
99
|
-
await mkdir(options.profile, { recursive: true });
|
|
100
|
-
await launchBrowser(Object.assign({}, options, { headless: false, persistProfile: true }));
|
|
101
|
-
if (child !== undefined) {
|
|
102
|
-
await child.status;
|
|
103
|
-
}
|
|
104
|
-
await closeBrowser();
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function launchBrowser(options = {}, indexPath = 0) {
|
|
108
|
-
const executablePath = options.executablePath || BROWSER_PATHS[build.os][indexPath];
|
|
109
|
-
let args = Array.from(BROWSER_ARGS);
|
|
110
|
-
const debugPort = await getDebugPort();
|
|
111
|
-
browserDebugPort = debugPort;
|
|
112
|
-
args.push("--remote-debugging-port=" + debugPort);
|
|
113
|
-
keepProfilePath = Boolean(options.persistProfile);
|
|
114
|
-
if (keepProfilePath) {
|
|
115
|
-
profilePath = options.profile;
|
|
116
|
-
args = args.filter(arg => !PROFILE_INCOMPATIBLE_ARGS.includes(arg));
|
|
117
|
-
} else {
|
|
118
|
-
profilePath = await makeTempDir();
|
|
119
|
-
if (options.profile) {
|
|
120
|
-
await copyProfile(options.profile, profilePath);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (options.headless && !options.debug) {
|
|
124
|
-
args.push("--headless");
|
|
125
|
-
} else {
|
|
126
|
-
args.push("--start-maximized");
|
|
127
|
-
}
|
|
128
|
-
if (options.debug) {
|
|
129
|
-
args.push("--auto-open-devtools-for-tabs");
|
|
130
|
-
}
|
|
131
|
-
if (options.width && options.height) {
|
|
132
|
-
args.push("--window-size=" + options.width + "," + options.height);
|
|
133
|
-
}
|
|
134
|
-
if (options.userAgent) {
|
|
135
|
-
args.push("--user-agent=" + options.userAgent);
|
|
136
|
-
}
|
|
137
|
-
if (options.httpProxyServer) {
|
|
138
|
-
args.push("--proxy-server=" + options.httpProxyServer);
|
|
139
|
-
}
|
|
140
|
-
args.push("--user-data-dir=" + profilePath);
|
|
141
|
-
if (options.singleProcess) {
|
|
142
|
-
args.push("--single-process");
|
|
143
|
-
}
|
|
144
|
-
if (options.args) {
|
|
145
|
-
const argNames = options.args.map(arg => arg.split("=")[0]);
|
|
146
|
-
args = args.filter(arg => !argNames.includes(arg.split("=")[0]));
|
|
147
|
-
args.push(...options.args);
|
|
148
|
-
}
|
|
149
|
-
if (args.includes("--headless=new") ||
|
|
150
|
-
args.includes("--auto-open-devtools-for-tabs") ||
|
|
151
|
-
args.includes("--start-maximized") ||
|
|
152
|
-
!args.includes("--headless")) {
|
|
153
|
-
args.push("--disable-site-isolation-trials");
|
|
154
|
-
}
|
|
155
|
-
if (options.startUrl) {
|
|
156
|
-
args.push(options.startUrl);
|
|
157
|
-
}
|
|
158
|
-
try {
|
|
159
|
-
child = await spawnBrowser(executablePath, args, keepProfilePath ? "" : profilePath);
|
|
160
|
-
} catch (error) {
|
|
161
|
-
if (!keepProfilePath) {
|
|
162
|
-
await remove(profilePath, { recursive: true }).catch(() => { });
|
|
163
|
-
}
|
|
164
|
-
profilePath = undefined;
|
|
165
|
-
if (error instanceof errors.NotFound) {
|
|
166
|
-
if (!options.executablePath && indexPath + 1 < BROWSER_PATHS[build.os].length) {
|
|
167
|
-
return launchBrowser(options, indexPath + 1);
|
|
168
|
-
}
|
|
169
|
-
throw new Error(options.executablePath ?
|
|
170
|
-
`The browser executable was not found at ${JSON.stringify(executablePath)}` :
|
|
171
|
-
"The browser executable was not found, use --browser-executable-path to set its location");
|
|
172
|
-
}
|
|
173
|
-
throw error;
|
|
174
|
-
}
|
|
175
|
-
child.ref();
|
|
176
|
-
childExited = false;
|
|
177
|
-
child.status.then(() => childExited = true);
|
|
178
|
-
try {
|
|
179
|
-
await waitUntilReady(debugPort);
|
|
180
|
-
} catch (error) {
|
|
181
|
-
await closeBrowser();
|
|
182
|
-
if (options.singleProcess) {
|
|
183
|
-
console.warn("Warning: the browser exited when using --browser-single-process, retrying without it"); // eslint-disable-line no-console
|
|
184
|
-
return launchBrowser(Object.assign({}, options, { singleProcess: false }), indexPath);
|
|
185
|
-
}
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
return debugPort;
|
|
189
|
-
}
|
|
70
|
+
export { spawnBrowser, getDebugPort, copyProfile, pruneProfile, removeProfileLockEntries };
|
|
190
71
|
|
|
191
72
|
async function spawnBrowser(executablePath, args, temporaryProfilePath) {
|
|
192
73
|
if (build.os == "windows") {
|
|
@@ -200,32 +81,6 @@ async function spawnBrowser(executablePath, args, temporaryProfilePath) {
|
|
|
200
81
|
return new Command(WATCHDOG_SHELL, { args: ["-c", WATCHDOG_SCRIPT, executablePath, temporaryProfilePath, ...args], stdout: NULL_STD_CONFIG, stderr: NULL_STD_CONFIG }).spawn();
|
|
201
82
|
}
|
|
202
83
|
|
|
203
|
-
async function waitUntilReady(debugPort) {
|
|
204
|
-
const timeoutTime = Date.now() + READY_TIMEOUT;
|
|
205
|
-
while (!childExited && Date.now() < timeoutTime) {
|
|
206
|
-
try {
|
|
207
|
-
await fetch("http://localhost:" + debugPort + "/json/version");
|
|
208
|
-
return;
|
|
209
|
-
} catch {
|
|
210
|
-
await new Promise(resolve => setTimeout(resolve, READY_RETRY_DELAY));
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
throw new Error(childExited ? "The browser exited unexpectedly" : "The browser is not responding");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function browserExited(maxDelay = 0) {
|
|
217
|
-
if (child === undefined) {
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
if (childExited || !maxDelay) {
|
|
221
|
-
return childExited;
|
|
222
|
-
}
|
|
223
|
-
return Promise.race([
|
|
224
|
-
child.status.then(() => true),
|
|
225
|
-
new Promise(resolve => setTimeout(() => resolve(childExited), maxDelay))
|
|
226
|
-
]);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
84
|
async function getDebugPort(port = getRandomDebugPort(), usedPorts = []) {
|
|
230
85
|
try {
|
|
231
86
|
await fetch("http://localhost:" + port + "/json/version");
|
|
@@ -243,60 +98,10 @@ async function getDebugPort(port = getRandomDebugPort(), usedPorts = []) {
|
|
|
243
98
|
}
|
|
244
99
|
}
|
|
245
100
|
|
|
246
|
-
|
|
247
101
|
function getRandomDebugPort() {
|
|
248
102
|
return Math.floor(Math.random() * DEBUG_PORT_RANGE) + DEBUG_PORT_MIN;
|
|
249
103
|
}
|
|
250
104
|
|
|
251
|
-
async function closeBrowser() {
|
|
252
|
-
const closedChild = child;
|
|
253
|
-
const closedProfilePath = profilePath;
|
|
254
|
-
child = undefined;
|
|
255
|
-
profilePath = undefined;
|
|
256
|
-
if (closedChild !== undefined) {
|
|
257
|
-
if (keepProfilePath && !childExited) {
|
|
258
|
-
await closeBrowserGracefully(closedChild);
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
closedChild.kill();
|
|
262
|
-
} catch {
|
|
263
|
-
// ignored
|
|
264
|
-
}
|
|
265
|
-
await closedChild.status;
|
|
266
|
-
}
|
|
267
|
-
if (closedProfilePath !== undefined) {
|
|
268
|
-
if (keepProfilePath) {
|
|
269
|
-
await removeProfileLockEntries(closedProfilePath).catch(() => { });
|
|
270
|
-
await pruneProfile(closedProfilePath).catch(() => { });
|
|
271
|
-
} else {
|
|
272
|
-
try {
|
|
273
|
-
await remove(closedProfilePath, { recursive: true });
|
|
274
|
-
} catch {
|
|
275
|
-
console.log("Warning: failed to remove profile directory: " + closedProfilePath); // eslint-disable-line no-console
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
async function closeBrowserGracefully(closedChild) {
|
|
282
|
-
try {
|
|
283
|
-
const response = await fetch(LOCALHOST + browserDebugPort + "/json/version");
|
|
284
|
-
const { webSocketDebuggerUrl } = await response.json();
|
|
285
|
-
const socket = new WebSocket(webSocketDebuggerUrl);
|
|
286
|
-
await new Promise((resolve, reject) => {
|
|
287
|
-
socket.addEventListener("open", resolve);
|
|
288
|
-
socket.addEventListener("error", reject);
|
|
289
|
-
});
|
|
290
|
-
socket.send(JSON.stringify({ id: 0, method: "Browser.close" }));
|
|
291
|
-
await Promise.race([
|
|
292
|
-
closedChild.status,
|
|
293
|
-
new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT))
|
|
294
|
-
]);
|
|
295
|
-
} catch {
|
|
296
|
-
// ignored
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
105
|
async function copyProfile(sourcePath, destinationPath) {
|
|
301
106
|
let entries;
|
|
302
107
|
try {
|
package/lib/cdp-client.js
CHANGED
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
/* global setTimeout, clearTimeout, URL, AbortController */
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} from "./
|
|
27
|
+
launchChromium,
|
|
28
|
+
getChromiumOptions,
|
|
29
|
+
closeChromium,
|
|
30
|
+
chromiumExited
|
|
31
|
+
} from "./chromium.js";
|
|
32
32
|
import {
|
|
33
33
|
CDP,
|
|
34
34
|
options as cdpOptions
|
|
@@ -73,15 +73,15 @@ let browserOptions, relaunchBrowserPromise;
|
|
|
73
73
|
export {
|
|
74
74
|
initialize,
|
|
75
75
|
getPageData,
|
|
76
|
-
closeBrowser
|
|
76
|
+
closeChromium as closeBrowser
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
async function initialize(singleFileOptions) {
|
|
80
80
|
if (singleFileOptions.browserServer) {
|
|
81
81
|
cdpOptions.apiUrl = singleFileOptions.browserServer;
|
|
82
82
|
} else {
|
|
83
|
-
browserOptions =
|
|
84
|
-
cdpOptions.apiUrl = LOCALHOST + (await
|
|
83
|
+
browserOptions = getChromiumOptions(singleFileOptions);
|
|
84
|
+
cdpOptions.apiUrl = LOCALHOST + (await launchChromium(browserOptions));
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -89,9 +89,9 @@ function relaunchBrowser() {
|
|
|
89
89
|
if (!relaunchBrowserPromise) {
|
|
90
90
|
console.warn("Warning: the browser exited when using --browser-single-process, retrying without it"); // eslint-disable-line no-console
|
|
91
91
|
relaunchBrowserPromise = (async () => {
|
|
92
|
-
await
|
|
92
|
+
await closeChromium();
|
|
93
93
|
browserOptions.singleProcess = false;
|
|
94
|
-
cdpOptions.apiUrl = LOCALHOST + (await
|
|
94
|
+
cdpOptions.apiUrl = LOCALHOST + (await launchChromium(browserOptions));
|
|
95
95
|
})();
|
|
96
96
|
}
|
|
97
97
|
return relaunchBrowserPromise;
|
|
@@ -135,7 +135,7 @@ async function getPageData(options) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
async function shouldRelaunchBrowser() {
|
|
138
|
-
return Boolean(browserOptions && browserOptions.singleProcess) && await
|
|
138
|
+
return Boolean(browserOptions && browserOptions.singleProcess) && await chromiumExited(BROWSER_EXITED_MAX_DELAY);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
async function relaunchBrowserAndRetry() {
|
package/lib/chromium.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2010-2024 Gildas Lormeau
|
|
3
|
+
* contact : gildas.lormeau <at> gmail.com
|
|
4
|
+
*
|
|
5
|
+
* This file is part of SingleFile.
|
|
6
|
+
*
|
|
7
|
+
* The code in this file is free software: you can redistribute it and/or
|
|
8
|
+
* modify it under the terms of the GNU Affero General Public License
|
|
9
|
+
* (GNU AGPL) as published by the Free Software Foundation, either version 3
|
|
10
|
+
* of the License, or (at your option) any later version.
|
|
11
|
+
*
|
|
12
|
+
* The code in this file is distributed in the hope that it will be useful,
|
|
13
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
|
15
|
+
* General Public License for more details.
|
|
16
|
+
*
|
|
17
|
+
* As additional permission under GNU AGPL version 3 section 7, you may
|
|
18
|
+
* distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
|
|
19
|
+
* AGPL normally required by section 4, provided you include this license
|
|
20
|
+
* notice and a URL through which recipients can access the Corresponding
|
|
21
|
+
* Source.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/* global fetch, setTimeout, WebSocket */
|
|
25
|
+
|
|
26
|
+
import { CHROMIUM_PATHS, CHROMIUM_ARGS } from "./constants.js";
|
|
27
|
+
import { spawnBrowser, getDebugPort, copyProfile, pruneProfile, removeProfileLockEntries } from "./browser.js";
|
|
28
|
+
import { Deno } from "./deno-polyfill.js";
|
|
29
|
+
|
|
30
|
+
const READY_TIMEOUT = 30000;
|
|
31
|
+
const READY_RETRY_DELAY = 250;
|
|
32
|
+
const CLOSE_TIMEOUT = 15000;
|
|
33
|
+
const LOCALHOST = "http://localhost:";
|
|
34
|
+
const PROFILE_INCOMPATIBLE_ARGS = [
|
|
35
|
+
"--no-startup-window",
|
|
36
|
+
"--bwsi",
|
|
37
|
+
"--deny-permission-prompts"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const { build, makeTempDir, mkdir, errors, remove } = Deno;
|
|
41
|
+
let child, profilePath, childExited, keepProfilePath, browserDebugPort;
|
|
42
|
+
export { launchChromium, createBrowserProfile, getChromiumOptions, closeChromium, chromiumExited };
|
|
43
|
+
|
|
44
|
+
function getChromiumOptions(options) {
|
|
45
|
+
return {
|
|
46
|
+
args: options.browserArgs,
|
|
47
|
+
headless: options.browserHeadless,
|
|
48
|
+
executablePath: options.browserExecutablePath,
|
|
49
|
+
debug: options.browserDebug,
|
|
50
|
+
singleProcess: options.browserSingleProcess,
|
|
51
|
+
width: options.browserWidth,
|
|
52
|
+
height: options.browserHeight,
|
|
53
|
+
userAgent: options.userAgent,
|
|
54
|
+
httpProxyServer: options.httpProxyServer,
|
|
55
|
+
profile: options.browserProfile
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function createBrowserProfile(options = {}) {
|
|
60
|
+
await mkdir(options.profile, { recursive: true });
|
|
61
|
+
await launchChromium(Object.assign({}, options, { headless: false, persistProfile: true }));
|
|
62
|
+
if (child !== undefined) {
|
|
63
|
+
await child.status;
|
|
64
|
+
}
|
|
65
|
+
await closeChromium();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function launchChromium(options = {}, indexPath = 0) {
|
|
69
|
+
const executablePath = options.executablePath || CHROMIUM_PATHS[build.os][indexPath];
|
|
70
|
+
let args = Array.from(CHROMIUM_ARGS);
|
|
71
|
+
const debugPort = await getDebugPort();
|
|
72
|
+
browserDebugPort = debugPort;
|
|
73
|
+
args.push("--remote-debugging-port=" + debugPort);
|
|
74
|
+
keepProfilePath = Boolean(options.persistProfile);
|
|
75
|
+
if (keepProfilePath) {
|
|
76
|
+
profilePath = options.profile;
|
|
77
|
+
args = args.filter(arg => !PROFILE_INCOMPATIBLE_ARGS.includes(arg));
|
|
78
|
+
} else {
|
|
79
|
+
profilePath = await makeTempDir();
|
|
80
|
+
if (options.profile) {
|
|
81
|
+
await copyProfile(options.profile, profilePath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (options.headless && !options.debug) {
|
|
85
|
+
args.push("--headless");
|
|
86
|
+
} else {
|
|
87
|
+
args.push("--start-maximized");
|
|
88
|
+
}
|
|
89
|
+
if (options.debug) {
|
|
90
|
+
args.push("--auto-open-devtools-for-tabs");
|
|
91
|
+
}
|
|
92
|
+
if (options.width && options.height) {
|
|
93
|
+
args.push("--window-size=" + options.width + "," + options.height);
|
|
94
|
+
}
|
|
95
|
+
if (options.userAgent) {
|
|
96
|
+
args.push("--user-agent=" + options.userAgent);
|
|
97
|
+
}
|
|
98
|
+
if (options.httpProxyServer) {
|
|
99
|
+
args.push("--proxy-server=" + options.httpProxyServer);
|
|
100
|
+
}
|
|
101
|
+
args.push("--user-data-dir=" + profilePath);
|
|
102
|
+
if (options.singleProcess) {
|
|
103
|
+
args.push("--single-process");
|
|
104
|
+
}
|
|
105
|
+
if (options.args) {
|
|
106
|
+
const argNames = options.args.map(arg => arg.split("=")[0]);
|
|
107
|
+
args = args.filter(arg => !argNames.includes(arg.split("=")[0]));
|
|
108
|
+
args.push(...options.args);
|
|
109
|
+
}
|
|
110
|
+
if (args.includes("--headless=new") ||
|
|
111
|
+
args.includes("--auto-open-devtools-for-tabs") ||
|
|
112
|
+
args.includes("--start-maximized") ||
|
|
113
|
+
!args.includes("--headless")) {
|
|
114
|
+
args.push("--disable-site-isolation-trials");
|
|
115
|
+
}
|
|
116
|
+
if (options.startUrl) {
|
|
117
|
+
args.push(options.startUrl);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
child = await spawnBrowser(executablePath, args, keepProfilePath ? "" : profilePath);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!keepProfilePath) {
|
|
123
|
+
await remove(profilePath, { recursive: true }).catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
profilePath = undefined;
|
|
126
|
+
if (error instanceof errors.NotFound) {
|
|
127
|
+
if (!options.executablePath && indexPath + 1 < CHROMIUM_PATHS[build.os].length) {
|
|
128
|
+
return launchChromium(options, indexPath + 1);
|
|
129
|
+
}
|
|
130
|
+
throw new Error(options.executablePath ?
|
|
131
|
+
`The browser executable was not found at ${JSON.stringify(executablePath)}` :
|
|
132
|
+
"The browser executable was not found, use --browser-executable-path to set its location");
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
child.ref();
|
|
137
|
+
childExited = false;
|
|
138
|
+
child.status.then(() => childExited = true);
|
|
139
|
+
try {
|
|
140
|
+
await waitUntilReady(debugPort);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
await closeChromium();
|
|
143
|
+
if (options.singleProcess) {
|
|
144
|
+
console.warn("Warning: the browser exited when using --browser-single-process, retrying without it"); // eslint-disable-line no-console
|
|
145
|
+
return launchChromium(Object.assign({}, options, { singleProcess: false }), indexPath);
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return debugPort;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function waitUntilReady(debugPort) {
|
|
153
|
+
const timeoutTime = Date.now() + READY_TIMEOUT;
|
|
154
|
+
while (!childExited && Date.now() < timeoutTime) {
|
|
155
|
+
try {
|
|
156
|
+
await fetch("http://localhost:" + debugPort + "/json/version");
|
|
157
|
+
return;
|
|
158
|
+
} catch {
|
|
159
|
+
await new Promise(resolve => setTimeout(resolve, READY_RETRY_DELAY));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw new Error(childExited ? "The browser exited unexpectedly" : "The browser is not responding");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function chromiumExited(maxDelay = 0) {
|
|
166
|
+
if (child === undefined) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (childExited || !maxDelay) {
|
|
170
|
+
return childExited;
|
|
171
|
+
}
|
|
172
|
+
return Promise.race([
|
|
173
|
+
child.status.then(() => true),
|
|
174
|
+
new Promise(resolve => setTimeout(() => resolve(childExited), maxDelay))
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function closeChromium() {
|
|
179
|
+
const closedChild = child;
|
|
180
|
+
const closedProfilePath = profilePath;
|
|
181
|
+
child = undefined;
|
|
182
|
+
profilePath = undefined;
|
|
183
|
+
if (closedChild !== undefined) {
|
|
184
|
+
if (keepProfilePath && !childExited) {
|
|
185
|
+
await closeBrowserGracefully(closedChild);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
closedChild.kill();
|
|
189
|
+
} catch {
|
|
190
|
+
// ignored
|
|
191
|
+
}
|
|
192
|
+
await closedChild.status;
|
|
193
|
+
}
|
|
194
|
+
if (closedProfilePath !== undefined) {
|
|
195
|
+
if (keepProfilePath) {
|
|
196
|
+
await removeProfileLockEntries(closedProfilePath).catch(() => { });
|
|
197
|
+
await pruneProfile(closedProfilePath).catch(() => { });
|
|
198
|
+
} else {
|
|
199
|
+
try {
|
|
200
|
+
await remove(closedProfilePath, { recursive: true });
|
|
201
|
+
} catch {
|
|
202
|
+
console.log("Warning: failed to remove profile directory: " + closedProfilePath); // eslint-disable-line no-console
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function closeBrowserGracefully(closedChild) {
|
|
209
|
+
try {
|
|
210
|
+
const response = await fetch(LOCALHOST + browserDebugPort + "/json/version");
|
|
211
|
+
const { webSocketDebuggerUrl } = await response.json();
|
|
212
|
+
const socket = new WebSocket(webSocketDebuggerUrl);
|
|
213
|
+
await new Promise((resolve, reject) => {
|
|
214
|
+
socket.addEventListener("open", resolve);
|
|
215
|
+
socket.addEventListener("error", reject);
|
|
216
|
+
});
|
|
217
|
+
socket.send(JSON.stringify({ id: 0, method: "Browser.close" }));
|
|
218
|
+
await Promise.race([
|
|
219
|
+
closedChild.status,
|
|
220
|
+
new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT))
|
|
221
|
+
]);
|
|
222
|
+
} catch {
|
|
223
|
+
// ignored
|
|
224
|
+
}
|
|
225
|
+
}
|