ShadowRocket 的 conf 语法没有引号机制,含空格/emoji 的策略名一律裸写。 此前 quoteName 给策略名加引号,引号被当作名字的一部分,规则指向不存在的 策略组而失效,流量落到默认行为走代理(小红书/微信/抖音配了直连仍走代理)。 改为 policyName 剔除引号与逗号后裸写,补回归测试,并在 CLAUDE.md 记录此约束。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
import type { ProfileIR } from '../ir.js';
|
||
|
||
/**
|
||
* ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可;
|
||
* 加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效。
|
||
* 逗号是唯一分隔符,名字里的逗号无法表达,只能剔除。
|
||
*/
|
||
function policyName(name: string): string {
|
||
return name.replace(/[",]/g, '').trim();
|
||
}
|
||
|
||
/**
|
||
* ShadowRocket 的 [General] 只保留它认识的通用键——模板里的 Surge 专有键
|
||
* (all-hybrid / udp-priority / http-api / compatibility-mode 等)会被过滤掉。
|
||
*/
|
||
const SR_GENERAL_KEYS = new Set([
|
||
'bypass-system',
|
||
'skip-proxy',
|
||
'bypass-tun',
|
||
'dns-server',
|
||
'fallback-dns-server',
|
||
'ipv6',
|
||
'prefer-ipv6',
|
||
'dns-direct-system',
|
||
'icmp-auto-reply',
|
||
'always-real-ip',
|
||
'hijack-dns',
|
||
'udp-policy-not-supported-behaviour',
|
||
'interface-mode',
|
||
]);
|
||
|
||
function shadowrocketGeneral(surgeGeneral: string): string {
|
||
const lines: string[] = [];
|
||
for (const raw of surgeGeneral.split(/\r?\n/)) {
|
||
const line = raw.trim();
|
||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||
const key = line.split('=')[0].trim();
|
||
if (SR_GENERAL_KEYS.has(key)) lines.push(line);
|
||
}
|
||
// ShadowRocket 的常用默认值,模板里没有就补上
|
||
if (!lines.some((l) => l.startsWith('bypass-system'))) lines.push('bypass-system = true');
|
||
if (!lines.some((l) => l.startsWith('ipv6'))) lines.push('ipv6 = false');
|
||
if (!lines.some((l) => l.startsWith('dns-server'))) lines.push('dns-server = 223.5.5.5, 119.29.29.29');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/** ShadowRocket 不认识的规则类型 */
|
||
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']);
|
||
|
||
export interface ShadowrocketConfOutput {
|
||
content: string;
|
||
skippedRules: string[];
|
||
}
|
||
|
||
/**
|
||
* 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。
|
||
* 节点由「节点订阅」单独导入。ShadowRocket 没有 Surge 的 `use=` 订阅引用语法,
|
||
* 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选,
|
||
* 因此订阅在 App 里叫什么名字都可以。
|
||
*/
|
||
export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput {
|
||
const lines: string[] = [];
|
||
|
||
lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)');
|
||
lines.push('# 节点请另行导入「节点订阅」');
|
||
lines.push('');
|
||
lines.push('[General]');
|
||
lines.push(shadowrocketGeneral(ir.sections['General'] ?? ''));
|
||
|
||
lines.push('', '[Proxy Group]');
|
||
for (const g of ir.groups) {
|
||
const parts: string[] = [g.type];
|
||
if (!g.filterRegex && !g.includeAllNodes) {
|
||
const members = g.members.map((m) => policyName(m.name));
|
||
parts.push(...(members.length ? members : ['DIRECT']));
|
||
}
|
||
if (g.type !== 'select') {
|
||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||
}
|
||
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
|
||
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||
else if (g.includeAllNodes) parts.push('policy-regex-filter=.*');
|
||
lines.push(`${policyName(g.name)} = ${parts.join(', ')}`);
|
||
}
|
||
|
||
const skippedRules: string[] = [];
|
||
lines.push('', '[Rule]');
|
||
for (const r of ir.rules) {
|
||
if (r.type === 'FINAL') {
|
||
lines.push(['FINAL', policyName(r.policy)].join(','));
|
||
continue;
|
||
}
|
||
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
||
skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`);
|
||
continue;
|
||
}
|
||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||
lines.push([r.type, value, policyName(r.policy), ...r.params].join(','));
|
||
}
|
||
|
||
for (const section of ['Host', 'URL Rewrite', 'MITM']) {
|
||
if (ir.sections[section]) lines.push('', `[${section}]`, ir.sections[section]);
|
||
}
|
||
|
||
return { content: lines.join('\n') + '\n', skippedRules };
|
||
}
|