import type { ProfileIR } from '../ir.js'; import { SHADOWROCKET_LAN_RULES } from './shadowrocket-lan.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', 'PROTOCOL']); export interface ShadowrocketConfOutput { content: string; skippedRules: string[]; } /** * 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。 * 节点由「节点订阅」单独导入。ShadowRocket 没有 Surge 的 `use=` 订阅引用语法, * 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选, * 因此订阅在 App 里叫什么名字都可以。 */ export function generateShadowrocketConf(ir: ProfileIR, opts: { convertBaseUrl?: string } = {}): 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]; const members = [...new Set(g.members.map((m) => policyName(m.name)))]; parts.push(...(members.length ? members : !g.filterRegex && !g.includeAllNodes ? ['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.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`); } // policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则 const filter = g.filterRegex || (g.includeAllNodes ? '.*' : null); if (filter) parts.push(`policy-regex-filter=${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 (r.type === 'DOMAIN-SET' && r.ruleset?.clashBehavior === 'domain' && opts.convertBaseUrl) { const url = `${opts.convertBaseUrl}/ruleset/${encodeURIComponent(r.ruleset.id)}.list`; lines.push(['RULE-SET', url, policyName(r.policy), ...r.params].join(',')); continue; } if (r.type === 'RULE-SET' && !r.ruleset && r.value === 'LAN') { lines.push(...SHADOWROCKET_LAN_RULES.map((rule) => [rule, policyName(r.policy), ...r.params].join(','))); continue; } // An absolute macOS app-bundle path cannot identify an iOS application. if (r.type === 'PROCESS-NAME' && /^\/Applications\/.*\.app(?:\/|$)/.test(r.value ?? '')) { skippedRules.push(`${r.type},${r.value},${r.policy}`); 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 }; }