mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
feat(sub): add Happ client integration, routing presets, and app management (#6434)
* feat(sub): add Happ client integration, routing presets, and app management Implement comprehensive Happ proxy client integration according to official developer specifications. - Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings. - Add support for 'happ://routing/off' deeplink in routing validation. - Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS. - Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings. - Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings. - Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations. * fix(sub): keep Happ header overrides behind the auto-detect opt-in The Routing-Enable/Hide-Settings off values were emitted on the User-Agent alone, so every panel that upgraded would push "Routing-Enable: 0" — documented by happ.su as disabling routing globally — to every Happ client without the operator enabling anything. They now ride subHappAutoDetect like every other Happ header. Two further mismatches against the vendor spec: - serverDescription was written as a key of the VMess base64 JSON object. happ.su documents it as a "#Title?serverDescription=<base64>" link parameter or a JSON "meta" entry, so the caption never reached Happ while every other VMess consumer received an unknown key. Dropped rather than moved: emitting the documented form is unsafe here because our own parser base64-decodes the whole VMess body (internal/util/link/outbound.go). - The TUN Mode dropdown stored the literal "default", forwarded as "Tun-Mode: default", where happ.su documents system|gvisor only. It now stores the unset value so no header is sent. TUN Type "default" is a documented value and is unchanged. Each fix carries a test that fails without it.
This commit is contained in:
@@ -59,6 +59,29 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subExpiredTemplate": "",
|
||||
"subHappAlwaysHwid": false,
|
||||
"subHappAutoConnect": false,
|
||||
"subHappAutoConnectType": "",
|
||||
"subHappAutoDetect": false,
|
||||
"subHappColorProfile": "",
|
||||
"subHappExcludeApns": false,
|
||||
"subHappExcludeRoutes": "",
|
||||
"subHappFallbackUrl": "",
|
||||
"subHappNewUrl": "",
|
||||
"subHappNoLimit": false,
|
||||
"subHappNotificationExpire": false,
|
||||
"subHappPerAppList": "",
|
||||
"subHappPerAppMode": "",
|
||||
"subHappPingType": "",
|
||||
"subHappProviderId": "",
|
||||
"subHappSubExpire": false,
|
||||
"subHappSubExpireButtonLink": "",
|
||||
"subHappSubInfoButtonLink": "",
|
||||
"subHappSubInfoButtonText": "",
|
||||
"subHappSubInfoColor": "",
|
||||
"subHappSubInfoText": "",
|
||||
"subHappTunMode": "",
|
||||
"subHappTunType": "",
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
@@ -176,6 +199,29 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subExpiredTemplate": "",
|
||||
"subHappAlwaysHwid": false,
|
||||
"subHappAutoConnect": false,
|
||||
"subHappAutoConnectType": "",
|
||||
"subHappAutoDetect": false,
|
||||
"subHappColorProfile": "",
|
||||
"subHappExcludeApns": false,
|
||||
"subHappExcludeRoutes": "",
|
||||
"subHappFallbackUrl": "",
|
||||
"subHappNewUrl": "",
|
||||
"subHappNoLimit": false,
|
||||
"subHappNotificationExpire": false,
|
||||
"subHappPerAppList": "",
|
||||
"subHappPerAppMode": "",
|
||||
"subHappPingType": "",
|
||||
"subHappProviderId": "",
|
||||
"subHappSubExpire": false,
|
||||
"subHappSubExpireButtonLink": "",
|
||||
"subHappSubInfoButtonLink": "",
|
||||
"subHappSubInfoButtonText": "",
|
||||
"subHappSubInfoColor": "",
|
||||
"subHappSubInfoText": "",
|
||||
"subHappTunMode": "",
|
||||
"subHappTunType": "",
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
|
||||
@@ -194,6 +194,76 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subExpiredTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappAlwaysHwid": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappAutoConnect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappAutoConnectType": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappAutoDetect": {
|
||||
"description": "Happ client customization settings (app-management / routing / UX).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappColorProfile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappExcludeApns": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappExcludeRoutes": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNoLimit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappNotificationExpire": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappPerAppList": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappPerAppMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappPingType": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappProviderId": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubExpire": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappSubExpireButtonLink": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoButtonLink": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoButtonText": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoColor": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoText": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappTunMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappTunType": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -417,6 +487,29 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subExpiredTemplate",
|
||||
"subHappAlwaysHwid",
|
||||
"subHappAutoConnect",
|
||||
"subHappAutoConnectType",
|
||||
"subHappAutoDetect",
|
||||
"subHappColorProfile",
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
"subHappPerAppList",
|
||||
"subHappPerAppMode",
|
||||
"subHappPingType",
|
||||
"subHappProviderId",
|
||||
"subHappSubExpire",
|
||||
"subHappSubExpireButtonLink",
|
||||
"subHappSubInfoButtonLink",
|
||||
"subHappSubInfoButtonText",
|
||||
"subHappSubInfoColor",
|
||||
"subHappSubInfoText",
|
||||
"subHappTunMode",
|
||||
"subHappTunType",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
@@ -685,6 +778,76 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subExpiredTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappAlwaysHwid": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappAutoConnect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappAutoConnectType": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappAutoDetect": {
|
||||
"description": "Happ client customization settings (app-management / routing / UX).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappColorProfile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappExcludeApns": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappExcludeRoutes": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNoLimit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappNotificationExpire": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappPerAppList": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappPerAppMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappPingType": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappProviderId": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubExpire": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHappSubExpireButtonLink": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoButtonLink": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoButtonText": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoColor": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappSubInfoText": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappTunMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappTunType": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -915,6 +1078,29 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subExpiredTemplate",
|
||||
"subHappAlwaysHwid",
|
||||
"subHappAutoConnect",
|
||||
"subHappAutoConnectType",
|
||||
"subHappAutoDetect",
|
||||
"subHappColorProfile",
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
"subHappPerAppList",
|
||||
"subHappPerAppMode",
|
||||
"subHappPingType",
|
||||
"subHappProviderId",
|
||||
"subHappSubExpire",
|
||||
"subHappSubExpireButtonLink",
|
||||
"subHappSubInfoButtonLink",
|
||||
"subHappSubInfoButtonText",
|
||||
"subHappSubInfoColor",
|
||||
"subHappSubInfoText",
|
||||
"subHappTunMode",
|
||||
"subHappTunType",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
|
||||
@@ -67,6 +67,29 @@ export interface AllSetting {
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subExpiredTemplate: string;
|
||||
subHappAlwaysHwid: boolean;
|
||||
subHappAutoConnect: boolean;
|
||||
subHappAutoConnectType: string;
|
||||
subHappAutoDetect: boolean;
|
||||
subHappColorProfile: string;
|
||||
subHappExcludeApns: boolean;
|
||||
subHappExcludeRoutes: string;
|
||||
subHappFallbackUrl: string;
|
||||
subHappNewUrl: string;
|
||||
subHappNoLimit: boolean;
|
||||
subHappNotificationExpire: boolean;
|
||||
subHappPerAppList: string;
|
||||
subHappPerAppMode: string;
|
||||
subHappPingType: string;
|
||||
subHappProviderId: string;
|
||||
subHappSubExpire: boolean;
|
||||
subHappSubExpireButtonLink: string;
|
||||
subHappSubInfoButtonLink: string;
|
||||
subHappSubInfoButtonText: string;
|
||||
subHappSubInfoColor: string;
|
||||
subHappSubInfoText: string;
|
||||
subHappTunMode: string;
|
||||
subHappTunType: string;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
@@ -185,6 +208,29 @@ export interface AllSettingView {
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subExpiredTemplate: string;
|
||||
subHappAlwaysHwid: boolean;
|
||||
subHappAutoConnect: boolean;
|
||||
subHappAutoConnectType: string;
|
||||
subHappAutoDetect: boolean;
|
||||
subHappColorProfile: string;
|
||||
subHappExcludeApns: boolean;
|
||||
subHappExcludeRoutes: string;
|
||||
subHappFallbackUrl: string;
|
||||
subHappNewUrl: string;
|
||||
subHappNoLimit: boolean;
|
||||
subHappNotificationExpire: boolean;
|
||||
subHappPerAppList: string;
|
||||
subHappPerAppMode: string;
|
||||
subHappPingType: string;
|
||||
subHappProviderId: string;
|
||||
subHappSubExpire: boolean;
|
||||
subHappSubExpireButtonLink: string;
|
||||
subHappSubInfoButtonLink: string;
|
||||
subHappSubInfoButtonText: string;
|
||||
subHappSubInfoColor: string;
|
||||
subHappSubInfoText: string;
|
||||
subHappTunMode: string;
|
||||
subHappTunType: string;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
|
||||
@@ -83,6 +83,29 @@ export const AllSettingSchema = z.object({
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subExpiredTemplate: z.string(),
|
||||
subHappAlwaysHwid: z.boolean(),
|
||||
subHappAutoConnect: z.boolean(),
|
||||
subHappAutoConnectType: z.string(),
|
||||
subHappAutoDetect: z.boolean(),
|
||||
subHappColorProfile: z.string(),
|
||||
subHappExcludeApns: z.boolean(),
|
||||
subHappExcludeRoutes: z.string(),
|
||||
subHappFallbackUrl: z.string(),
|
||||
subHappNewUrl: z.string(),
|
||||
subHappNoLimit: z.boolean(),
|
||||
subHappNotificationExpire: z.boolean(),
|
||||
subHappPerAppList: z.string(),
|
||||
subHappPerAppMode: z.string(),
|
||||
subHappPingType: z.string(),
|
||||
subHappProviderId: z.string(),
|
||||
subHappSubExpire: z.boolean(),
|
||||
subHappSubExpireButtonLink: z.string(),
|
||||
subHappSubInfoButtonLink: z.string(),
|
||||
subHappSubInfoButtonText: z.string(),
|
||||
subHappSubInfoColor: z.string(),
|
||||
subHappSubInfoText: z.string(),
|
||||
subHappTunMode: z.string(),
|
||||
subHappTunType: z.string(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
@@ -202,6 +225,29 @@ export const AllSettingViewSchema = z.object({
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subExpiredTemplate: z.string(),
|
||||
subHappAlwaysHwid: z.boolean(),
|
||||
subHappAutoConnect: z.boolean(),
|
||||
subHappAutoConnectType: z.string(),
|
||||
subHappAutoDetect: z.boolean(),
|
||||
subHappColorProfile: z.string(),
|
||||
subHappExcludeApns: z.boolean(),
|
||||
subHappExcludeRoutes: z.string(),
|
||||
subHappFallbackUrl: z.string(),
|
||||
subHappNewUrl: z.string(),
|
||||
subHappNoLimit: z.boolean(),
|
||||
subHappNotificationExpire: z.boolean(),
|
||||
subHappPerAppList: z.string(),
|
||||
subHappPerAppMode: z.string(),
|
||||
subHappPingType: z.string(),
|
||||
subHappProviderId: z.string(),
|
||||
subHappSubExpire: z.boolean(),
|
||||
subHappSubExpireButtonLink: z.string(),
|
||||
subHappSubInfoButtonLink: z.string(),
|
||||
subHappSubInfoButtonText: z.string(),
|
||||
subHappSubInfoColor: z.string(),
|
||||
subHappSubInfoText: z.string(),
|
||||
subHappTunMode: z.string(),
|
||||
subHappTunType: z.string(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
|
||||
@@ -73,6 +73,29 @@ export class AllSetting {
|
||||
subJsonObservatory = '';
|
||||
subThemeDir = '';
|
||||
subHideSettings = false;
|
||||
subHappAutoDetect = false;
|
||||
subHappProviderId = '';
|
||||
subHappNewUrl = '';
|
||||
subHappFallbackUrl = '';
|
||||
subHappSubInfoColor = 'blue';
|
||||
subHappSubInfoText = '';
|
||||
subHappSubInfoButtonText = '';
|
||||
subHappSubInfoButtonLink = '';
|
||||
subHappSubExpire = false;
|
||||
subHappSubExpireButtonLink = '';
|
||||
subHappNotificationExpire = false;
|
||||
subHappNoLimit = false;
|
||||
subHappAlwaysHwid = false;
|
||||
subHappTunMode = '';
|
||||
subHappTunType = '';
|
||||
subHappExcludeRoutes = '';
|
||||
subHappExcludeApns = false;
|
||||
subHappColorProfile = '';
|
||||
subHappPingType = '';
|
||||
subHappAutoConnect = false;
|
||||
subHappAutoConnectType = 'lowestdelay';
|
||||
subHappPerAppMode = 'off';
|
||||
subHappPerAppList = '';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd';
|
||||
import {
|
||||
BranchesOutlined,
|
||||
BuildOutlined,
|
||||
CloudSyncOutlined,
|
||||
DesktopOutlined,
|
||||
MobileOutlined,
|
||||
NotificationOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
|
||||
|
||||
interface HappSettingsContentProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
isMobile: boolean;
|
||||
remoteSourceBadge: (val: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function HappSettingsContent({
|
||||
allSetting,
|
||||
updateSetting,
|
||||
isMobile,
|
||||
remoteSourceBadge,
|
||||
}: HappSettingsContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [directDomains, setDirectDomains] = useState('');
|
||||
const [proxyDomains, setProxyDomains] = useState('');
|
||||
const [blockDomains, setBlockDomains] = useState('');
|
||||
const [directIPs, setDirectIPs] = useState('');
|
||||
const [proxyIPs, setProxyIPs] = useState('');
|
||||
const [blockIPs, setBlockIPs] = useState('');
|
||||
|
||||
const applyPreset = () => {
|
||||
const payload = buildHappPresetDeeplink(selectedPreset);
|
||||
if (payload) {
|
||||
updateSetting({ subRoutingRules: payload });
|
||||
message.success(t('pages.settings.subHappPresetApplied'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildDeeplink = () => {
|
||||
interface FieldRule {
|
||||
type: string;
|
||||
outboundTag: string;
|
||||
domain?: string[];
|
||||
ip?: string[];
|
||||
network?: string;
|
||||
}
|
||||
const rules: FieldRule[] = [];
|
||||
|
||||
const bDom = parseList(blockDomains);
|
||||
const bIp = parseList(blockIPs);
|
||||
if (bDom.length > 0 || bIp.length > 0) {
|
||||
rules.push({
|
||||
type: 'field',
|
||||
outboundTag: 'block',
|
||||
...(bDom.length > 0 ? { domain: bDom } : {}),
|
||||
...(bIp.length > 0 ? { ip: bIp } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const dDom = parseList(directDomains);
|
||||
const dIp = parseList(directIPs);
|
||||
if (dDom.length > 0 || dIp.length > 0) {
|
||||
rules.push({
|
||||
type: 'field',
|
||||
outboundTag: 'direct',
|
||||
...(dDom.length > 0 ? { domain: dDom } : {}),
|
||||
...(dIp.length > 0 ? { ip: dIp } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const pDom = parseList(proxyDomains);
|
||||
const pIp = parseList(proxyIPs);
|
||||
if (pDom.length > 0 || pIp.length > 0) {
|
||||
rules.push({
|
||||
type: 'field',
|
||||
outboundTag: 'proxy',
|
||||
...(pDom.length > 0 ? { domain: pDom } : {}),
|
||||
...(pIp.length > 0 ? { ip: pIp } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
rules.push({
|
||||
type: 'field',
|
||||
outboundTag: 'proxy',
|
||||
network: 'tcp,udp',
|
||||
});
|
||||
|
||||
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify({ rules }));
|
||||
updateSetting({ subRoutingRules: deeplink });
|
||||
setIsModalOpen(false);
|
||||
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
type="card"
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'routing',
|
||||
label: (
|
||||
<span>
|
||||
<BranchesOutlined /> {!isMobile && t('pages.settings.subHappGroupRouting')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subEnableRouting')}
|
||||
description={t('pages.settings.subEnableRoutingDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subEnableRouting}
|
||||
onChange={(v) => updateSetting({ subEnableRouting: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappPresets')}
|
||||
description={t('pages.settings.subHappPresetsDesc')}
|
||||
>
|
||||
<Space orientation="horizontal" style={{ width: '100%' }}>
|
||||
<Select
|
||||
value={selectedPreset}
|
||||
style={{ minWidth: 170 }}
|
||||
onChange={setSelectedPreset}
|
||||
options={[
|
||||
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
|
||||
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
|
||||
{ value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
|
||||
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') },
|
||||
{ value: 'off', label: t('pages.settings.subHappPresetOff') },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" onClick={applyPreset}>
|
||||
{t('pages.settings.subHappPresets')}
|
||||
</Button>
|
||||
</Space>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappVisualBuilder')}
|
||||
description={t('pages.settings.subHappVisualBuilderDesc')}
|
||||
>
|
||||
<Button icon={<BuildOutlined />} onClick={() => setIsModalOpen(true)}>
|
||||
{t('pages.settings.subHappVisualBuilder')}
|
||||
</Button>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subRoutingRules')}
|
||||
badge={remoteSourceBadge(allSetting.subRoutingRules)}
|
||||
description={t('pages.settings.subRoutingRulesDesc')}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={allSetting.subRoutingRules}
|
||||
rows={4}
|
||||
placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
|
||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappNoLimit')}
|
||||
description={t('pages.settings.subHappNoLimitDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappNoLimit}
|
||||
onChange={(v) => updateSetting({ subHappNoLimit: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHideSettings')}
|
||||
description={t('pages.settings.subHideSettingsDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHideSettings}
|
||||
onChange={(v) => updateSetting({ subHideSettings: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'banners',
|
||||
label: (
|
||||
<span>
|
||||
<NotificationOutlined /> {!isMobile && t('pages.settings.subHappGroupBanners')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappSubInfoText')}
|
||||
description={t('pages.settings.subHappSubInfoTextDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappSubInfoText}
|
||||
maxLength={200}
|
||||
placeholder="Welcome to our high-speed network!"
|
||||
onChange={(e) => updateSetting({ subHappSubInfoText: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappSubInfoColor')}
|
||||
description={t('pages.settings.subHappSubInfoColorDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappSubInfoColor || 'blue'}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappSubInfoColor: v })}
|
||||
options={[
|
||||
{ value: 'blue', label: t('pages.settings.subHappColorBlue') },
|
||||
{ value: 'green', label: t('pages.settings.subHappColorGreen') },
|
||||
{ value: 'red', label: t('pages.settings.subHappColorRed') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappSubInfoButtonText')}
|
||||
description={t('pages.settings.subHappSubInfoButtonTextDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappSubInfoButtonText}
|
||||
maxLength={25}
|
||||
placeholder="Support Channel"
|
||||
onChange={(e) => updateSetting({ subHappSubInfoButtonText: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappSubInfoButtonLink')}
|
||||
description={t('pages.settings.subHappSubInfoButtonLinkDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappSubInfoButtonLink}
|
||||
placeholder="https://t.me/your_channel"
|
||||
onChange={(e) => updateSetting({ subHappSubInfoButtonLink: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappSubExpire')}
|
||||
description={t('pages.settings.subHappSubExpireDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappSubExpire}
|
||||
onChange={(v) => updateSetting({ subHappSubExpire: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappSubExpireButtonLink')}
|
||||
description={t('pages.settings.subHappSubExpireButtonLinkDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappSubExpireButtonLink}
|
||||
placeholder="https://example.com/renew"
|
||||
onChange={(e) => updateSetting({ subHappSubExpireButtonLink: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappNotificationExpire')}
|
||||
description={t('pages.settings.subHappNotificationExpireDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappNotificationExpire}
|
||||
onChange={(v) => updateSetting({ subHappNotificationExpire: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'network',
|
||||
label: (
|
||||
<span>
|
||||
<ThunderboltOutlined /> {!isMobile && t('pages.settings.subHappGroupNetwork')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappTunMode')}
|
||||
description={t('pages.settings.subHappTunModeDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappTunMode}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappTunMode: v })}
|
||||
options={[
|
||||
// happ.su documents tun-mode as system|gvisor only, so
|
||||
// Default is the unset state rather than a third value.
|
||||
{ value: '', label: t('pages.settings.subHappTunModeDefault') },
|
||||
{ value: 'system', label: t('pages.settings.subHappTunModeSystem') },
|
||||
{ value: 'gvisor', label: t('pages.settings.subHappTunModeGvisor') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappTunType')}
|
||||
description={t('pages.settings.subHappTunTypeDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappTunType || 'default'}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappTunType: v })}
|
||||
options={[
|
||||
{ value: 'singbox', label: t('pages.settings.subHappTunTypeSingbox') },
|
||||
{ value: 'tun2proxy', label: t('pages.settings.subHappTunTypeTun2proxy') },
|
||||
{ value: 'default', label: t('pages.settings.subHappTunTypeDefault') },
|
||||
{ value: 'xray', label: t('pages.settings.subHappTunTypeXray') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappExcludeRoutes')}
|
||||
description={t('pages.settings.subHappExcludeRoutesDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappExcludeRoutes}
|
||||
placeholder="192.168.0.0/16, 10.0.0.0/8"
|
||||
onChange={(e) => updateSetting({ subHappExcludeRoutes: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappExcludeApns')}
|
||||
description={t('pages.settings.subHappExcludeApnsDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappExcludeApns}
|
||||
onChange={(v) => updateSetting({ subHappExcludeApns: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappPingType')}
|
||||
description={t('pages.settings.subHappPingTypeDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappPingType || 'proxy'}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappPingType: v })}
|
||||
options={[
|
||||
{ value: 'proxy', label: t('pages.settings.subHappPingProxy') },
|
||||
{ value: 'proxy-head', label: t('pages.settings.subHappPingProxyHead') },
|
||||
{ value: 'tcp', label: t('pages.settings.subHappPingTcp') },
|
||||
{ value: 'icmp', label: t('pages.settings.subHappPingIcmp') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAutoConnect')}
|
||||
description={t('pages.settings.subHappAutoConnectDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappAutoConnect}
|
||||
onChange={(v) => updateSetting({ subHappAutoConnect: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAutoConnectType')}
|
||||
description={t('pages.settings.subHappAutoConnectTypeDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappAutoConnectType || 'lowestdelay'}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappAutoConnectType: v })}
|
||||
options={[
|
||||
{
|
||||
value: 'lowestdelay',
|
||||
label: t('pages.settings.subHappAutoConnectLowestDelay'),
|
||||
},
|
||||
{ value: 'lastused', label: t('pages.settings.subHappAutoConnectLastUsed') },
|
||||
{ value: 'random', label: t('pages.settings.subHappAutoConnectRandom') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'themes',
|
||||
label: (
|
||||
<span>
|
||||
<DesktopOutlined /> {!isMobile && t('pages.settings.subHappGroupThemes')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappColorProfile')}
|
||||
description={t('pages.settings.subHappColorProfileDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappColorProfile}
|
||||
placeholder="default, violet, turquoise, cyberpunk, or custom JSON"
|
||||
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'failover',
|
||||
label: (
|
||||
<span>
|
||||
<CloudSyncOutlined /> {!isMobile && t('pages.settings.subHappGroupFailover')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAutoDetect')}
|
||||
description={t('pages.settings.subHappAutoDetectDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappAutoDetect}
|
||||
onChange={(v) => updateSetting({ subHappAutoDetect: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappProviderId')}
|
||||
description={t('pages.settings.subHappProviderIdDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappProviderId}
|
||||
placeholder="my-happ-provider"
|
||||
onChange={(e) => updateSetting({ subHappProviderId: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappNewUrl')}
|
||||
description={t('pages.settings.subHappNewUrlDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappNewUrl}
|
||||
placeholder="https://new-domain.com/sub/..."
|
||||
onChange={(e) => updateSetting({ subHappNewUrl: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappFallbackUrl')}
|
||||
description={t('pages.settings.subHappFallbackUrlDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.subHappFallbackUrl}
|
||||
placeholder="https://backup-domain.com/sub/..."
|
||||
onChange={(e) => updateSetting({ subHappFallbackUrl: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAlwaysHwid')}
|
||||
description={t('pages.settings.subHappAlwaysHwidDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHappAlwaysHwid}
|
||||
onChange={(v) => updateSetting({ subHappAlwaysHwid: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'android',
|
||||
label: (
|
||||
<span>
|
||||
<MobileOutlined /> {!isMobile && t('pages.settings.subHappGroupAndroid')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappPerAppMode')}
|
||||
description={t('pages.settings.subHappPerAppModeDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappPerAppMode || 'off'}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappPerAppMode: v })}
|
||||
options={[
|
||||
{ value: 'off', label: t('pages.settings.subHappPerAppOff') },
|
||||
{ value: 'on', label: t('pages.settings.subHappPerAppOn') },
|
||||
{ value: 'bypass', label: t('pages.settings.subHappPerAppBypass') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappPerAppList')}
|
||||
description={t('pages.settings.subHappPerAppListDesc')}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={allSetting.subHappPerAppList}
|
||||
rows={4}
|
||||
placeholder="org.telegram.messenger, com.google.android.youtube"
|
||||
onChange={(e) => updateSetting({ subHappPerAppList: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('pages.settings.subHappModalTitle')}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onOk={handleBuildDeeplink}
|
||||
okText={t('pages.settings.subHappBuildDeeplink')}
|
||||
width={650}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappDirectDomains')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={directDomains}
|
||||
placeholder="domain:ir, domain:cn, example.local"
|
||||
onChange={(e) => setDirectDomains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappProxyDomains')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={proxyDomains}
|
||||
placeholder="geosite:google, youtube.com"
|
||||
onChange={(e) => setProxyDomains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappBlockDomains')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={blockDomains}
|
||||
placeholder="geosite:category-ads-all, analytics.google.com"
|
||||
onChange={(e) => setBlockDomains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappDirectIPs')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={directIPs}
|
||||
placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
|
||||
onChange={(e) => setDirectIPs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappProxyIPs')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={proxyIPs}
|
||||
placeholder="1.1.1.1/32, 8.8.8.8/32"
|
||||
onChange={(e) => setProxyIPs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappBlockIPs')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={blockIPs}
|
||||
placeholder="geoip:phishing, 0.0.0.0/8"
|
||||
onChange={(e) => setBlockIPs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { RemarkTemplateField } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
import HappSettingsContent from './HappSettingsContent';
|
||||
|
||||
interface SubscriptionGeneralTabProps {
|
||||
allSetting: AllSetting;
|
||||
@@ -344,40 +345,12 @@ export default function SubscriptionGeneralTab({
|
||||
key: '5',
|
||||
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subEnableRouting')}
|
||||
description={t('pages.settings.subEnableRoutingDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subEnableRouting}
|
||||
onChange={(v) => updateSetting({ subEnableRouting: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subRoutingRules')}
|
||||
badge={remoteSourceBadge(allSetting.subRoutingRules)}
|
||||
description={t('pages.settings.subRoutingRulesDesc')}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={allSetting.subRoutingRules}
|
||||
placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
|
||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHideSettings')}
|
||||
description={t('pages.settings.subHideSettingsDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subHideSettings}
|
||||
onChange={(v) => updateSetting({ subHideSettings: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={updateSetting}
|
||||
isMobile={isMobile}
|
||||
remoteSourceBadge={remoteSourceBadge}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Encode UTF-8 string to standard Base64 safe for browser environments.
|
||||
export function toBase64Utf8(str: string): string {
|
||||
return btoa(
|
||||
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
|
||||
String.fromCharCode(Number(`0x${p1}`)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Splits multiline or comma-separated string into clean unique token arrays.
|
||||
export function parseList(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Build standard Happ routing deeplink or special state for curated presets.
|
||||
export function buildHappPresetDeeplink(preset: string): string {
|
||||
switch (preset) {
|
||||
case 'off':
|
||||
return 'happ://routing/off';
|
||||
case 'iran-bypass':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
rules: [
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'direct',
|
||||
domain: ['domain:ir', 'regexp:.*\\.ir$'],
|
||||
ip: ['geoip:ir', 'geoip:private'],
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'block',
|
||||
domain: ['geosite:category-ads-all'],
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'proxy',
|
||||
network: 'tcp,udp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
);
|
||||
case 'china-direct':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
rules: [
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'direct',
|
||||
domain: ['domain:cn', 'geosite:cn'],
|
||||
ip: ['geoip:cn', 'geoip:private'],
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'block',
|
||||
domain: ['geosite:category-ads-all'],
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'proxy',
|
||||
network: 'tcp,udp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
);
|
||||
case 'adblock':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
rules: [
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'block',
|
||||
domain: ['geosite:category-ads-all'],
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'direct',
|
||||
ip: ['geoip:private'],
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'proxy',
|
||||
network: 'tcp,udp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
);
|
||||
case 'global':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
rules: [
|
||||
{
|
||||
type: 'field',
|
||||
outboundTag: 'proxy',
|
||||
network: 'tcp,udp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,29 @@ export const AllSettingSchema = z
|
||||
subJsonFinalMask: z.string().optional(),
|
||||
subJsonObservatory: z.string().optional(),
|
||||
subHideSettings: z.boolean().optional(),
|
||||
subHappAutoDetect: z.boolean().optional(),
|
||||
subHappProviderId: z.string().optional(),
|
||||
subHappNewUrl: z.string().optional(),
|
||||
subHappFallbackUrl: z.string().optional(),
|
||||
subHappSubInfoColor: z.string().optional(),
|
||||
subHappSubInfoText: z.string().optional(),
|
||||
subHappSubInfoButtonText: z.string().optional(),
|
||||
subHappSubInfoButtonLink: z.string().optional(),
|
||||
subHappSubExpire: z.boolean().optional(),
|
||||
subHappSubExpireButtonLink: z.string().optional(),
|
||||
subHappNotificationExpire: z.boolean().optional(),
|
||||
subHappNoLimit: z.boolean().optional(),
|
||||
subHappAlwaysHwid: z.boolean().optional(),
|
||||
subHappTunMode: z.string().optional(),
|
||||
subHappTunType: z.string().optional(),
|
||||
subHappExcludeRoutes: z.string().optional(),
|
||||
subHappExcludeApns: z.boolean().optional(),
|
||||
subHappColorProfile: z.string().optional(),
|
||||
subHappPingType: z.string().optional(),
|
||||
subHappAutoConnect: z.boolean().optional(),
|
||||
subHappAutoConnectType: z.string().optional(),
|
||||
subHappPerAppMode: z.string().optional(),
|
||||
subHappPerAppList: z.string().optional(),
|
||||
timeLocation: z.string().optional(),
|
||||
ldapEnable: z.boolean().optional(),
|
||||
ldapHost: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
describe('Happ presets and helpers', () => {
|
||||
it('correctly parses comma and newline separated lists', () => {
|
||||
const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
|
||||
const parsed = parseList(raw);
|
||||
expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
|
||||
});
|
||||
|
||||
it('generates valid happ://routing/off for off preset', () => {
|
||||
const link = buildHappPresetDeeplink('off');
|
||||
expect(link).toBe('happ://routing/off');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for iran-bypass preset', () => {
|
||||
const link = buildHappPresetDeeplink('iran-bypass');
|
||||
expect(link.startsWith('happ://routing/onadd/')).toBe(true);
|
||||
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed).toHaveProperty('rules');
|
||||
expect(Array.isArray(parsed.rules)).toBe(true);
|
||||
|
||||
const directRule = parsed.rules.find(
|
||||
(r: { outboundTag: string }) => r.outboundTag === 'direct',
|
||||
);
|
||||
expect(directRule).toBeDefined();
|
||||
expect(directRule.domain).toContain('domain:ir');
|
||||
expect(directRule.ip).toContain('geoip:ir');
|
||||
|
||||
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
|
||||
expect(blockRule).toBeDefined();
|
||||
expect(blockRule.domain).toContain('geosite:category-ads-all');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for china-direct preset', () => {
|
||||
const link = buildHappPresetDeeplink('china-direct');
|
||||
expect(link.startsWith('happ://routing/onadd/')).toBe(true);
|
||||
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
const directRule = parsed.rules.find(
|
||||
(r: { outboundTag: string }) => r.outboundTag === 'direct',
|
||||
);
|
||||
expect(directRule.domain).toContain('domain:cn');
|
||||
expect(directRule.ip).toContain('geoip:cn');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for adblock preset', () => {
|
||||
const link = buildHappPresetDeeplink('adblock');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
|
||||
expect(blockRule.domain).toContain('geosite:category-ads-all');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for global preset', () => {
|
||||
const link = buildHappPresetDeeplink('global');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.rules[0].outboundTag).toBe('proxy');
|
||||
expect(parsed.rules[0].network).toBe('tcp,udp');
|
||||
});
|
||||
|
||||
it('encodes unicode properly via toBase64Utf8', () => {
|
||||
const text = 'فیلترشکن و روتینگ';
|
||||
const b64 = toBase64Utf8(text);
|
||||
const decoded = decodeURIComponent(
|
||||
Array.prototype.map
|
||||
.call(atob(b64), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
);
|
||||
expect(decoded).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function openTab(name: string) {
|
||||
const tab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find((t) =>
|
||||
(t.textContent ?? '').includes(name),
|
||||
);
|
||||
if (!tab) throw new Error(`tab '${name}' not found`);
|
||||
fireEvent.click(tab);
|
||||
}
|
||||
|
||||
function selectFor(title: string): HTMLElement {
|
||||
const row = Array.from(document.querySelectorAll('.ant-select')).find((s) =>
|
||||
(s.closest('li,div[class*="setting"]')?.textContent ?? '').includes(title),
|
||||
);
|
||||
if (!row) throw new Error(`select for '${title}' not found`);
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
function clickOption(text: string) {
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
(o) => (o.textContent ?? '').trim() === text,
|
||||
);
|
||||
if (!option) throw new Error(`option '${text}' not found`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
describe('Happ TUN Mode select', () => {
|
||||
// happ.su documents tun-mode as system|gvisor only, so the Default entry has
|
||||
// to mean "send no header", the same state a fresh panel ships with.
|
||||
it('stores the empty value for Default so no Tun-Mode header is emitted', () => {
|
||||
const updateSetting = vi.fn();
|
||||
const allSetting = new AllSetting();
|
||||
allSetting.subHappTunMode = 'gvisor';
|
||||
|
||||
renderWithProviders(
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={updateSetting}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>,
|
||||
);
|
||||
|
||||
openTab('Network');
|
||||
const select = selectFor('TUN Mode');
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
clickOption('Default');
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ subHappTunMode: '' });
|
||||
});
|
||||
|
||||
it('labels the unset state Default rather than leaving the control blank', () => {
|
||||
renderWithProviders(
|
||||
<HappSettingsContent
|
||||
allSetting={new AllSetting()}
|
||||
updateSetting={vi.fn()}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>,
|
||||
);
|
||||
|
||||
openTab('Network');
|
||||
const select = selectFor('TUN Mode');
|
||||
expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user