@xcanwin/manyoyo 8.3.2 → 8.4.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.
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const { isValidId } = require('./container-state');
5
+
6
+ // 容器网络策略:校验(唯一入口)+ 生成 nft 规则文本。
7
+ // nft 文本只由校验过的结构化数据生成:IP/CIDR 经 net.isIP + 前缀整数、端口是 1-65535 的整数、域名不进规则
8
+ // (域名只在过滤代理里匹配),所以任何用户字符串都拼不进规则。
9
+ const PRESETS = ['restricted', 'open', 'allowlist'];
10
+ const PROTOS = ['tcp', 'udp'];
11
+ const LIMITS = { domains: 200, rules: 100, host: 50, inbound: 50, deny: 100, expose: 20, portItems: 32 };
12
+ const DOMAIN_RE = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
13
+
14
+ // restricted / allowlist 默认禁止的私有与保留网段(公网放行)
15
+ const PRIVATE_V4 = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15', '224.0.0.0/4', '240.0.0.0/4'];
16
+ // 64:ff9b::/96(NAT64)里嵌的是 IPv4,可以映射到私有地址
17
+ const PRIVATE_V6 = ['fc00::/7', '64:ff9b::/96'];
18
+
19
+ function fail(message) {
20
+ throw new Error(message);
21
+ }
22
+
23
+ function isPort(value) {
24
+ return Number.isInteger(value) && value >= 1 && value <= 65535;
25
+ }
26
+
27
+ /**
28
+ * 端口表达式 "80" / "80-90" / "80,443,8000-8100" → 规范化字符串;空串表示不限端口(仅部分规则允许)。
29
+ */
30
+ function normalizePorts(input, { required = true, label = '端口' } = {}) {
31
+ const text = String(input === undefined || input === null ? '' : input).replace(/\s+/g, '');
32
+ if (!text) {
33
+ if (required) fail(`${label}不能为空`);
34
+ return '';
35
+ }
36
+ const items = text.split(',');
37
+ if (items.length > LIMITS.portItems) fail(`${label}条目过多`);
38
+ const out = items.map(item => {
39
+ const m = /^(\d{1,5})(?:-(\d{1,5}))?$/.exec(item);
40
+ if (!m) fail(`${label}格式非法: ${String(input).slice(0, 40)}`);
41
+ const a = Number(m[1]);
42
+ const b = m[2] === undefined ? a : Number(m[2]);
43
+ if (!isPort(a) || !isPort(b) || b < a) fail(`${label}超出范围: ${item}`);
44
+ return a === b ? String(a) : `${a}-${b}`;
45
+ });
46
+ return out.join(',');
47
+ }
48
+
49
+ function normalizeProto(input) {
50
+ const proto = String(input === undefined || input === null || input === '' ? 'tcp' : input).toLowerCase();
51
+ if (!PROTOS.includes(proto)) fail(`协议只支持 tcp / udp: ${String(input).slice(0, 20)}`);
52
+ return proto;
53
+ }
54
+
55
+ /** IP 或 CIDR,返回 {cidr, family};不接受主机名。 */
56
+ function normalizeCidr(input, label = 'CIDR') {
57
+ const text = String(input === undefined || input === null ? '' : input).trim();
58
+ const m = /^([0-9a-fA-F:.]+)(?:\/(\d{1,3}))?$/.exec(text);
59
+ if (!m) fail(`${label}格式非法: ${text.slice(0, 60)}`);
60
+ const family = net.isIP(m[1]);
61
+ if (!family) fail(`${label}不是合法 IP: ${text.slice(0, 60)}`);
62
+ const max = family === 4 ? 32 : 128;
63
+ let prefix = m[2] === undefined ? max : Number(m[2]);
64
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > max) fail(`${label}前缀超出范围: ${text.slice(0, 60)}`);
65
+ return { cidr: `${m[1].toLowerCase()}${m[2] === undefined ? '' : `/${prefix}`}`, family };
66
+ }
67
+
68
+ function normalizeDomain(input) {
69
+ const text = String(input === undefined || input === null ? '' : input).trim().toLowerCase();
70
+ if (text.length > 253 || !DOMAIN_RE.test(text)) fail(`域名非法: ${text.slice(0, 80)}`);
71
+ return text;
72
+ }
73
+
74
+ function normalizeBind(input) {
75
+ const text = String(input === undefined || input === null || input === '' ? '127.0.0.1' : input).trim();
76
+ if (!net.isIP(text)) fail(`监听地址必须是 IP: ${text.slice(0, 60)}`);
77
+ return text.toLowerCase();
78
+ }
79
+
80
+ function asArray(value, max, label) {
81
+ if (value === undefined || value === null) return [];
82
+ if (!Array.isArray(value)) fail(`${label}必须是数组`);
83
+ if (value.length > max) fail(`${label}最多 ${max} 条`);
84
+ return value;
85
+ }
86
+
87
+ function asObject(value, label) {
88
+ if (value === undefined || value === null) return {};
89
+ if (typeof value !== 'object' || Array.isArray(value)) fail(`${label}必须是对象`);
90
+ return value;
91
+ }
92
+
93
+ function defaultPolicy() {
94
+ return {
95
+ version: 1,
96
+ preset: 'restricted',
97
+ egress: { domains: [], rules: [] },
98
+ host: [],
99
+ peers: { inbound: [] },
100
+ deny: [],
101
+ expose: [],
102
+ autostartOnServe: false
103
+ };
104
+ }
105
+
106
+ /**
107
+ * 校验并规范化策略;任何非法输入抛错(调用方返回 400)。
108
+ */
109
+ function normalizePolicy(input) {
110
+ const src = asObject(input, '网络策略');
111
+ const policy = defaultPolicy();
112
+ if (src.preset !== undefined) {
113
+ if (!PRESETS.includes(src.preset)) fail(`preset 只支持 ${PRESETS.join(' / ')}`);
114
+ policy.preset = src.preset;
115
+ }
116
+ const egress = asObject(src.egress, 'egress');
117
+ const seenDomains = new Set();
118
+ asArray(egress.domains, LIMITS.domains, 'egress.domains').forEach(d => {
119
+ const domain = normalizeDomain(d);
120
+ if (!seenDomains.has(domain)) {
121
+ seenDomains.add(domain);
122
+ policy.egress.domains.push(domain);
123
+ }
124
+ });
125
+ asArray(egress.rules, LIMITS.rules, 'egress.rules').forEach(rule => {
126
+ const r = asObject(rule, 'egress.rules[]');
127
+ const { cidr } = normalizeCidr(r.cidr, 'egress.rules.cidr');
128
+ policy.egress.rules.push({ cidr, ports: normalizePorts(r.ports, { required: false }), proto: normalizeProto(r.proto) });
129
+ });
130
+ asArray(src.host, LIMITS.host, 'host').forEach(entry => {
131
+ const e = asObject(entry, 'host[]');
132
+ policy.host.push({ ports: normalizePorts(e.ports, { label: '宿主机端口' }), proto: normalizeProto(e.proto) });
133
+ });
134
+ const peers = asObject(src.peers, 'peers');
135
+ asArray(peers.inbound, LIMITS.inbound, 'peers.inbound').forEach(entry => {
136
+ const e = asObject(entry, 'peers.inbound[]');
137
+ if (!isValidId(e.from)) fail('peers.inbound.from 必须是容器 id');
138
+ policy.peers.inbound.push({ from: e.from, ports: normalizePorts(e.ports, { label: '入站端口' }), proto: normalizeProto(e.proto) });
139
+ });
140
+ asArray(src.deny, LIMITS.deny, 'deny').forEach(entry => {
141
+ policy.deny.push({ cidr: normalizeCidr(asObject(entry, 'deny[]').cidr, 'deny.cidr').cidr });
142
+ });
143
+ const hostPorts = new Set();
144
+ asArray(src.expose, LIMITS.expose, 'expose').forEach(entry => {
145
+ const e = asObject(entry, 'expose[]');
146
+ const hostPort = Number(e.hostPort);
147
+ const port = Number(e.port);
148
+ if (!isPort(hostPort)) fail(`宿主机端口非法: ${String(e.hostPort).slice(0, 10)}`);
149
+ if (!isPort(port)) fail(`容器端口非法: ${String(e.port).slice(0, 10)}`);
150
+ const bind = normalizeBind(e.bind);
151
+ const key = `${bind}:${hostPort}`;
152
+ if (hostPorts.has(key)) fail(`重复的暴露端口: ${key}`);
153
+ hostPorts.add(key);
154
+ policy.expose.push({ bind, hostPort, port });
155
+ });
156
+ policy.autostartOnServe = src.autostartOnServe === true;
157
+ return policy;
158
+ }
159
+
160
+ // ---- 域名匹配(过滤代理用) ----
161
+
162
+ /** pattern 为 `example.com`(只匹配自身)或 `*.example.com`(匹配任意子域,不含自身)。 */
163
+ function domainMatches(pattern, host) {
164
+ const h = String(host || '').toLowerCase().replace(/\.$/, '');
165
+ if (pattern.startsWith('*.')) return h.endsWith(pattern.slice(1)) && h.length > pattern.length - 1;
166
+ return h === pattern;
167
+ }
168
+
169
+ function domainAllowed(domains, host) {
170
+ return (domains || []).some(pattern => domainMatches(pattern, host));
171
+ }
172
+
173
+ // ---- nft 生成 ----
174
+
175
+ function portSet(ports) {
176
+ const normalized = normalizePorts(ports);
177
+ return normalized.includes(',') ? `{ ${normalized.split(',').join(', ')} }` : normalized;
178
+ }
179
+
180
+ function addrExpr(direction, cidr, label) {
181
+ const { cidr: c, family } = normalizeCidr(cidr, label);
182
+ return `${family === 4 ? 'ip' : 'ip6'} ${direction} ${c}`;
183
+ }
184
+
185
+ function portClause(proto, ports) {
186
+ return ports ? ` ${normalizeProto(proto)} dport ${portSet(ports)}` : ` meta l4proto ${normalizeProto(proto)}`;
187
+ }
188
+
189
+ function cidrList(cidrs, family) {
190
+ return cidrs.map(c => normalizeCidr(c)).filter(c => c.family === family).map(c => c.cidr);
191
+ }
192
+
193
+ /**
194
+ * @param {object} policy normalizePolicy 的结果
195
+ * @param {object} endpoints 运行时解析出的地址(下发时现算,全部重新校验)
196
+ * @param {string[]} [endpoints.dns]
197
+ * @param {{ip:string, ports:string, proto?:string}[]} [endpoints.allow] manyoyo 必需端点、上游代理、过滤代理、Playwright、镜像、host 端口、出站 peer
198
+ * @param {{ip:string, ports:string, proto?:string}[]} [endpoints.peerIn] 允许进来的 peer
199
+ * @param {{subnet:string, gateway:string}|null} [endpoints.bridge]
200
+ * @returns {string} 整份 nft 脚本(先删后建,一次 nft -f 原子替换);open 预设只删表
201
+ */
202
+ function buildNftRuleset(policy, endpoints = {}) {
203
+ const lines = ['add table inet manyoyo', 'delete table inet manyoyo'];
204
+ if (policy.preset === 'open') return `${lines.join('\n')}\n`;
205
+
206
+ const out = [' oif lo accept', ' ct state established,related accept'];
207
+ // 用户 deny 排在所有放行规则之前,才能真正“禁止”
208
+ if (policy.preset !== 'open') policy.deny.forEach(rule => out.push(` ${addrExpr('daddr', rule.cidr, 'deny')} reject`));
209
+ (endpoints.dns || []).forEach(ip => {
210
+ const { cidr, family } = normalizeCidr(ip, 'dns');
211
+ const prefix = family === 4 ? 'ip' : 'ip6';
212
+ out.push(` ${prefix} daddr ${cidr} udp dport 53 accept`);
213
+ out.push(` ${prefix} daddr ${cidr} tcp dport 53 accept`);
214
+ });
215
+ (endpoints.allow || []).forEach(item => {
216
+ out.push(` ${addrExpr('daddr', item.ip, 'allow')}${portClause(item.proto, item.ports)} accept`);
217
+ });
218
+ // egress.rules:直连放行的 IP 规则(restricted 下用来放行私有网段里的指定端点,allowlist 下是唯一的直连出口)
219
+ policy.egress.rules.forEach(rule => {
220
+ out.push(` ${addrExpr('daddr', rule.cidr, 'egress')}${portClause(rule.proto, rule.ports)} accept`);
221
+ });
222
+ if (policy.preset === 'allowlist') {
223
+ out.push(' reject');
224
+ } else {
225
+ // restricted:先放行用户 deny 之外的公网,再封私有网段
226
+ const v4 = cidrList(PRIVATE_V4, 4);
227
+ const v6 = cidrList(PRIVATE_V6, 6);
228
+ out.push(` ip daddr { ${v4.join(', ')} } reject`);
229
+ out.push(` ip6 daddr { ${v6.join(', ')} } reject`);
230
+ out.push(' meta l4proto { tcp, udp } ip6 daddr fe80::/10 reject');
231
+ }
232
+
233
+ const inn = [' iif lo accept', ' ct state established,related accept'];
234
+ (endpoints.peerIn || []).forEach(item => {
235
+ inn.push(` ${addrExpr('saddr', item.ip, 'peer')}${portClause(item.proto, item.ports)} accept`);
236
+ });
237
+ if (endpoints.bridge) {
238
+ const subnet = normalizeCidr(endpoints.bridge.subnet, 'bridge.subnet');
239
+ const gateway = normalizeCidr(endpoints.bridge.gateway, 'bridge.gateway');
240
+ if (subnet.family === 4 && gateway.family === 4) {
241
+ inn.push(` ip saddr ${subnet.cidr} ip saddr != ${gateway.cidr} ct state new reject`);
242
+ }
243
+ }
244
+
245
+ lines.push('table inet manyoyo {');
246
+ lines.push(' chain output {', ' type filter hook output priority 0; policy accept;', ...out, ' }');
247
+ lines.push(' chain input {', ' type filter hook input priority 0; policy accept;', ...inn, ' }');
248
+ lines.push('}');
249
+ return `${lines.join('\n')}\n`;
250
+ }
251
+
252
+ module.exports = {
253
+ PRESETS,
254
+ PRIVATE_V4,
255
+ PRIVATE_V6,
256
+ normalizePorts,
257
+ normalizeProto,
258
+ normalizeCidr,
259
+ normalizeDomain,
260
+ normalizePolicy,
261
+ defaultPolicy,
262
+ domainMatches,
263
+ domainAllowed,
264
+ buildNftRuleset
265
+ };
@@ -2,7 +2,8 @@
2
2
 
