From 5e15120cecad3250ee6ff5103fb10e23acf74a8f Mon Sep 17 00:00:00 2001 From: NgaiYeanCoi Date: Sun, 27 Sep 2026 04:57:01 +0800 Subject: [PATCH] feat(happ): add routing editor, optional ad blocking, and LAN bypass preset (#6545) * feat(happ): make ad blocking optional in routing presets Add an independent AdBlock toggle for Iran, China, and global presets, applied only when generating routing rules. Update the China preset to Bypass-CN and cover preset behavior and localized controls. * feat: add visual routing editor with JSON support and localization updates - Implemented a new modal for editing routing profiles with basic and advanced tabs. - Added functionality to load, parse, and generate routing profiles in JSON format. - Enhanced user experience with validation and error handling for JSON input. - Updated translations for Russian, Turkish, Ukrainian, Vietnamese, Chinese (Simplified and Traditional) to include new routing editor terms. - Created helper functions for managing routing profiles and generating deep links. - Added unit tests for routing editor functionalities and JSON handling. * feat: update routing editor to preserve null lists in profiles and enhance validation messages --- .../pages/settings/HappRoutingEditorModal.tsx | 204 +++++++++++ .../pages/settings/HappSettingsContent.tsx | 132 ++----- frontend/src/pages/settings/happPresets.ts | 98 ++++-- .../src/pages/settings/happRoutingEditor.ts | 90 +++++ frontend/src/schemas/happRouting.ts | 16 + frontend/src/test/happ-presets.test.ts | 96 ++++-- .../test/happ-routing-editor-helpers.test.ts | 156 +++++++++ .../src/test/happ-routing-editor.test.tsx | 322 ++++++++++++++++++ .../src/test/happ-settings-presets.test.tsx | 211 ++++++++++++ internal/web/translation/ar-EG.json | 19 +- internal/web/translation/en-US.json | 19 +- internal/web/translation/es-ES.json | 19 +- internal/web/translation/fa-IR.json | 19 +- internal/web/translation/id-ID.json | 19 +- internal/web/translation/ja-JP.json | 19 +- internal/web/translation/pt-BR.json | 19 +- internal/web/translation/ru-RU.json | 19 +- internal/web/translation/tr-TR.json | 19 +- internal/web/translation/uk-UA.json | 19 +- internal/web/translation/vi-VN.json | 19 +- internal/web/translation/zh-CN.json | 19 +- internal/web/translation/zh-TW.json | 19 +- 22 files changed, 1363 insertions(+), 209 deletions(-) create mode 100644 frontend/src/pages/settings/HappRoutingEditorModal.tsx create mode 100644 frontend/src/pages/settings/happRoutingEditor.ts create mode 100644 frontend/src/schemas/happRouting.ts create mode 100644 frontend/src/test/happ-routing-editor-helpers.test.ts create mode 100644 frontend/src/test/happ-routing-editor.test.tsx create mode 100644 frontend/src/test/happ-settings-presets.test.tsx diff --git a/frontend/src/pages/settings/HappRoutingEditorModal.tsx b/frontend/src/pages/settings/HappRoutingEditorModal.tsx new file mode 100644 index 000000000..60aa8c493 --- /dev/null +++ b/frontend/src/pages/settings/HappRoutingEditorModal.tsx @@ -0,0 +1,204 @@ +import { useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Input, Modal, Space, Tabs, Typography } from 'antd'; + +import JsonEditor from '@/components/form/JsonEditor'; +import type { HappRoutingProfile } from '@/schemas/happRouting'; +import { + buildHappRoutingDeeplink, + loadHappRouting, + parseHappRoutingJson, + parseHappRoutingList, + type HappRoutingListKey, +} from './happRoutingEditor'; + +interface HappRoutingEditorModalProps { + input: string; + onCancel: () => void; + onGenerate: (deeplink: string) => void; +} + +const basicFields: { key: HappRoutingListKey; label: string; placeholder: string }[] = [ + { + key: 'DirectSites', + label: 'subHappDirectDomains', + placeholder: 'domain:ir\ndomain:cn\nexample.local', + }, + { + key: 'ProxySites', + label: 'subHappProxyDomains', + placeholder: 'geosite:google\nyoutube.com', + }, + { + key: 'BlockSites', + label: 'subHappBlockDomains', + placeholder: 'geosite:category-ads-all\nanalytics.google.com', + }, + { + key: 'DirectIp', + label: 'subHappDirectIPs', + placeholder: 'geoip:ir\n192.168.0.0/16\n10.0.0.0/8', + }, + { + key: 'ProxyIp', + label: 'subHappProxyIPs', + placeholder: '1.1.1.1/32\n8.8.8.8/32', + }, + { + key: 'BlockIp', + label: 'subHappBlockIPs', + placeholder: 'geoip:phishing\n0.0.0.0/8', + }, +]; + +type BasicBuffers = Record; + +function basicBuffers(profile: HappRoutingProfile | null): BasicBuffers { + return { + DirectSites: profile?.DirectSites?.join('\n') ?? '', + ProxySites: profile?.ProxySites?.join('\n') ?? '', + BlockSites: profile?.BlockSites?.join('\n') ?? '', + DirectIp: profile?.DirectIp?.join('\n') ?? '', + ProxyIp: profile?.ProxyIp?.join('\n') ?? '', + BlockIp: profile?.BlockIp?.join('\n') ?? '', + }; +} + +function mergeBasicRules(profile: HappRoutingProfile, buffers: BasicBuffers): HappRoutingProfile { + const next = { ...profile }; + // Only replace edited lists; absent lists and all other profile fields must survive unchanged. + for (const { key } of basicFields) { + if (buffers[key] !== (profile[key]?.join('\n') ?? '')) { + next[key] = parseHappRoutingList(buffers[key]); + } + } + return next; +} + +const loadErrorKeys = { + off: 'subHappEditorLoadOff', + remote: 'subHappEditorLoadRemote', + invalid: 'subHappEditorLoadInvalid', +}; + +export default function HappRoutingEditorModal({ + input, + onCancel, + onGenerate, +}: HappRoutingEditorModalProps) { + const { t } = useTranslation(); + const fieldId = useId(); + const [loaded] = useState(() => loadHappRouting(input)); + const [profile, setProfile] = useState(() => (loaded.success ? loaded.profile : null)); + const [activeTab, setActiveTab] = useState('basic'); + // Keep raw buffers while typing so trailing newlines and temporarily invalid JSON are not lost. + const [buffers, setBuffers] = useState(() => basicBuffers(profile)); + const [jsonText, setJsonText] = useState(() => (profile ? JSON.stringify(profile, null, 2) : '')); + const advancedProfile = activeTab === 'advanced' ? parseHappRoutingJson(jsonText) : null; + const invalidJson = activeTab === 'advanced' && advancedProfile === null; + + const switchTab = (nextTab: string) => { + if (!profile || nextTab === activeTab) return; + if (nextTab === 'advanced') { + const next = mergeBasicRules(profile, buffers); + setProfile(next); + setJsonText(JSON.stringify(next, null, 2)); + } else { + if (!advancedProfile) return; + setProfile(advancedProfile); + setBuffers(basicBuffers(advancedProfile)); + } + setActiveTab(nextTab); + }; + + const generate = () => { + if (!loaded.success || !profile) return; + const next = activeTab === 'advanced' ? advancedProfile : mergeBasicRules(profile, buffers); + if (next) onGenerate(buildHappRoutingDeeplink(next, loaded.mode)); + }; + + return ( + + + {!loaded.success ? ( + + ) : loaded.isNew ? ( + + ) : null} + {loaded.success ? ( + + + {t('pages.settings.subHappEditorListHint')} + + {basicFields.map(({ key, label, placeholder }) => ( +
+ + + setBuffers((previous) => ({ ...previous, [key]: event.target.value })) + } + /> +
+ ))} +
+ ), + }, + { + key: 'advanced', + label: t('pages.settings.subHappEditorAdvanced'), + children: ( + + + {t('pages.settings.subHappEditorAdvancedHint')} + + {invalidJson ? ( + + ) : null} + + + ), + }, + ]} + /> + ) : null} + +
+ ); +} diff --git a/frontend/src/pages/settings/HappSettingsContent.tsx b/frontend/src/pages/settings/HappSettingsContent.tsx index 6434330ac..459cc2fa6 100644 --- a/frontend/src/pages/settings/HappSettingsContent.tsx +++ b/frontend/src/pages/settings/HappSettingsContent.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd'; +import { Button, Input, Select, Space, Switch, Tabs, message } from 'antd'; import { BranchesOutlined, BuildOutlined, @@ -13,7 +13,8 @@ import { } from '@ant-design/icons'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; -import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets'; +import { buildHappPresetDeeplink } from './happPresets'; +import HappRoutingEditorModal from './HappRoutingEditorModal'; import { catTabLabel } from './catTabLabel'; interface HappSettingsContentProps { @@ -32,38 +33,20 @@ export default function HappSettingsContent({ defaultActiveTab = 'routing', }: HappSettingsContentProps) { const { t } = useTranslation(); + // Generator choices stay local until Apply updates the draft; page Save persists it. const [selectedPreset, setSelectedPreset] = useState('iran-bypass'); + const [includeAdblock, setIncludeAdblock] = useState(false); 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); + const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock); if (payload) { updateSetting({ subRoutingRules: payload }); message.success(t('pages.settings.subHappPresetApplied')); } }; - const handleBuildDeeplink = () => { - const profile = { - Name: 'Custom Rules', - GlobalProxy: 'true', - DirectSites: parseList(directDomains), - DirectIp: parseList(directIPs), - ProxySites: parseList(proxyDomains), - ProxyIp: parseList(proxyIPs), - BlockSites: parseList(blockDomains), - BlockIp: parseList(blockIPs), - DomainStrategy: 'IPIfNonMatch', - }; - - const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile)); + const handleBuildDeeplink = (deeplink: string) => { updateSetting({ subRoutingRules: deeplink }); setIsModalOpen(false); message.success(t('pages.settings.subHappDeeplinkGenerated')); @@ -112,21 +95,31 @@ export default function HappSettingsContent({ title={t('pages.settings.subHappPresets')} description={t('pages.settings.subHappPresetsDesc')} > - +