fix: ShadowRocket 策略组改用 policy-regex-filter 收节点

ShadowRocket 没有 Surge 的 use= 订阅引用语法,首版 conf 里
「订阅名 + use=true」在真机上解析失败,All Nodes 组只显示一个
无效的 Proxy Group 残留成员。改为原生的 policy-regex-filter
从 App 内全部节点筛选(收纳全部节点 = .*),订阅命名约束随之
取消,删除 SHADOWROCKET_SUB_NAME 常量并同步前端提示与文档。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:52:27 +08:00
parent bf7f2df67e
commit e5a3c70773
6 changed files with 14 additions and 33 deletions

View File

@@ -74,7 +74,7 @@ IR 层承担三件防护,改动时不要绕过:
### ShadowRocket 分两步导入 ### ShadowRocket 分两步导入
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf。**conf 里的策略组用 `use=true` 引用订阅名**,所以用户在 App 内必须把节点订阅命名为 `SHADOWROCKET_SUB_NAME`= `Proxy Station`。ShadowRocket 不支持 `DOMAIN-SET``AND``URL-REGEX`,生成时跳过并回报 `skippedRules`**该 conf 尚未经过真机验证。** 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`
### 鉴权与订阅 ### 鉴权与订阅

View File

@@ -27,7 +27,7 @@
| **ClashMeta** (Android) | ✅ | ✅ | ✅ | 一个 URL`RULE-SET` 自动映射为 rule-providers | | **ClashMeta** (Android) | ✅ | ✅ | ✅ | 一个 URL`RULE-SET` 自动映射为 rule-providers |
| **ShadowRocket** (iOS) | ✅ | ✅ | ✅ | **两个 URL**:节点订阅 + 规则配置,见下 | | **ShadowRocket** (iOS) | ✅ | ✅ | ✅ | **两个 URL**:节点订阅 + 规则配置,见下 |
ShadowRocket 的原生订阅格式只承载节点,因此拆成两步:先在「订阅」里添加节点链接并**命名为 `Proxy Station`**,再在「配置」里添加规则链接。配置文件按这个名字引用节点,名字必须一致。订阅页的 ⓘ 图标里有同样的说明。 ShadowRocket 的原生订阅格式只承载节点,因此拆成两步:先在「订阅」里添加节点链接(名称随意),再在「配置」里添加规则链接。策略组通过 `policy-regex-filter` 从 App 内全部节点里筛选。订阅页的 ⓘ 图标里有同样的说明。
> ClashMeta 侧无 Clash 版本的规则集,由本服务实时拉取 Surge 版并转换后提供,无需外部转换服务。 > ClashMeta 侧无 Clash 版本的规则集,由本服务实时拉取 Surge 版并转换后提供,无需外部转换服务。

View File

@@ -9,7 +9,6 @@ import { convertSurgeList } from '../services/rulesets/convert.js';
import { generateSurge } from '../services/generators/surge.js'; import { generateSurge } from '../services/generators/surge.js';
import { generateShadowrocket } from '../services/generators/shadowrocket.js'; import { generateShadowrocket } from '../services/generators/shadowrocket.js';
import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js'; import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js';
import { SHADOWROCKET_SUB_NAME } from '@proxy-station/shared';
import { generateClash } from '../services/generators/clash.js'; import { generateClash } from '../services/generators/clash.js';
function constantTimeEqual(a: string, b: string): boolean { function constantTimeEqual(a: string, b: string): boolean {
@@ -65,9 +64,7 @@ export const subRoute = new Hono()
const row = findToken(c.req.param('token')); const row = findToken(c.req.param('token'));
if (!row) return c.text('not found', 404); if (!row) return c.text('not found', 404);
touchToken(row.id, row.accessCount); touchToken(row.id, row.accessCount);
const { content } = generateShadowrocketConf(irFor(row), { const { content } = generateShadowrocketConf(irFor(row));
subscriptionName: SHADOWROCKET_SUB_NAME,
});
c.header('content-type', 'text/plain; charset=utf-8'); c.header('content-type', 'text/plain; charset=utf-8');
c.header('content-disposition', 'attachment; filename="Proxy Station Rules.conf"; filename*=UTF-8\'\'Proxy%20Station%20Rules.conf'); c.header('content-disposition', 'attachment; filename="Proxy Station Rules.conf"; filename*=UTF-8\'\'Proxy%20Station%20Rules.conf');
return c.body(content); return c.body(content);

View File

@@ -50,18 +50,15 @@ export interface ShadowrocketConfOutput {
/** /**
* 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。 * 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。
* 节点由「节点订阅」单独导入,策略组通过 `use=true` 引用该订阅的名称 * 节点由「节点订阅」单独导入。ShadowRocket 没有 Surge 的 `use=` 订阅引用语法
* 因此用户在 App 里给订阅起的名字必须与 subscriptionName 一致。 * 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选,
* 因此订阅在 App 里叫什么名字都可以。
*/ */
export function generateShadowrocketConf( export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput {
ir: ProfileIR,
opts: { subscriptionName: string }
): ShadowrocketConfOutput {
const sub = quoteName(opts.subscriptionName);
const lines: string[] = []; const lines: string[] = [];
lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)'); lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)');
lines.push(`# 节点请另行导入「节点订阅」,并在 App 中将其命名为:${opts.subscriptionName}`); lines.push('# 节点请另行导入「节点订阅」');
lines.push(''); lines.push('');
lines.push('[General]'); lines.push('[General]');
lines.push(shadowrocketGeneral(ir.sections['General'] ?? '')); lines.push(shadowrocketGeneral(ir.sections['General'] ?? ''));
@@ -69,13 +66,7 @@ export function generateShadowrocketConf(
lines.push('', '[Proxy Group]'); lines.push('', '[Proxy Group]');
for (const g of ir.groups) { for (const g of ir.groups) {
const parts: string[] = [g.type]; const parts: string[] = [g.type];
if (g.filterRegex) { if (!g.filterRegex && !g.includeAllNodes) {
// 地区组:引用订阅 + 正则筛选ShadowRocket 原生支持 policy-regex-filter
parts.push(sub, 'use=true', `policy-regex-filter=${g.filterRegex}`);
} else if (g.includeAllNodes) {
// 收纳全部节点:引用订阅
parts.push(sub, 'use=true');
} else {
const members = g.members.map((m) => quoteName(m.name)); const members = g.members.map((m) => quoteName(m.name));
parts.push(...(members.length ? members : ['DIRECT'])); parts.push(...(members.length ? members : ['DIRECT']));
} }
@@ -84,6 +75,9 @@ export function generateShadowrocketConf(
if (g.interval) parts.push(`interval=${g.interval}`); if (g.interval) parts.push(`interval=${g.interval}`);
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`); 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(`${g.name} = ${parts.join(', ')}`); lines.push(`${g.name} = ${parts.join(', ')}`);
} }

View File

@@ -10,12 +10,6 @@ export const CLIENT_LABELS: Record<ClientType, string> = {
clash: 'ClashMeta (Android)', clash: 'ClashMeta (Android)',
}; };
/**
* ShadowRocket 分两步导入:节点走订阅、策略组与规则走配置文件。
* 配置文件里的策略组用 `use=true` 引用订阅,因此 App 内的订阅名必须与此一致。
*/
export const SHADOWROCKET_SUB_NAME = 'Proxy Station';
export type Egress = 'direct' | 'warp' | 'unknown'; export type Egress = 'direct' | 'warp' | 'unknown';
/** Surge 与 Clash 使用 IETF 命名Xray 分享链接使用简名 */ /** Surge 与 Clash 使用 IETF 命名Xray 分享链接使用简名 */

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue'; import { nextTick, onMounted, ref, watch } from 'vue';
import { NButton, NPopover, NSpace, NSwitch, useDialog, useMessage } from 'naive-ui'; import { NButton, NPopover, NSpace, NSwitch, useDialog, useMessage } from 'naive-ui';
import { SHADOWROCKET_SUB_NAME, type TokenDto } from '@proxy-station/shared'; import type { TokenDto } from '@proxy-station/shared';
import { useSubscriptionsStore } from '../stores/subscriptions'; import { useSubscriptionsStore } from '../stores/subscriptions';
import { useNodesStore } from '../stores/nodes'; import { useNodesStore } from '../stores/nodes';
import { copyText } from '../utils/clipboard'; import { copyText } from '../utils/clipboard';
@@ -120,11 +120,7 @@ watch(manualCopyText, async (v) => {
<button class="info" aria-label="导入说明">?</button> <button class="info" aria-label="导入说明">?</button>
</template> </template>
<p class="pop-title">分两步导入</p> <p class="pop-title">分两步导入</p>
<p class="pop-body"> <p class="pop-body"> App 订阅里添加节点链接订阅名称随意</p>
App 订阅里添加节点链接并把订阅命名为
<code class="mono pop-code">{{ SHADOWROCKET_SUB_NAME }}</code>
配置文件按这个名字引用节点名字必须完全一致
</p>
<p class="pop-body"> 配置里添加规则链接并启用</p> <p class="pop-body"> 配置里添加规则链接并启用</p>
</NPopover> </NPopover>
</span> </span>