3
3
  // 浏览器指纹的唯一数据源:宿主机生成的配置与镜像内的 docker/res/playwright/* 都从这里生成。
4
4
  // 原则是各项信号自洽:UA、Client Hints、平台、字体、WebGL 一律用浏览器真实值,不做自相矛盾的伪装;
5
- // 只固定语言、时区、窗口形态与自动化痕迹。
5
+ // 只固定语言、时区、窗口形态与自动化痕迹。语言与时区由浏览器进程的原生环境变量(TZ / LANG / LANGUAGE)提供,
6
+ // 而不是 Playwright 的 contextOptions 模拟:后者只作用于页面,Worker 里还是系统值,会被检测站点识别为不一致。
6
7
 
7
8
  const CONTAINER_CONFIG_DIR = '/run/manyoyo-playwright';
8
9
  const CONTAINER_CONFIG_PATH = `${CONTAINER_CONFIG_DIR}/config.json`;
@@ -13,7 +14,7 @@ const DEFAULT_PROFILE = {
13
14
  locale: 'en-US',
14
15
  timezoneId: 'UTC',
15
16
  navigatorPlatform: '',
16
- disableWebRTC: false
17
+ disableWebRTC: true
17
18
  };
18
19
 
19
20
  function resolveProfile(options = {}) {
@@ -22,13 +23,34 @@ function resolveProfile(options = {}) {
22
23
  locale: text(options.locale, DEFAULT_PROFILE.locale),
23
24
  timezoneId: text(options.timezoneId, DEFAULT_PROFILE.timezoneId),
24
25
  navigatorPlatform: text(options.navigatorPlatform, DEFAULT_PROFILE.navigatorPlatform),
25
- disableWebRTC: options.disableWebRTC === true
26
+ disableWebRTC: options.disableWebRTC === undefined ? DEFAULT_PROFILE.disableWebRTC : options.disableWebRTC === true
26
27
  };
27
28
  }
