diff --git a/CLAUDE.md b/CLAUDE.md index 9213861..55bfd21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ IR 层承担三件防护,改动时不要绕过: ### ShadowRocket 分两步导入 -ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。 +ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。**ShadowRocket 的 conf 语法没有引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。 ### 鉴权与订阅 diff --git a/server/src/services/generators/shadowrocket-conf.test.ts b/server/src/services/generators/shadowrocket-conf.test.ts new file mode 100644 index 0000000..d1a9fad --- /dev/null +++ b/server/src/services/generators/shadowrocket-conf.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { generateShadowrocketConf } from './shadowrocket-conf.js'; +import type { ProfileIR } from '../ir.js'; + +const ir: ProfileIR = { + nodes: [], + groups: [ + { + name: '🇨🇳 Mainland', + type: 'select', + testUrl: null, + interval: null, + tolerance: null, + filterRegex: null, + includeAllNodes: false, + members: [{ kind: 'builtin', name: 'DIRECT' }], + }, + { + name: '🎯 NoAuto', + type: 'select', + testUrl: null, + interval: null, + tolerance: null, + filterRegex: null, + includeAllNodes: false, + members: [ + { kind: 'group', name: '🇨🇳 Mainland' }, + { kind: 'group', name: '⚡️ Automatic' }, + ], + }, + ], + rules: [ + { type: 'DOMAIN-SUFFIX', value: 'xiaohongshu.com', policy: '🇨🇳 Mainland', params: [], ruleset: null }, + { type: 'FINAL', value: null, policy: '🎯 NoAuto', params: ['dns-failed'], ruleset: null }, + ], + sections: {}, +}; + +describe('generateShadowrocketConf', () => { + // ShadowRocket 不剥引号:带引号的策略名会匹配不到策略组,整条规则失效(真机与社区配置证实) + it('含空格/emoji 的策略名一律裸写,不加引号', () => { + const { content } = generateShadowrocketConf(ir); + expect(content).not.toContain('"'); + expect(content).toContain('DOMAIN-SUFFIX,xiaohongshu.com,🇨🇳 Mainland'); + expect(content).toContain('FINAL,🎯 NoAuto'); + expect(content).toContain('🎯 NoAuto = select, 🇨🇳 Mainland, ⚡️ Automatic'); + }); +}); diff --git a/server/src/services/generators/shadowrocket-conf.ts b/server/src/services/generators/shadowrocket-conf.ts index b7492ae..478cdcf 100644 --- a/server/src/services/generators/shadowrocket-conf.ts +++ b/server/src/services/generators/shadowrocket-conf.ts @@ -1,8 +1,12 @@ import type { ProfileIR } from '../ir.js'; -/** 成员名含空格/逗号时加引号 */ -function quoteName(name: string): string { - return /[\s,]/.test(name) ? `"${name}"` : name; +/** + * ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可; + * 加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效。 + * 逗号是唯一分隔符,名字里的逗号无法表达,只能剔除。 + */ +function policyName(name: string): string { + return name.replace(/[",]/g, '').trim(); } /** @@ -67,7 +71,7 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput for (const g of ir.groups) { const parts: string[] = [g.type]; if (!g.filterRegex && !g.includeAllNodes) { - const members = g.members.map((m) => quoteName(m.name)); + const members = g.members.map((m) => policyName(m.name)); parts.push(...(members.length ? members : ['DIRECT'])); } if (g.type !== 'select') { @@ -78,14 +82,14 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput // 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(`${g.name} = ${parts.join(', ')}`); + 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', quoteName(r.policy)].join(',')); + lines.push(['FINAL', policyName(r.policy)].join(',')); continue; } if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) { @@ -93,7 +97,7 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput continue; } const value = r.ruleset ? r.ruleset.surgeUrl : r.value; - lines.push([r.type, value, quoteName(r.policy), ...r.params].join(',')); + lines.push([r.type, value, policyName(r.policy), ...r.params].join(',')); } for (const section of ['Host', 'URL Rewrite', 'MITM']) {