@xcanwin/manyoyo 8.2.1 → 8.3.1

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.
@@ -0,0 +1,299 @@
1
+ 'use strict';
2
+
3
+ // 浏览器扩展:下载 CRX 并转成可加载的目录,以及把目录解析成 Chromium 启动参数。
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ const EXTENSIONS = [
11
+ ['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
12
+ ['adguard', 'bgnkhhnnamicmpeenaelnjfhikgbkllg'],
13
+ ['privacy-badger', 'pkehgijcmpdhfbdbbnkijodmdjhbjlgp'],
14
+ ['webrtc-leak-shield', 'bppamachkoflopbagkdoflbgfjflfnfl'],
15
+ ['webgl-fingerprint-defender', 'olnbjpaejebpnokblkepbphhembdicik']
16
+ ];
17
+
18
+ function isHostPermission(value) {
19
+ if (value === '<all_urls>') {
20
+ return true;
21
+ }
22
+ return /^(?:\*|http|https|file|ftp):\/\//.test(value);
23
+ }
24
+
25
+ function scriptSourcesFromHtml(htmlFile) {
26
+ const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
27
+ const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
28
+ return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
29
+ }
30
+
31
+ function convertManifestV2ToV3(extDir) {
32
+ const manifestFile = path.join(extDir, 'manifest.json');
33
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
34
+ if (manifest.manifest_version !== 2) {
35
+ return false;
36
+ }
37
+
38
+ manifest.manifest_version = 3;
39
+
40
+ if (manifest.browser_action && !manifest.action) {
41
+ manifest.action = manifest.browser_action;
42
+ delete manifest.browser_action;
43
+ }
44
+ if (manifest.page_action && !manifest.action) {
45
+ manifest.action = manifest.page_action;
46
+ delete manifest.page_action;
47
+ }
48
+
49
+ const background = manifest.background;
50
+ if (background && typeof background === 'object' && !Array.isArray(background)) {
51
+ let scripts = [];
52
+ if (Array.isArray(background.scripts)) {
53
+ scripts = background.scripts.filter(s => typeof s === 'string');
54
+ } else if (typeof background.page === 'string') {
55
+ const pagePath = path.join(extDir, background.page);
56
+ if (fs.existsSync(pagePath)) {
57
+ scripts = scriptSourcesFromHtml(pagePath);
58
+ }
59
+ }
60
+
61
+ if (scripts.length > 0) {
62
+ const swName = 'generated_background_sw.js';
63
+ const swFile = path.join(extDir, swName);
64
+ const swLines = [
65
+ '// Auto-generated by manyoyo playwright ext-download for MV3.',
66
+ `importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
67
+ ''
68
+ ];
69
+ fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
70
+ manifest.background = { service_worker: swName };
71
+ } else {
72
+ delete manifest.background;
73
+ }
74
+ }
75
+
76
+ if (typeof manifest.content_security_policy === 'string') {
77
+ manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
78
+ }
79
+
80
+ if (Array.isArray(manifest.permissions)) {
81
+ const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
82
+ const keptPermissions = [];
83
+
84
+ for (const perm of manifest.permissions) {
85
+ if (typeof perm === 'string' && isHostPermission(perm)) {
86
+ if (!hostPermissions.includes(perm)) {
87
+ hostPermissions.push(perm);
88
+ }
89
+ } else {
90
+ keptPermissions.push(perm);
91
+ }
92
+ }
93
+
94
+ manifest.permissions = keptPermissions;
95
+ if (hostPermissions.length > 0) {
96
+ manifest.host_permissions = hostPermissions;
97
+ }
98
+ }
99
+
100
+ const war = manifest.web_accessible_resources;
101
+ if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
102
+ manifest.web_accessible_resources = [
103
+ {
104
+ resources: war,
105
+ matches: ['<all_urls>']
106
+ }
107
+ ];
108
+ }
109
+
110
+ fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
111
+ return true;
112
+ }
113
+
114
+ function buildCrxUrl(extId, prodversion) {
115
+ return (
116
+ 'https://clients2.google.com/service/update2/crx' +
117
+ `?response=redirect&prodversion=${prodversion}` +
118
+ '&acceptformat=crx2,crx3' +
119
+ `&x=id%3D${extId}%26installsource%3Dondemand%26uc`
120
+ );
121
+ }
122
+
123
+ function crxZipOffset(data) {
124
+ if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
125
+ throw new Error('not a CRX file');
126
+ }
127
+
128
+ const version = data.readUInt32LE(4);
129
+ if (version === 2) {
130
+ const pubLen = data.readUInt32LE(8);
131
+ const sigLen = data.readUInt32LE(12);
132
+ return 16 + pubLen + sigLen;
133
+ }
134
+ if (version === 3) {
135
+ const headerLen = data.readUInt32LE(8);
136
+ return 12 + headerLen;
137
+ }
138
+ throw new Error(`unsupported CRX version: ${version}`);
139
+ }
140
+ const CONTAINER_EXTENSION_ROOT = '/app/extensions';
141
+
142
+ function sleep(ms) {
143
+ return new Promise(resolve => setTimeout(resolve, ms));
144
+ }
145
+
146
+ function extensionDirPath(pluginRoot) {
147
+ return path.join(pluginRoot, 'extensions');
148
+ }
149
+
150
+ function resolveExtensionPaths(inputs) {
151
+ const uniquePaths = [];
152
+ const add = value => {
153
+ if (!uniquePaths.includes(value)) {
154
+ uniquePaths.push(value);
155
+ }
156
+ };
157
+
158
+ for (const item of inputs) {
159
+ const absPath = path.resolve(item);
160
+ if (absPath.includes(',')) {
161
+ throw new Error(`扩展路径不能包含逗号: ${absPath}`);
162
+ }
163
+ if (!fs.existsSync(absPath)) {
164
+ throw new Error(`扩展路径不存在: ${absPath}`);
165
+ }
166
+ if (!fs.statSync(absPath).isDirectory()) {
167
+ throw new Error(`扩展路径必须是目录: ${absPath}`);
168
+ }
169
+ if (fs.existsSync(path.join(absPath, 'manifest.json'))) {
170
+ add(absPath);
171
+ continue;
172
+ }
173
+ const children = fs.readdirSync(absPath, { withFileTypes: true })
174
+ .filter(dirent => dirent.isDirectory())
175
+ .map(dirent => path.join(absPath, dirent.name))
176
+ .filter(child => fs.existsSync(path.join(child, 'manifest.json')));
177
+ if (children.length === 0) {
178
+ throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
179
+ }
180
+ children.forEach(add);
181
+ }
182
+ return uniquePaths;
183
+ }
184
+
185
+ // --ext-path 给目录,--ext-name 给 ~/.manyoyo/plugin/playwright/extensions/ 下的目录名
186
+ function resolveExtensionInputs(pluginRoot, { extensionPaths = [], extensionNames = [] } = {}) {
187
+ const extensionRoot = path.resolve(extensionDirPath(pluginRoot));
188
+ const named = extensionNames.map(name => {
189
+ if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
190
+ throw new Error(`扩展名称无效: ${name}`);
191
+ }
192
+ return path.join(extensionRoot, name);
193
+ });
194
+ return resolveExtensionPaths([...extensionPaths, ...named]);
195
+ }
196
+
197
+ function buildExtensionLaunchArgs(extensionPaths) {
198
+ if (extensionPaths.length === 0) {
199
+ return [];
200
+ }
201
+ const joined = extensionPaths.join(',');
202
+ return [`--disable-extensions-except=${joined}`, `--load-extension=${joined}`];
203
+ }
204
+
205
+ // 容器里看到的扩展目录:${CONTAINER_EXTENSION_ROOT}/ext-<序号>-<目录名>
206
+ function buildContainerExtensionMounts(extensionPaths) {
207
+ const containerPaths = [];
208
+ const volumes = [];
209
+ extensionPaths.forEach((hostPath, idx) => {
210
+ const safeName = path.basename(hostPath).replace(/[^A-Za-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'ext';
211
+ const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
212
+ containerPaths.push(containerPath);
213
+ volumes.push(`${hostPath}:${containerPath}:ro`);
214
+ });
215
+ return { containerPaths, volumes };
216
+ }
217
+
218
+ function curlDownload(url, output, retries = 3, timeoutSec = 60) {
219
+ let lastError = null;
220
+ for (let i = 1; i <= retries; i += 1) {
221
+ const result = spawnSync('curl', [
222
+ '--fail', '--location', '--silent', '--show-error',
223
+ '--connect-timeout', String(timeoutSec), '--max-time', String(timeoutSec),
224
+ '--output', output, url
225
+ ], { encoding: 'utf8' });
226
+ if (!result.error && result.status === 0) {
227
+ return;
228
+ }
229
+ lastError = result.error || new Error(result.stderr || `curl failed with exit code ${result.status}`);
230
+ }
231
+ throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
232
+ }
233
+
234
+ function extractCrx(crxFile, outDir, log) {
235
+ const data = fs.readFileSync(crxFile);
236
+ const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
237
+ fs.mkdirSync(outDir, { recursive: true });
238
+ fs.writeFileSync(tempZip, data.subarray(crxZipOffset(data)));
239
+ const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
240
+ fs.rmSync(tempZip, { force: true });
241
+ if (result.error) {
242
+ throw result.error;
243
+ }
244
+ if (result.status !== 0) {
245
+ throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
246
+ }
247
+ if (!fs.existsSync(path.join(outDir, 'manifest.json'))) {
248
+ throw new Error(`${crxFile} extracted but manifest.json missing`);
249
+ }
250
+ if (convertManifestV2ToV3(outDir)) {
251
+ log(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
252
+ }
253
+ }
254
+
255
+ async function downloadExtensions({ pluginRoot, prodversion, log, logError }) {
256
+ if (spawnSync('unzip', ['-v'], { stdio: 'ignore' }).status !== 0) {
257
+ logError('[ext-download] failed: unzip command not found.');
258
+ return 1;
259
+ }
260
+ const extDir = path.resolve(extensionDirPath(pluginRoot));
261
+ const tmpDir = path.resolve(pluginRoot, 'tmp-crx');
262
+ fs.rmSync(tmpDir, { recursive: true, force: true });
263
+ fs.mkdirSync(extDir, { recursive: true });
264
+ fs.mkdirSync(tmpDir, { recursive: true });
265
+ try {
266
+ log(`[info] ext dir: ${extDir}`);
267
+ for (const [name, extId] of EXTENSIONS) {
268
+ const crxFile = path.join(tmpDir, `${name}.crx`);
269
+ const outDir = path.join(extDir, name);
270
+ log(`[download] ${name}`);
271
+ curlDownload(buildCrxUrl(extId, prodversion), crxFile);
272
+ log(`[extract] ${name}`);
273
+ // 先解到临时目录再替换,解压失败不会丢掉原来能用的扩展
274
+ const stagingDir = path.join(tmpDir, `${name}.staged`);
275
+ extractCrx(crxFile, stagingDir, log);
276
+ fs.rmSync(outDir, { recursive: true, force: true });
277
+ fs.renameSync(stagingDir, outDir);
278
+ await sleep(0);
279
+ }
280
+ } finally {
281
+ fs.rmSync(tmpDir, { recursive: true, force: true });
282
+ }
283
+ log(`[done] all extensions are ready: ${extDir}`);
284
+ return 0;
285
+ }
286
+
287
+ module.exports = {
288
+ EXTENSIONS,
289
+ CONTAINER_EXTENSION_ROOT,
290
+ buildContainerExtensionMounts,
291
+ buildCrxUrl,
292
+ buildExtensionLaunchArgs,
293
+ convertManifestV2ToV3,
294
+ crxZipOffset,
295
+ downloadExtensions,
296
+ extensionDirPath,
297
+ resolveExtensionInputs,
298
+ resolveExtensionPaths
299
+ };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // 真实探测:连上浏览器、新开页面、打开 data: 页面并读回内容。
4
+ // 作为子进程运行,父进程负责超时;端点(含 token)只经环境变量 MANYOYO_PROBE 传入。
5
+ // MANYOYO_PROBE = { core, kind: 'ws' | 'cdp', endpoint, timeout }
6
+
7
+ async function probe({ core, kind, endpoint, timeout }) {
8
+ const { chromium } = require(core);
9
+ const started = Date.now();
10
+ const browser = kind === 'cdp'
11
+ ? await chromium.connectOverCDP(endpoint, { timeout })
12
+ : await chromium.connect(endpoint, { timeout });
13
+ try {
14
+ let context;
15
+ let ownContext = false;
16
+ if (kind === 'cdp') {
17
+ context = browser.contexts()[0] || await browser.newContext();
18
+ } else {
19
+ context = await browser.newContext();
20
+ ownContext = true;
21
+ }
22
+ const page = await context.newPage();
23
+ try {
24
+ await page.goto('data:text/html,<title>manyoyo-ok</title>', { timeout });
25
+ const title = await page.title();
26
+ if (title !== 'manyoyo-ok') {
27
+ throw new Error(`页面内容不符: ${title}`);
28
+ }
29
+ } finally {
30
+ await page.close().catch(() => {});
31
+ if (ownContext) {
32
+ await context.close().catch(() => {});
33
+ }
34
+ }
35
+ } finally {
36
+ await browser.close().catch(() => {});
37
+ }
38
+ return Date.now() - started;
39
+ }
40
+
41
+ if (require.main === module) {
42
+ probe(JSON.parse(process.env.MANYOYO_PROBE))
43
+ .then(ms => {
44
+ console.log(JSON.stringify({ ok: true, ms }));
45
+ process.exit(0);
46
+ })
47
+ .catch(error => {
48
+ console.log(JSON.stringify({ ok: false, error: String(error && error.message || error).split('\n')[0] }));
49
+ process.exit(1);
50
+ });
51
+ }
52
+
53
+ module.exports = { probe };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ // Chrome 中继:把容器里的 CDP 连接转发给用户正在使用的 Chrome。
4
+ // 只放行带 token 路径的 WebSocket upgrade;每次新连接都重新读取 DevToolsActivePort,
5
+ // 所以 Chrome 重启(端口与路径变化)后不用重建容器;转发时改写 Host 头以通过 Chrome 的 Host 检查。
6
+
7
+ const fs = require('fs');
8
+ const http = require('http');
9
+ const crypto = require('crypto');
10
+ const { WebSocket, WebSocketServer } = require('ws');
11
+
12
+ function parseDevToolsActivePort(content) {
13
+ const lines = String(content || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
14
+ const port = Number(lines[0]);
15
+ const wsPath = lines[1] || '';
16
+ if (!Number.isInteger(port) || port <= 0 || port > 65535 || !wsPath.startsWith('/devtools/browser/')) {
17
+ return null;
18
+ }
19
+ return { port, wsPath };
20
+ }
21
+
22
+ function findDevToolsActivePort(candidates) {
23
+ for (const filePath of candidates) {
24
+ try {
25
+ const parsed = parseDevToolsActivePort(fs.readFileSync(filePath, 'utf8'));
26
+ if (parsed) {
27
+ return { ...parsed, filePath };
28
+ }
29
+ } catch {
30
+ // 候选路径不存在,继续找下一个
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function tokenMatches(actual, expected) {
37
+ const a = Buffer.from(String(actual));
38
+ const b = Buffer.from(String(expected));
39
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
40
+ }
41
+
42
+ function createRelay({ token, candidates }) {
43
+ const wss = new WebSocketServer({ noServer: true });
44
+ const server = http.createServer((req, res) => {
45
+ res.writeHead(404);
46
+ res.end();
47
+ });
48
+
49
+ server.on('upgrade', (req, socket, head) => {
50
+ const pathname = String(req.url || '').split('?')[0];
51
+ if (!tokenMatches(pathname, `/${token}`)) {
52
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
53
+ return;
54
+ }
55
+ const target = findDevToolsActivePort(candidates);
56
+ if (!target) {
57
+ socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
58
+ return;
59
+ }
60
+ const upstream = new WebSocket(`ws://127.0.0.1:${target.port}${target.wsPath}`, {
61
+ headers: { Host: `127.0.0.1:${target.port}` },
62
+ perMessageDeflate: false
63
+ });
64
+ upstream.once('error', () => socket.destroy());
65
+ upstream.once('open', () => {
66
+ wss.handleUpgrade(req, socket, head, client => {
67
+ client.on('message', (data, isBinary) => upstream.send(data, { binary: isBinary }));
68
+ upstream.on('message', (data, isBinary) => client.send(data, { binary: isBinary }));
69
+ client.on('close', () => upstream.close());
70
+ upstream.on('close', () => client.close());
71
+ client.on('error', () => upstream.terminate());
72
+ upstream.on('error', () => client.terminate());
73
+ });
74
+ });
75
+ });
76
+
77
+ return server;
78
+ }
79
+
80
+ if (require.main === module) {
81
+ // 配置只经文件传入(含 token),不进命令行与环境变量
82
+ const config = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
83
+ const server = createRelay(config);
84
+ server.listen(config.port, config.host);
85
+ process.on('SIGTERM', () => server.close(() => process.exit(0)));
86
+ }
87
+
88
+ module.exports = { createRelay, findDevToolsActivePort, parseDevToolsActivePort };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ // 宿主机(或 vnc 容器内)的 Playwright 浏览器服务:用 @playwright/cli 自带的 playwright-core 启动有头浏览器并开放 WebSocket。
4
+ // 用法:node playwright-server.js <playwright-core 目录> <配置文件>,配置为 chromium.launchServer 的选项。
5
+
6
+ const fs = require('fs');
7
+
8
+ async function main() {
9
+ const [corePath, configPath] = process.argv.slice(2);
10
+ const options = JSON.parse(fs.readFileSync(configPath, 'utf8'));
11
+ const { chromium } = require(corePath);
12
+ const server = await chromium.launchServer(options);
13
+ const shutdown = () => server.close().finally(() => process.exit(0));
14
+ process.on('SIGTERM', shutdown);
15
+ process.on('SIGINT', shutdown);
16
+ server.process().on('exit', () => process.exit(1));
17
+ console.log('ready');
18
+ }
19
+
20
+ main().catch(error => {
21
+ console.error(error && error.stack ? error.stack : String(error));
22
+ process.exit(1);
23
+ });