28
29
 
29
- function acceptLanguage(locale) {
30
- const language = String(locale).split('-')[0];
31
- return language === locale ? `${locale};q=0.9` : `${locale},${language};q=0.9`;
30
+ // 浏览器进程的原生语言与时区环境变量;值会写进 shell 与 env 文件,所以格式必须严格。
31
+ // locale 只取“语言[-文字][-地区]”(兼容 en_US 写法,丢掉 -u- 之类的扩展),LANG 用 ll_RR.UTF-8
32
+ function buildProcessEnv(options = {}) {
33
+ const profile = resolveProfile(options);
34
+ const match = /^([A-Za-z]{2,3})(?:[-_][A-Za-z]{4})?(?:[-_]([A-Za-z]{2}|\d{3}))?(?:[-_][A-Za-z0-9]{1,8})*$/.exec(profile.locale);
35
+ if (!match) {
36
+ throw new Error(`locale 格式无效: ${profile.locale}`);
37
+ }
38
+ if (!/^[A-Za-z0-9_+/-]+$/.test(profile.timezoneId)) {
39
+ throw new Error(`timezoneId 格式无效: ${profile.timezoneId}`);
40
+ }
41
+ const language = match[1].toLowerCase();
42
+ const region = match[2] ? match[2].toUpperCase() : '';
43
+ const posix = region ? `${language}_${region}` : language;
44
+ return {
45
+ TZ: profile.timezoneId,
46
+ LANG: `${posix}.UTF-8`,
47
+ LANGUAGE: region ? `${posix}:${language}` : language
48
+ };
49
+ }
50
+
51
+ // 容器内 playwright-cli 包装脚本 source 的文件
52
+ function buildContainerEnvFile(options = {}) {
53
+ return Object.entries(buildProcessEnv(options)).map(([key, value]) => `export ${key}='${value}'\n`).join('');
32
54
  }
33
55
 
34
56
  // 宿主机的时区与系统语言,作为默认值
@@ -42,7 +64,8 @@ function buildLaunchArgs(options = {}, window = 'native') {
42
64
  const profile = resolveProfile(options);
43
65
  const args = [
44
66
  `--lang=${profile.locale}`,
45
- window === 'virtual' ? '--window-size=1920,1080' : '--start-maximized',
67
+ // 虚拟屏 1920x1080,fluxbox 底栏占 22px:窗口取工作区大小并贴在 (0,0),否则 Chrome 被整体顶上去(y=-21),顶部标签栏被截掉
68
+ ...(window === 'virtual' ? ['--window-size=1920,1058', '--window-position=0,0'] : ['--start-maximized']),
46
69
  '--disable-blink-features=AutomationControlled',
47
70
  '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
48
71
  ];
@@ -53,36 +76,23 @@ function buildLaunchArgs(options = {}, window = 'native') {
53
76
  }
54
77
 
55
78
  // 有头浏览器不设置 viewport / screen,让窗口尺寸与屏幕尺寸像真实窗口一样不同
56
- function buildContextOptions(options = {}) {
57
- const profile = resolveProfile(options);
58
- return {
59
- locale: profile.locale,
60
- timezoneId: profile.timezoneId,
61
- viewport: null,
62
- extraHTTPHeaders: {
63
- 'Accept-Language': acceptLanguage(profile.locale)
64
- }
65
- };
79
+ function buildContextOptions() {
80
+ return { viewport: null };
81
+ }
82
+
83
+ // 只有 navigatorPlatform(用户显式设置)或 disableWebRTC(默认开启,STUN 会暴露本机公网 IP)生效时才会改写页面;都不需要时什么都不改(每个被改写的 getter 都可能被检测站点当作 lie)
84
+ function needsInitScript(profile) {
85
+ return Boolean(profile.navigatorPlatform) || profile.disableWebRTC;
66
86
  }
67
87
 
68
88
  function buildInitScript(options = {}) {
69
89
  const profile = resolveProfile(options);
70
- // 首个页面加载时窗口尚未显示,outerWidth/outerHeight 会读到 0(无头浏览器的典型特征);
71
- // 只在读到 0 时用 inner 尺寸加上工具栏高度补上,真实窗口的值原样返回
90
+ if (!needsInitScript(profile)) {
91
+ return "'use strict';\n";
92
+ }
72
93
  const lines = [
73
94
  "'use strict';",
74
- '(function () {',
75
- ' try {',
76
- ' const toolbar = 87;',
77
- ' const patch = (name, fallback, getter) => {',
78
- ' const original = Object.getOwnPropertyDescriptor(window, name);',
79
- ' if (!original || !original.get) return;',
80
- ' const real = original.get;',
81
- ' Object.defineProperty(window, name, { ...original, get: getter(function () { const value = real.call(this); return value === 0 ? fallback() : value; }) });',
82
- ' };',
83
- " patch('outerWidth', () => window.innerWidth, fn => Object.getOwnPropertyDescriptor({ get outerWidth() { return fn.call(this); } }, 'outerWidth').get);",
84
- " patch('outerHeight', () => window.innerHeight + toolbar, fn => Object.getOwnPropertyDescriptor({ get outerHeight() { return fn.call(this); } }, 'outerHeight').get);",
85
- ' } catch (_) {}'
95
+ '(function () {'
86
96
  ];
87
97
  if (profile.navigatorPlatform) {
88
98
  lines.push(
@@ -117,16 +127,16 @@ function buildInitScript(options = {}) {
117
127
  }
118
128
 
119
129
  // 容器内 playwright-cli 读取的配置。
120
- // default: 容器内 Xvfb 里的有头浏览器;ws: 附着宿主机 launch-server;cdp: 附着用户自己的 Chrome(不注入指纹)
130
+ // default: 容器内 Xvfb 里的有头 Google Chrome;ws: 附着宿主机 launch-server;cdp: 附着用户自己的 Chrome(不注入指纹)
121
131
  function buildContainerConfig(kind, options = {}) {
122
132
  const base = { outputDir: CONTAINER_OUTPUT_DIR };
123
133
  if (kind === 'cdp') {
124
134
  return { ...base, browser: { cdpEndpoint: options.endpoint, cdpTimeout: 60000 } };
125
135
  }
126
- const fingerprint = {
127
- initScript: [CONTAINER_INIT_SCRIPT_PATH],
128
- contextOptions: buildContextOptions(options)
129
- };
136
+ const fingerprint = { contextOptions: buildContextOptions() };
137
+ if (needsInitScript(resolveProfile(options))) {
138
+ fingerprint.initScript = [CONTAINER_INIT_SCRIPT_PATH];
139
+ }
130
140
  if (kind === 'ws') {
131
141
  // launch-server 不预创建 context,必须 isolated 才会自建
132
142
  return { ...base, browser: { remoteEndpoint: options.endpoint, isolated: true, ...fingerprint } };
@@ -134,12 +144,12 @@ function buildContainerConfig(kind, options = {}) {
134
144
  return {
135
145
  ...base,
136
146
  browser: {
137
- // 容器内是 root,Chromium 不允许 root 开沙箱
138
- chromiumSandbox: false,
139
147
  browserName: 'chromium',
140
148
  ...fingerprint,
141
149
  launchOptions: {
142
- channel: 'chromium',
150
+ channel: 'chrome',
151
+ // 容器内是 root,Chrome 不允许 root 开沙箱;必须写在 launchOptions 里才生效
152
+ chromiumSandbox: false,
143
153
  headless: false,
144
154
  args: buildLaunchArgs(options, 'virtual')
145
155
  }
@@ -148,16 +158,20 @@ function buildContainerConfig(kind, options = {}) {
148
158
  }
149
159
 
150
160
  // 宿主机 playwright launch-server 的配置
151
- function buildServerConfig({ host, port, wsPath, headless = false, window = 'native', chromiumSandbox = true, extensionArgs = [], ...profileOptions }) {
152
- return {
153
- host,
154
- port,
155
- wsPath,
156
- headless,
157
- channel: 'chromium',
158
- chromiumSandbox,
159
- args: [...buildLaunchArgs(profileOptions, window), ...extensionArgs]
160
- };
161
+ // useChrome 为 false(宿主机没装 Google Chrome)时不写 channel,退回 patchright 自己下载的 Chromium。
162
+ // extensionPaths:官方 Google Chrome 从 M137 起忽略 --load-extension,扩展由 playwright-server.js 在启动后经 CDP
163
+ // Extensions.loadUnpacked 加载(需要 --enable-unsafe-extension-debugging,所以只在有扩展时才加);该字段会在 launchServer 前被取走
164
+ function buildServerConfig({ host, port, wsPath, headless = false, window = 'native', chromiumSandbox = true, useChrome = true, extensionPaths = [], ...profileOptions }) {
165
+ const config = { host, port, wsPath, headless, chromiumSandbox };
166
+ if (useChrome) {
167
+ config.channel = 'chrome';
168
+ }
169
+ config.args = buildLaunchArgs(profileOptions, window);
170
+ if (extensionPaths.length > 0) {
171
+ config.args.push('--enable-unsafe-extension-debugging');
172
+ config.extensionPaths = extensionPaths;
173
+ }
174
+ return config;
161
175
  }
162
176
 
163
177
  module.exports = {
@@ -167,7 +181,8 @@ module.exports = {
167
181
  DEFAULT_PROFILE,
168
182
  resolveProfile,
169
183
  detectHostProfile,
170
- acceptLanguage,
184
+ buildProcessEnv,
185
+ buildContainerEnvFile,
171
186
  buildLaunchArgs,
172
187
  buildContextOptions,
173
188
  buildInitScript,
@@ -194,14 +194,6 @@ function resolveExtensionInputs(pluginRoot, { extensionPaths = [], extensionName
194
194
  return resolveExtensionPaths([...extensionPaths, ...named]);
195
195
  }
196
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
197
  // 容器里看到的扩展目录:${CONTAINER_EXTENSION_ROOT}/ext-<序号>-<目录名>
206
198
  function buildContainerExtensionMounts(extensionPaths) {
207
199
  const containerPaths = [];
@@ -289,7 +281,6 @@ module.exports = {
289
281
  CONTAINER_EXTENSION_ROOT,
290
282
  buildContainerExtensionMounts,
291
283
  buildCrxUrl,
292
- buildExtensionLaunchArgs,
293
284
  convertManifestV2ToV3,
294
285
  crxZipOffset,
295
286
  downloadExtensions,
@@ -1,15 +1,35 @@
1
1
  'use strict';
2
2
 
3
- // 宿主机(或 vnc 容器内)的 Playwright 浏览器服务:用 @playwright/cli 自带的 playwright-core 启动有头浏览器并开放 WebSocket。
3
+ // 宿主机(或 vnc 容器内)的 Playwright 浏览器服务:用 patchright-core 启动有头浏览器(Google Chrome 优先)并开放 WebSocket。
4
4
  // 用法:node playwright-server.js <playwright-core 目录> <配置文件>,配置为 chromium.launchServer 的选项。
5
5
 
6
6
  const fs = require('fs');
7
7
 
8
+ // 官方 Google Chrome 忽略 --load-extension,改用 CDP Extensions.loadUnpacked;加载后的扩展在连接断开后仍保留
9
+ async function loadExtensions(chromium, endpoint, extensionPaths) {
10
+ const browser = await chromium.connect(endpoint);
11
+ try {
12
+ const cdp = await browser.newBrowserCDPSession();
13
+ for (const extensionPath of extensionPaths) {
14
+ try {
15
+ await cdp.send('Extensions.loadUnpacked', { path: extensionPath });
16
+ } catch (error) {
17
+ console.error(`扩展加载失败 ${extensionPath}: ${String(error && error.message || error).split('\n')[0]}`);
18
+ }
19
+ }
20
+ } finally {
21
+ await browser.close();
22
+ }
23
+ }
24
+
8
25
  async function main() {
9
26
  const [corePath, configPath] = process.argv.slice(2);
10
- const options = JSON.parse(fs.readFileSync(configPath, 'utf8'));
27
+ const { extensionPaths = [], ...options } = JSON.parse(fs.readFileSync(configPath, 'utf8'));
11
28
  const { chromium } = require(corePath);
12
29
  const server = await chromium.launchServer(options);
30
+ if (extensionPaths.length > 0) {
31
+ await loadExtensions(chromium, server.wsEndpoint(), extensionPaths);
32
+ }
13
33
  const shutdown = () => server.close().finally(() => process.exit(0));
14
34
  process.on('SIGTERM', shutdown);
15
35
  process.on('SIGINT', shutdown);