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 }; }