mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 09:42:10 +03:00
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
This commit is contained in:
@@ -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<HappRoutingListKey, string>;
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={t('pages.settings.subHappModalTitle')}
|
||||
open
|
||||
onCancel={onCancel}
|
||||
onOk={generate}
|
||||
okText={t('pages.settings.subHappBuildDeeplink')}
|
||||
okButtonProps={{ disabled: !loaded.success || invalidJson }}
|
||||
width={650}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
|
||||
{!loaded.success ? (
|
||||
<Alert type="error" showIcon title={t(`pages.settings.${loadErrorKeys[loaded.error]}`)} />
|
||||
) : loaded.isNew ? (
|
||||
<Alert type="info" showIcon title={t('pages.settings.subHappEditorNew')} />
|
||||
) : null}
|
||||
{loaded.success ? (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={switchTab}
|
||||
destroyOnHidden
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: t('pages.settings.subHappEditorBasic'),
|
||||
disabled: invalidJson,
|
||||
children: (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Typography.Text type="secondary" id={`${fieldId}-hint`}>
|
||||
{t('pages.settings.subHappEditorListHint')}
|
||||
</Typography.Text>
|
||||
{basicFields.map(({ key, label, placeholder }) => (
|
||||
<div key={key}>
|
||||
<label
|
||||
htmlFor={`${fieldId}-${key}`}
|
||||
style={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
|
||||
>
|
||||
{t(`pages.settings.${label}`)}
|
||||
</label>
|
||||
<Input.TextArea
|
||||
id={`${fieldId}-${key}`}
|
||||
aria-describedby={`${fieldId}-hint`}
|
||||
rows={2}
|
||||
value={buffers[key]}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) =>
|
||||
setBuffers((previous) => ({ ...previous, [key]: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('pages.settings.subHappEditorAdvanced'),
|
||||
children: (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.subHappEditorAdvancedHint')}
|
||||
</Typography.Text>
|
||||
{invalidJson ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.settings.subHappEditorInvalidJson')}
|
||||
/>
|
||||
) : null}
|
||||
<JsonEditor
|
||||
value={jsonText}
|
||||
onChange={setJsonText}
|
||||
minHeight="320px"
|
||||
maxHeight="50vh"
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string>('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')}
|
||||
>
|
||||
<Space orientation="horizontal" style={{ width: '100%' }}>
|
||||
<Space orientation="horizontal" wrap style={{ width: '100%' }}>
|
||||
<Select
|
||||
aria-label={t('pages.settings.subHappPresets')}
|
||||
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: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
|
||||
{ value: 'off', label: t('pages.settings.subHappPresetOff') },
|
||||
]}
|
||||
/>
|
||||
<Space size="small">
|
||||
<Switch
|
||||
aria-label={t('pages.settings.subHappIncludeAdblock')}
|
||||
checked={includeAdblock}
|
||||
disabled={selectedPreset === 'off'}
|
||||
onChange={setIncludeAdblock}
|
||||
/>
|
||||
<span>{t('pages.settings.subHappIncludeAdblock')}</span>
|
||||
</Space>
|
||||
<Button type="primary" onClick={applyPreset}>
|
||||
{t('pages.settings.subHappPresets')}
|
||||
{t('pages.settings.subHappApplyPreset')}
|
||||
</Button>
|
||||
</Space>
|
||||
</SettingListItem>
|
||||
@@ -589,83 +582,14 @@ export default function HappSettingsContent({
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('pages.settings.subHappModalTitle')}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onOk={handleBuildDeeplink}
|
||||
okText={t('pages.settings.subHappBuildDeeplink')}
|
||||
width={650}
|
||||
>
|
||||
<Space orientation="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>
|
||||
{/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
|
||||
{isModalOpen ? (
|
||||
<HappRoutingEditorModal
|
||||
input={allSetting.subRoutingRules}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onGenerate={handleBuildDeeplink}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,11 @@ export function toBase64Utf8(str: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
export function buildHappPresetDeeplink(preset: string, includeAdblock = false): string {
|
||||
// Ad blocking is opt-in for each generated profile, independent of the base routing rules.
|
||||
const blockSites = includeAdblock ? ['geosite:category-ads-all'] : [];
|
||||
|
||||
switch (preset) {
|
||||
case 'off':
|
||||
return 'happ://routing/off';
|
||||
@@ -27,9 +22,20 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
JSON.stringify({
|
||||
Name: 'Iran Bypass',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
|
||||
DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
DirectSites: ['geosite:private', 'domain:ir', 'geosite:category-ir'],
|
||||
DirectIp: [
|
||||
'geoip:ir',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
@@ -42,32 +48,38 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'China Direct',
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
BlockIp: [],
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
}),
|
||||
)
|
||||
);
|
||||
case 'adblock':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'AdBlock',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
}),
|
||||
)
|
||||
);
|
||||
@@ -80,7 +92,25 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
BlockSites: [],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'AsIs',
|
||||
}),
|
||||
)
|
||||
);
|
||||
// LAN bypass has its own identity so applying it does not overwrite the Global profile.
|
||||
case 'lan-bypass':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'Global Bypass Local Network',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:private'],
|
||||
DirectIp: ['geoip:private'],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { HappRoutingProfileSchema, type HappRoutingProfile } from '@/schemas/happRouting';
|
||||
import { toBase64Utf8 } from './happPresets';
|
||||
import { isRemoteRoutingSource } from './subscriptionShared';
|
||||
|
||||
export type HappRoutingMode = 'add' | 'onadd';
|
||||
export type HappRoutingListKey =
|
||||
| 'DirectSites'
|
||||
| 'DirectIp'
|
||||
| 'ProxySites'
|
||||
| 'ProxyIp'
|
||||
| 'BlockSites'
|
||||
| 'BlockIp';
|
||||
|
||||
export type HappRoutingLoadResult =
|
||||
| { success: true; profile: HappRoutingProfile; mode: HappRoutingMode; isNew: boolean }
|
||||
| { success: false; error: 'off' | 'remote' | 'invalid' };
|
||||
|
||||
export function parseHappRoutingJson(input: string): HappRoutingProfile | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(input);
|
||||
const result = HappRoutingProfileSchema.safeParse(value);
|
||||
// Keep the validated input object; schema output can discard some unknown extension keys.
|
||||
return result.success ? (value as HappRoutingProfile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadHappRouting(input: string): HappRoutingLoadResult {
|
||||
const source = input.trim();
|
||||
if (!source) {
|
||||
return {
|
||||
success: true,
|
||||
profile: {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
},
|
||||
mode: 'onadd',
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
if (source === 'happ://routing/off') return { success: false, error: 'off' };
|
||||
if (isRemoteRoutingSource(source)) return { success: false, error: 'remote' };
|
||||
|
||||
if (source.startsWith('{')) {
|
||||
const profile = parseHappRoutingJson(source);
|
||||
return profile
|
||||
? { success: true, profile, mode: 'onadd', isNew: false }
|
||||
: { success: false, error: 'invalid' };
|
||||
}
|
||||
|
||||
const match = /^happ:\/\/routing\/(onadd|add)\/([A-Za-z0-9+/_-]+={0,2})$/.exec(source);
|
||||
if (!match) return { success: false, error: 'invalid' };
|
||||
|
||||
try {
|
||||
const binary = atob(match[2].replace(/-/g, '+').replace(/_/g, '/'));
|
||||
// Decode UTF-8 strictly so malformed bytes cannot silently change profile names or rules.
|
||||
const json = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
Uint8Array.from(binary, (character) => character.charCodeAt(0)),
|
||||
);
|
||||
const profile = parseHappRoutingJson(json);
|
||||
return profile
|
||||
? { success: true, profile, mode: match[1] as HappRoutingMode, isNew: false }
|
||||
: { success: false, error: 'invalid' };
|
||||
} catch {
|
||||
return { success: false, error: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHappRoutingDeeplink(
|
||||
profile: HappRoutingProfile,
|
||||
mode: HappRoutingMode = 'onadd',
|
||||
): string {
|
||||
return `happ://routing/${mode}/` + toBase64Utf8(JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export function parseHappRoutingList(input: string): string[] {
|
||||
// Commas can belong to regexp rules, so the editor uses one rule per line.
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Extensions belong to the Happ client and must survive a trip through this editor.
|
||||
export const HappRoutingProfileSchema = z
|
||||
.object({
|
||||
// The backend treats null lists as absent; preserve them until explicitly edited.
|
||||
DirectSites: z.array(z.string()).nullish(),
|
||||
DirectIp: z.array(z.string()).nullish(),
|
||||
ProxySites: z.array(z.string()).nullish(),
|
||||
ProxyIp: z.array(z.string()).nullish(),
|
||||
BlockSites: z.array(z.string()).nullish(),
|
||||
BlockIp: z.array(z.string()).nullish(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export type HappRoutingProfile = z.infer<typeof HappRoutingProfileSchema>;
|
||||
@@ -1,14 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
import { buildHappPresetDeeplink, 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');
|
||||
@@ -26,7 +20,7 @@ describe('Happ presets and helpers', () => {
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toContain('domain:ir');
|
||||
expect(parsed.DirectIp).toContain('geoip:ir');
|
||||
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||
expect(parsed.BlockSites).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for china-direct preset', () => {
|
||||
@@ -37,28 +31,92 @@ describe('Happ presets and helpers', () => {
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.Name).toBe('China Direct');
|
||||
expect(parsed.DirectSites).toContain('geosite:cn');
|
||||
expect(parsed.DirectIp).toContain('geoip:cn');
|
||||
expect(parsed).toEqual({
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
it.each(['iran-bypass', 'china-direct', 'global', 'lan-bypass'])(
|
||||
'adds ad blocking only when opted in for %s without changing its routing',
|
||||
(preset) => {
|
||||
const decode = (link: string) => JSON.parse(atob(link.replace('happ://routing/onadd/', '')));
|
||||
const base = decode(buildHappPresetDeeplink(preset));
|
||||
const withAds = decode(buildHappPresetDeeplink(preset, true));
|
||||
const withoutAds = decode(buildHappPresetDeeplink(preset, false));
|
||||
|
||||
expect(parsed.Name).toBe('AdBlock');
|
||||
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||
expect(base.BlockSites).toEqual([]);
|
||||
expect(withAds.BlockSites).toEqual(['geosite:category-ads-all']);
|
||||
expect({ ...withAds, BlockSites: [] }).toEqual(base);
|
||||
expect(withoutAds).toEqual(base);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps routing disabled even when ad blocking is selected', () => {
|
||||
expect(buildHappPresetDeeplink('off', true)).toBe('happ://routing/off');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for global preset', () => {
|
||||
it.each(['adblock', 'unknown'])(
|
||||
'does not generate a profile for unsupported preset %s',
|
||||
(preset) => {
|
||||
expect(buildHappPresetDeeplink(preset)).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the global preset without direct exceptions', () => {
|
||||
const link = buildHappPresetDeeplink('global');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.Name).toBe('Global Proxy');
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toEqual([]);
|
||||
expect(parsed.DirectIp).toEqual([]);
|
||||
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||
});
|
||||
|
||||
it('generates a separate LAN bypass preset with proxy as the default', () => {
|
||||
const link = buildHappPresetDeeplink('lan-bypass');
|
||||
const prefix = 'happ://routing/onadd/';
|
||||
expect(link.startsWith(prefix)).toBe(true);
|
||||
const parsed = JSON.parse(atob(link.slice(prefix.length)));
|
||||
|
||||
expect(parsed.Name).toBe('Global Bypass Local Network');
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toEqual(['geosite:private']);
|
||||
expect(parsed.DirectIp).toEqual(['geoip:private']);
|
||||
expect(parsed.BlockSites).toEqual([]);
|
||||
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildHappRoutingDeeplink,
|
||||
loadHappRouting,
|
||||
parseHappRoutingJson,
|
||||
parseHappRoutingList,
|
||||
} from '@/pages/settings/happRoutingEditor';
|
||||
|
||||
const profile = {
|
||||
Name: '大陆 · فارسی 🛣',
|
||||
GlobalProxy: 'false',
|
||||
RouteOrder: 'direct-proxy-block',
|
||||
DomainStrategy: 'AsIs',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
DnsHosts: { 'dns.alidns.com': '223.5.5.5' },
|
||||
DirectSites: ['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$'],
|
||||
DirectIp: ['geoip:cn'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
FutureSetting: { enabled: true, values: ['保留', 7, null] },
|
||||
};
|
||||
|
||||
describe('Happ routing editor profile loading', () => {
|
||||
it.each(['onadd', 'add'] as const)('loads %s with UTF-8 and preserves every field', (mode) => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
|
||||
expect(loadHappRouting(` happ://routing/${mode}/${encoded}\n`)).toEqual({
|
||||
success: true,
|
||||
profile,
|
||||
mode,
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['base64', 'base64url'] as const)(
|
||||
'accepts padded and unpadded %s payloads',
|
||||
(encoding) => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString(encoding);
|
||||
const unpadded = encoded.replace(/=+$/, '');
|
||||
const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
|
||||
for (const payload of [padded, unpadded]) {
|
||||
const loaded = loadHappRouting(`happ://routing/onadd/${payload}`);
|
||||
expect(loaded).toMatchObject({ success: true, profile });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('loads raw JSON without filling absent fields or discarding extensions', () => {
|
||||
const input = '{"Name":"existing","GlobalProxy":"false","Future":{"nested":[1,null]}}';
|
||||
expect(loadHappRouting(input)).toEqual({
|
||||
success: true,
|
||||
profile: { Name: 'existing', GlobalProxy: 'false', Future: { nested: [1, null] } },
|
||||
mode: 'onadd',
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['json', 'add', 'onadd'] as const)('preserves null lists in a %s profile', (format) => {
|
||||
const nullableProfile = {
|
||||
...profile,
|
||||
DirectSites: null,
|
||||
DirectIp: null,
|
||||
ProxySites: null,
|
||||
ProxyIp: null,
|
||||
BlockSites: null,
|
||||
BlockIp: null,
|
||||
};
|
||||
const json = JSON.stringify(nullableProfile);
|
||||
const source =
|
||||
format === 'json'
|
||||
? json
|
||||
: `happ://routing/${format}/${Buffer.from(json, 'utf8').toString('base64')}`;
|
||||
|
||||
expect(loadHappRouting(source)).toEqual({
|
||||
success: true,
|
||||
profile: nullableProfile,
|
||||
mode: format === 'json' ? 'onadd' : format,
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains the existing new-profile flow only for an empty source', () => {
|
||||
expect(loadHappRouting(' \n ')).toEqual({
|
||||
success: true,
|
||||
profile: {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
},
|
||||
mode: 'onadd',
|
||||
isNew: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['happ://routing/off', 'off'],
|
||||
['https://example.com/rules', 'remote'],
|
||||
[' \nHTTPS://example.com/rules\t ', 'remote'],
|
||||
['http://example.com/rules', 'invalid'],
|
||||
['https://', 'invalid'],
|
||||
['https://example.com/rules with spaces', 'invalid'],
|
||||
['https://example.com/rules\nsecond-line', 'invalid'],
|
||||
['happ://routing/onadd/', 'invalid'],
|
||||
['happ://routing/onadd/%invalid', 'invalid'],
|
||||
['happ://routing/onadd/e3\n0=', 'invalid'],
|
||||
['happ://routing/add//w==', 'invalid'],
|
||||
['happ://routing/onadd/W10=', 'invalid'],
|
||||
['happ://unknown/e30=', 'invalid'],
|
||||
['{"DirectSites":"geosite:cn"}', 'invalid'],
|
||||
['{"DirectIp":[3]}', 'invalid'],
|
||||
['{"Name":', 'invalid'],
|
||||
])('does not replace an unloadable source %s with a new profile', (source, error) => {
|
||||
expect(loadHappRouting(source)).toEqual({ success: false, error });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Happ routing editor JSON and output', () => {
|
||||
it('preserves all fields through a valid JSON edit', () => {
|
||||
expect(parseHappRoutingJson(JSON.stringify(profile))).toEqual(profile);
|
||||
});
|
||||
|
||||
it('preserves extension keys as JSON data without changing object prototypes', () => {
|
||||
const source = '{"Name":"extensions","__proto__":{"clientOption":true},"DirectSites":[]}';
|
||||
const parsed = parseHappRoutingJson(source);
|
||||
expect(parsed).not.toBeNull();
|
||||
if (!parsed) throw new Error('Expected a valid profile');
|
||||
|
||||
const rebuilt = JSON.parse(atob(buildHappRoutingDeeplink(parsed).split('/onadd/')[1]));
|
||||
expect(rebuilt).toEqual(JSON.parse(source));
|
||||
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it.each(['', '{', '[]', 'null', 'true', '42', '{"BlockIp":[null]}', '{"ProxySites":[{}]}'])(
|
||||
'rejects an invalid profile %s',
|
||||
(source) => {
|
||||
expect(parseHappRoutingJson(source)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('generates a standard UTF-8 link while retaining add semantics and unknown fields', () => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
|
||||
expect(buildHappRoutingDeeplink(profile, 'add')).toBe(`happ://routing/add/${encoded}`);
|
||||
expect(buildHappRoutingDeeplink(profile)).toBe(`happ://routing/onadd/${encoded}`);
|
||||
});
|
||||
|
||||
it('keeps regexp commas intact when editing one rule per line', () => {
|
||||
expect(
|
||||
parseHappRoutingList(' geosite:cn\r\nregexp:^host[0-9]{1,3}\\.example$\n\n domain:local '),
|
||||
).toEqual(['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$', 'domain:local']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { EditorView } from 'codemirror';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
import { toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const profile = {
|
||||
Name: '当前草稿',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSDomain: 'https://dns.example/dns-query',
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
DnsHosts: { 'dns.example': '1.1.1.1' },
|
||||
DirectSites: ['geosite:cn', 'regexp:^example{1,2}\\.com$'],
|
||||
ProxySites: ['geosite:google'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
DirectIp: ['geoip:cn'],
|
||||
ProxyIp: ['1.1.1.1/32'],
|
||||
BlockIp: ['0.0.0.0/8'],
|
||||
FutureSetting: { nested: [{ enabled: false, count: 0, text: '保留' }] },
|
||||
};
|
||||
|
||||
const nullableProfile = {
|
||||
...profile,
|
||||
DirectSites: null,
|
||||
DirectIp: null,
|
||||
ProxySites: null,
|
||||
ProxyIp: null,
|
||||
BlockSites: null,
|
||||
BlockIp: null,
|
||||
};
|
||||
|
||||
function routingLink(value: Record<string, unknown>, mode = 'onadd') {
|
||||
return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
|
||||
}
|
||||
|
||||
function renderSettings(input = routingLink(profile)) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
function SettingsHarness() {
|
||||
const [allSetting, setAllSetting] = useState(() =>
|
||||
Object.assign(new AllSetting(), { subRoutingRules: input }),
|
||||
);
|
||||
|
||||
return (
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) => {
|
||||
updateSetting(patch);
|
||||
setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
|
||||
}}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visual Rule Generator' }));
|
||||
return within(screen.getByRole('dialog'));
|
||||
}
|
||||
|
||||
function routingRules() {
|
||||
return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function generatedProfile(): Record<string, unknown> {
|
||||
const payload = routingRules().replace(/^happ:\/\/routing\/(?:onadd|add)\//, '');
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(Uint8Array.from(atob(payload), (char) => char.charCodeAt(0))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function basicField(name: string) {
|
||||
return screen.getByRole('textbox', { name }) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function jsonEditor() {
|
||||
const content = screen.getByRole('textbox', { name: 'JSON editor' });
|
||||
const editor = EditorView.findFromDOM(content);
|
||||
if (!editor) throw new Error('CodeMirror editor not mounted');
|
||||
return editor;
|
||||
}
|
||||
|
||||
function changeJson(value: string) {
|
||||
const editor = jsonEditor();
|
||||
act(() => {
|
||||
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
|
||||
});
|
||||
}
|
||||
|
||||
describe('Happ routing editor', () => {
|
||||
it('loads the current unsaved routing draft when opened', () => {
|
||||
const updateSetting = renderSettings();
|
||||
const latest = { ...profile, DirectSites: ['domain:latest-draft.example'] };
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
|
||||
target: { value: routingLink(latest) },
|
||||
});
|
||||
updateSetting.mockClear();
|
||||
|
||||
const dialog = openEditor();
|
||||
|
||||
expect((dialog.getAllByRole('textbox')[0] as HTMLTextAreaElement).value).toBe(
|
||||
'domain:latest-draft.example',
|
||||
);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['add', 'onadd'])(
|
||||
'preserves other fields and %s semantics when editing basic rules',
|
||||
(mode) => {
|
||||
const updateSetting = renderSettings(routingLink(profile, mode));
|
||||
openEditor();
|
||||
const edited = 'regexp:^other{1,2}\\.com$\n domain:another.example\n';
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), { target: { value: edited } });
|
||||
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe(edited);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(routingRules().startsWith(`happ://routing/${mode}/`)).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
...profile,
|
||||
DirectSites: ['regexp:^other{1,2}\\.com$', 'domain:another.example'],
|
||||
});
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('syncs both tabs while retaining full-profile edits', () => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Blocked Domains (Ad/Malware)'), {
|
||||
target: { value: 'geosite:category-ads-all\ndomain:ads.example' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
const advanced = JSON.parse(jsonEditor().state.doc.toString()) as Record<string, unknown>;
|
||||
expect(advanced).toEqual({
|
||||
...profile,
|
||||
BlockSites: ['geosite:category-ads-all', 'domain:ads.example'],
|
||||
});
|
||||
|
||||
const next = { ...advanced, ProxyIp: ['9.9.9.9/32'], FutureSetting: { deep: ['new'] } };
|
||||
changeJson(JSON.stringify(next, null, 2));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Proxy IPs / CIDRs').value).toBe('9.9.9.9/32');
|
||||
expect(basicField('Blocked Domains (Ad/Malware)').value).toBe(
|
||||
'geosite:category-ads-all\ndomain:ads.example',
|
||||
);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(next);
|
||||
});
|
||||
|
||||
it.each(['Basic rules', 'Advanced editor'])(
|
||||
'preserves untouched null lists when generating from %s',
|
||||
(tab) => {
|
||||
renderSettings(JSON.stringify(nullableProfile));
|
||||
const dialog = openEditor();
|
||||
expect(dialog.queryByRole('alert')).toBeNull();
|
||||
expect(
|
||||
dialog.getAllByRole('textbox').map((field) => (field as HTMLTextAreaElement).value),
|
||||
).toEqual(['', '', '', '', '', '']);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(nullableProfile);
|
||||
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(generatedProfile()).toEqual(nullableProfile);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('replaces only the edited null list with rules', () => {
|
||||
renderSettings(routingLink(nullableProfile, 'add'));
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'domain:local.example\n' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(routingRules().startsWith('happ://routing/add/')).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
...nullableProfile,
|
||||
DirectSites: ['domain:local.example'],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts setting a list to null in the advanced editor', () => {
|
||||
renderSettings();
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
const next = { ...profile, DirectSites: null };
|
||||
changeJson(JSON.stringify(next));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe('');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(generatedProfile()).toEqual(next);
|
||||
});
|
||||
|
||||
it.each(['{"Name":', '{"DirectSites": [42]}'])(
|
||||
'retains invalid JSON %s until repaired',
|
||||
(invalid) => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
changeJson(invalid);
|
||||
|
||||
const generate = screen.getByRole('button', {
|
||||
name: 'Generate Deeplink',
|
||||
}) as HTMLButtonElement;
|
||||
expect(generate.disabled).toBe(true);
|
||||
expect(screen.getByRole('alert').textContent).toContain('arrays of strings');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Advanced editor' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(jsonEditor().state.doc.toString()).toBe(invalid);
|
||||
fireEvent.click(generate);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
|
||||
const repaired = { ...profile, Name: 'Repaired', BlockIp: ['192.0.2.0/24'] };
|
||||
changeJson(JSON.stringify(repaired));
|
||||
expect(generate.disabled).toBe(false);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Blocked IPs / CIDRs').value).toBe('192.0.2.0/24');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(repaired);
|
||||
},
|
||||
);
|
||||
|
||||
it('discards canceled edits and reloads the latest parent draft on reopening', () => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'discard.example' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
changeJson('{invalid');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe(routingLink(profile));
|
||||
|
||||
const latest = { ...profile, DirectSites: ['latest.example'] };
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
|
||||
target: { value: JSON.stringify(latest) },
|
||||
});
|
||||
updateSetting.mockClear();
|
||||
openEditor();
|
||||
expect(screen.getByRole('tab', { name: 'Basic rules' }).getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe('latest.example');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(latest);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://example.com/DEFAULT.DEEPLINK', 'Remote URLs cannot be loaded here'],
|
||||
['http://example.com/rules', 'not a valid Happ routing link'],
|
||||
['happ://routing/off', 'Routing is disabled'],
|
||||
['happ://routing/onadd/not-base64', 'not a valid Happ routing link'],
|
||||
['{"BlockIp":42}', 'not a valid Happ routing link'],
|
||||
])('blocks generation for unsupported current rules: %s', (input, message) => {
|
||||
const updateSetting = renderSettings(input);
|
||||
openEditor();
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain(message);
|
||||
const generate = screen.getByRole('button', { name: 'Generate Deeplink' }) as HTMLButtonElement;
|
||||
expect(generate.disabled).toBe(true);
|
||||
fireEvent.click(generate);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe(input);
|
||||
});
|
||||
|
||||
it('starts a new custom profile when the current draft is blank', () => {
|
||||
const updateSetting = renderSettings('');
|
||||
openEditor();
|
||||
expect(screen.getByRole('alert').textContent).toContain('No current rules');
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'domain:local.example\n' },
|
||||
});
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(routingRules().startsWith('happ://routing/onadd/')).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['domain:local.example'],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add absent list fields to an unchanged raw JSON profile', () => {
|
||||
const minimal = { Name: 'Minimal', FutureSetting: profile.FutureSetting };
|
||||
renderSettings(JSON.stringify(minimal));
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(minimal);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(minimal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const chinaProfile = {
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
};
|
||||
|
||||
function renderSettings(isMobile = false) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
function SettingsHarness() {
|
||||
const [allSetting, setAllSetting] = useState(() => {
|
||||
const settings = new AllSetting();
|
||||
settings.subRoutingRules = 'https://example.com/existing-routing';
|
||||
return settings;
|
||||
});
|
||||
|
||||
return (
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) => {
|
||||
updateSetting(patch);
|
||||
setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
remoteSourceBadge={() => null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function openPresets() {
|
||||
const select = screen.getByRole('combobox').closest('.ant-select');
|
||||
if (!select) throw new Error('Routing preset select not found');
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
return Array.from(
|
||||
document.querySelectorAll(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function choosePreset(label: string) {
|
||||
const option = openPresets().find((item) => item.textContent === label);
|
||||
if (!option) throw new Error(`Routing preset '${label}' not found`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
function routingRules() {
|
||||
return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function routingProfile(): Record<string, unknown> {
|
||||
const prefix = 'happ://routing/onadd/';
|
||||
const link = routingRules();
|
||||
expect(link.startsWith(prefix)).toBe(true);
|
||||
return JSON.parse(atob(link.slice(prefix.length))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('Happ routing preset controls', () => {
|
||||
it('keeps selecting and toggling local until Apply updates the routing rules', () => {
|
||||
const updateSetting = renderSettings();
|
||||
|
||||
choosePreset('China Direct (Bypass-CN)');
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
expect(includeAdblock.getAttribute('aria-checked')).toBe('false');
|
||||
fireEvent.click(includeAdblock);
|
||||
|
||||
expect(includeAdblock.getAttribute('aria-checked')).toBe('true');
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe('https://example.com/existing-routing');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
|
||||
expect(routingProfile().BlockSites).toEqual(['geosite:category-ads-all']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Iran Bypass', 'Iran Bypass'],
|
||||
['China Direct (Bypass-CN)', 'Bypass-CN'],
|
||||
['Full Proxy', 'Global Proxy'],
|
||||
['Global Bypass Local Network', 'Global Bypass Local Network'],
|
||||
])('applies %s without AdBlock by default', (label, profileName) => {
|
||||
renderSettings();
|
||||
choosePreset(label);
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Include AdBlock' }).getAttribute('aria-checked'),
|
||||
).toBe('false');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({ Name: profileName, BlockSites: [] });
|
||||
});
|
||||
|
||||
it('keeps LAN bypass separate from Full Proxy when switching presets', () => {
|
||||
renderSettings();
|
||||
choosePreset('Global Bypass Local Network');
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Include AdBlock' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({
|
||||
Name: 'Global Bypass Local Network',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:private'],
|
||||
DirectIp: ['geoip:private'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
|
||||
choosePreset('Full Proxy');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({
|
||||
Name: 'Global Proxy',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves China routing when opting in and clears AdBlock when reapplied after opting out', () => {
|
||||
const updateSetting = renderSettings(true);
|
||||
choosePreset('China Direct (Bypass-CN)');
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
fireEvent.click(includeAdblock);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toEqual({
|
||||
...chinaProfile,
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
const withAdblock = routingRules();
|
||||
|
||||
fireEvent.click(includeAdblock);
|
||||
expect(routingRules()).toBe(withAdblock);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledTimes(2);
|
||||
expect(routingProfile()).toEqual(chinaProfile);
|
||||
});
|
||||
|
||||
it('offers only base presets so AdBlock cannot replace a country preset', () => {
|
||||
renderSettings();
|
||||
|
||||
expect(openPresets().map((option) => option.textContent)).toEqual([
|
||||
'Iran Bypass',
|
||||
'China Direct (Bypass-CN)',
|
||||
'Full Proxy',
|
||||
'Global Bypass Local Network',
|
||||
'Disable Routing (happ://routing/off)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables AdBlock for Off and applies the off link even after opting in', () => {
|
||||
const updateSetting = renderSettings();
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
fireEvent.click(includeAdblock);
|
||||
choosePreset('Disable Routing (happ://routing/off)');
|
||||
|
||||
expect((includeAdblock as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({
|
||||
subRoutingRules: 'happ://routing/off',
|
||||
});
|
||||
expect(routingRules()).toBe('happ://routing/off');
|
||||
});
|
||||
});
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "أسماء حزم Android",
|
||||
"subHappPerAppListDesc": "أسماء حزم تطبيقات Android المراد تضمينها أو استثناؤها، مفصولة بفواصل (مثل org.telegram.messenger).",
|
||||
"subHappPresetIran": "تجاوز إيران",
|
||||
"subHappPresetChina": "مباشر للصين",
|
||||
"subHappPresetAdblock": "حظر الإعلانات (AdBlock)",
|
||||
"subHappPresetChina": "مباشر للصين (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "تضمين AdBlock",
|
||||
"subHappApplyPreset": "تطبيق الإعداد المسبق",
|
||||
"subHappPresetGlobal": "بروكسي كامل",
|
||||
"subHappPresetLocal": "بروكسي شامل مع تجاوز الشبكة المحلية",
|
||||
"subHappPresetOff": "تعطيل التوجيه (happ://routing/off)",
|
||||
"subHappColorBlue": "أزرق (قياسي / افتراضي)",
|
||||
"subHappColorGreen": "أخضر (نجاح)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "تجاوز (استثناء التطبيقات المحددة)",
|
||||
"subHappPresetApplied": "تم تطبيق إعداد Happ المسبق على قواعد التوجيه",
|
||||
"subHappPresets": "إعدادات التوجيه المسبقة",
|
||||
"subHappPresetsDesc": "قواعد توجيه مُعدّة مسبقًا ومخصصة لعملاء Happ.",
|
||||
"subHappPresetsDesc": "اختر إعدادًا مسبقًا أساسيًا وحدد ما إذا كنت تريد حظر الإعلانات، ثم طبّقه لاستبدال قواعد التوجيه الحالية.",
|
||||
"subHappVisualBuilder": "منشئ القواعد المرئي",
|
||||
"subHappVisualBuilderDesc": "إنشاء رابط عميق مخصص للتوجيه من قوائم النطاقات وعناوين IP.",
|
||||
"subHappVisualBuilderDesc": "حمّل ملف تعريف التوجيه الحالي وعدّل قواعده أو JSON بالكامل.",
|
||||
"subHappEditorBasic": "القواعد الأساسية",
|
||||
"subHappEditorAdvanced": "المحرر المتقدم",
|
||||
"subHappEditorListHint": "قاعدة واحدة في كل سطر. تُحفظ حقول ملف التعريف الأخرى.",
|
||||
"subHappEditorAdvancedHint": "عدّل ملف تعريف JSON بالكامل. تتم مزامنة التغييرات مع علامة تبويب القواعد الأساسية.",
|
||||
"subHappEditorInvalidJson": "أدخل كائن JSON. يجب أن تكون قوائم التوجيه، إن وُجدت، مصفوفات من السلاسل النصية أو null.",
|
||||
"subHappEditorLoadInvalid": "القواعد الحالية ليست رابط توجيه Happ صالحًا أو ملف تعريف JSON صالحًا.",
|
||||
"subHappEditorLoadRemote": "لا يمكن تحميل عناوين URL البعيدة هنا. الصق رابط توجيه Happ أو ملف تعريف JSON أولًا.",
|
||||
"subHappEditorLoadOff": "التوجيه معطّل. طبّق إعدادًا مسبقًا أو امسح قواعد التوجيه لإنشاء ملف تعريف.",
|
||||
"subHappEditorNew": "لا توجد قواعد حاليًا. ابدأ بملف تعريف مخصص فارغ.",
|
||||
"subHappBuildDeeplink": "إنشاء رابط عميق",
|
||||
"subHappModalTitle": "منشئ قواعد التوجيه المرئي لـ Happ",
|
||||
"subHappDirectDomains": "نطاقات مباشرة (تجاوز)",
|
||||
|
||||
@@ -1690,9 +1690,11 @@
|
||||
"subHappPerAppList": "Android Package Names",
|
||||
"subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. org.telegram.messenger).",
|
||||
"subHappPresetIran": "Iran Bypass",
|
||||
"subHappPresetChina": "China Direct",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "China Direct (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Include AdBlock",
|
||||
"subHappApplyPreset": "Apply preset",
|
||||
"subHappPresetGlobal": "Full Proxy",
|
||||
"subHappPresetLocal": "Global Bypass Local Network",
|
||||
"subHappPresetOff": "Disable Routing (happ://routing/off)",
|
||||
"subHappColorBlue": "Blue (Standard / Default)",
|
||||
"subHappColorGreen": "Green (Success)",
|
||||
@@ -1716,9 +1718,18 @@
|
||||
"subHappPerAppBypass": "Bypass (Exclude Listed Apps)",
|
||||
"subHappPresetApplied": "Happ preset applied to routing rules",
|
||||
"subHappPresets": "Routing Presets",
|
||||
"subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
|
||||
"subHappPresetsDesc": "Choose a base preset and optional ad blocking, then apply to replace the current routing rules.",
|
||||
"subHappVisualBuilder": "Visual Rule Generator",
|
||||
"subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
|
||||
"subHappVisualBuilderDesc": "Load the current routing profile and edit its rules or full JSON.",
|
||||
"subHappEditorBasic": "Basic rules",
|
||||
"subHappEditorAdvanced": "Advanced editor",
|
||||
"subHappEditorListHint": "One rule per line. Other profile fields are preserved.",
|
||||
"subHappEditorAdvancedHint": "Edit the complete JSON profile. Changes sync with the basic rules tab.",
|
||||
"subHappEditorInvalidJson": "Enter a JSON object. Routing lists, when present, must be arrays of strings or null.",
|
||||
"subHappEditorLoadInvalid": "The current rules are not a valid Happ routing link or JSON profile.",
|
||||
"subHappEditorLoadRemote": "Remote URLs cannot be loaded here. Paste a Happ routing link or JSON profile first.",
|
||||
"subHappEditorLoadOff": "Routing is disabled. Apply a preset or clear the routing rules to create a profile.",
|
||||
"subHappEditorNew": "No current rules. Start with an empty custom profile.",
|
||||
"subHappBuildDeeplink": "Generate Deeplink",
|
||||
"subHappModalTitle": "Happ Visual Routing Rule Generator",
|
||||
"subHappDirectDomains": "Direct Domains (Bypass)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Nombres de paquete de Android",
|
||||
"subHappPerAppListDesc": "Nombres de paquete de aplicaciones Android separados por comas que se incluirán o excluirán (p. ej. org.telegram.messenger).",
|
||||
"subHappPresetIran": "Irán: omitir proxy",
|
||||
"subHappPresetChina": "China: conexión directa",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "China: conexión directa (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Incluir AdBlock",
|
||||
"subHappApplyPreset": "Aplicar preajuste",
|
||||
"subHappPresetGlobal": "Todo por proxy",
|
||||
"subHappPresetLocal": "Proxy global con exclusión de la red local",
|
||||
"subHappPresetOff": "Desactivar enrutamiento (happ://routing/off)",
|
||||
"subHappColorBlue": "Azul (estándar / predeterminado)",
|
||||
"subHappColorGreen": "Verde (éxito)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Omitir (excluir apps de la lista)",
|
||||
"subHappPresetApplied": "Preajuste de Happ aplicado a las reglas de enrutamiento",
|
||||
"subHappPresets": "Preajustes de enrutamiento",
|
||||
"subHappPresetsDesc": "Preajustes de reglas de enrutamiento preconfigurados para clientes Happ.",
|
||||
"subHappPresetsDesc": "Elige un preajuste base y si deseas bloquear anuncios; luego aplícalo para reemplazar las reglas de enrutamiento actuales.",
|
||||
"subHappVisualBuilder": "Generador visual de reglas",
|
||||
"subHappVisualBuilderDesc": "Crear un deeplink de enrutamiento personalizado a partir de listas de dominios e IPs.",
|
||||
"subHappVisualBuilderDesc": "Carga el perfil de enrutamiento actual y edita sus reglas o el JSON completo.",
|
||||
"subHappEditorBasic": "Reglas básicas",
|
||||
"subHappEditorAdvanced": "Editor avanzado",
|
||||
"subHappEditorListHint": "Una regla por línea. Se conservan los demás campos del perfil.",
|
||||
"subHappEditorAdvancedHint": "Edita el perfil JSON completo. Los cambios se sincronizan con la pestaña de reglas básicas.",
|
||||
"subHappEditorInvalidJson": "Introduce un objeto JSON. Las listas de enrutamiento, si existen, deben ser matrices de cadenas de texto o null.",
|
||||
"subHappEditorLoadInvalid": "Las reglas actuales no son un enlace de enrutamiento de Happ ni un perfil JSON válidos.",
|
||||
"subHappEditorLoadRemote": "No se pueden cargar URL remotas aquí. Primero pega un enlace de enrutamiento de Happ o un perfil JSON.",
|
||||
"subHappEditorLoadOff": "El enrutamiento está desactivado. Aplica un preajuste o vacía las reglas de enrutamiento para crear un perfil.",
|
||||
"subHappEditorNew": "No hay reglas actuales. Empieza con un perfil personalizado vacío.",
|
||||
"subHappBuildDeeplink": "Generar deeplink",
|
||||
"subHappModalTitle": "Generador visual de reglas de enrutamiento de Happ",
|
||||
"subHappDirectDomains": "Dominios directos (sin proxy)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "نام بستههای برنامههای اندروید",
|
||||
"subHappPerAppListDesc": "نام بسته برنامههای اندروید برای شامل یا مستثنی کردن، جدا شده با کاما (مانند org.telegram.messenger).",
|
||||
"subHappPresetIran": "دور زدن سایتهای ایران (Iran Bypass)",
|
||||
"subHappPresetChina": "دور زدن چین (China Direct)",
|
||||
"subHappPresetAdblock": "مسدودسازی تبلیغات (AdBlock)",
|
||||
"subHappPresetChina": "اتصال مستقیم چین (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "افزودن AdBlock",
|
||||
"subHappApplyPreset": "اعمال الگو",
|
||||
"subHappPresetGlobal": "پراکسی کل ترافیک (Global)",
|
||||
"subHappPresetLocal": "پراکسی سراسری با اتصال مستقیم شبکه محلی",
|
||||
"subHappPresetOff": "غیرفعالسازی روتینگ (happ://routing/off)",
|
||||
"subHappColorBlue": "آبی (استاندارد / پیشفرض)",
|
||||
"subHappColorGreen": "سبز (موفقیت)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "بایپس (مستثنیکردن برنامههای فهرستشده)",
|
||||
"subHappPresetApplied": "الگوی روتینگ Happ اعمال شد",
|
||||
"subHappPresets": "الگوهای آماده روتینگ",
|
||||
"subHappPresetsDesc": "الگوهای آماده و بهینهسازیشده برای روتینگ در برنامه Happ.",
|
||||
"subHappPresetsDesc": "یک الگوی پایه انتخاب کنید و در صورت نیاز مسدودسازی تبلیغات را فعال کنید، سپس آن را برای جایگزینی قوانین مسیریابی فعلی اعمال کنید.",
|
||||
"subHappVisualBuilder": "سازنده بصری قوانین",
|
||||
"subHappVisualBuilderDesc": "ایجاد آسان دیپلینک روتینگ سفارشی بر اساس دامنهها و آیپیها.",
|
||||
"subHappVisualBuilderDesc": "پروفایل مسیریابی فعلی را بارگیری کنید و قوانین یا کل JSON آن را ویرایش کنید.",
|
||||
"subHappEditorBasic": "قوانین پایه",
|
||||
"subHappEditorAdvanced": "ویرایشگر پیشرفته",
|
||||
"subHappEditorListHint": "در هر خط یک قانون وارد کنید. سایر فیلدهای پروفایل حفظ میشوند.",
|
||||
"subHappEditorAdvancedHint": "کل پروفایل JSON را ویرایش کنید. تغییرات با زبانه قوانین پایه همگام میشوند.",
|
||||
"subHappEditorInvalidJson": "یک شیء JSON وارد کنید. فهرستهای مسیریابی، در صورت وجود، باید آرایههایی از رشتهها یا null باشند.",
|
||||
"subHappEditorLoadInvalid": "قوانین فعلی، لینک مسیریابی معتبر Happ یا پروفایل معتبر JSON نیستند.",
|
||||
"subHappEditorLoadRemote": "نشانیهای راه دور در اینجا قابل بارگیری نیستند. ابتدا یک لینک مسیریابی Happ یا پروفایل JSON جایگذاری کنید.",
|
||||
"subHappEditorLoadOff": "مسیریابی غیرفعال است. برای ساخت پروفایل، یک الگو اعمال کنید یا قوانین مسیریابی را پاک کنید.",
|
||||
"subHappEditorNew": "در حال حاضر قانونی وجود ندارد. با یک پروفایل سفارشی خالی شروع کنید.",
|
||||
"subHappBuildDeeplink": "تولید دیپلینک",
|
||||
"subHappModalTitle": "سازنده بصری قوانین روتینگ Happ",
|
||||
"subHappDirectDomains": "دامنههای مستقیم (Direct)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Nama Paket Android",
|
||||
"subHappPerAppListDesc": "Nama paket aplikasi Android yang disertakan atau dikecualikan, dipisahkan koma (mis. org.telegram.messenger).",
|
||||
"subHappPresetIran": "Bypass Iran",
|
||||
"subHappPresetChina": "Tiongkok Langsung",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "Tiongkok Langsung (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Sertakan AdBlock",
|
||||
"subHappApplyPreset": "Terapkan preset",
|
||||
"subHappPresetGlobal": "Proxy Penuh",
|
||||
"subHappPresetLocal": "Proxy Global dengan Pengecualian Jaringan Lokal",
|
||||
"subHappPresetOff": "Nonaktifkan Routing (happ://routing/off)",
|
||||
"subHappColorBlue": "Biru (Standar / Bawaan)",
|
||||
"subHappColorGreen": "Hijau (Sukses)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Bypass (Kecualikan Aplikasi Terdaftar)",
|
||||
"subHappPresetApplied": "Preset Happ diterapkan ke aturan routing",
|
||||
"subHappPresets": "Preset Routing",
|
||||
"subHappPresetsDesc": "Preset aturan routing siap pakai yang disesuaikan untuk klien Happ.",
|
||||
"subHappPresetsDesc": "Pilih preset dasar dan opsi pemblokiran iklan, lalu terapkan untuk mengganti aturan routing saat ini.",
|
||||
"subHappVisualBuilder": "Pembuat Aturan Visual",
|
||||
"subHappVisualBuilderDesc": "Buat deeplink routing kustom dari daftar domain dan IP.",
|
||||
"subHappVisualBuilderDesc": "Muat profil routing saat ini dan edit aturannya atau seluruh JSON.",
|
||||
"subHappEditorBasic": "Aturan dasar",
|
||||
"subHappEditorAdvanced": "Editor lanjutan",
|
||||
"subHappEditorListHint": "Satu aturan per baris. Kolom lainnya dalam profil tetap dipertahankan.",
|
||||
"subHappEditorAdvancedHint": "Edit seluruh profil JSON. Perubahan disinkronkan dengan tab aturan dasar.",
|
||||
"subHappEditorInvalidJson": "Masukkan objek JSON. Daftar routing, jika ada, harus berupa array string atau null.",
|
||||
"subHappEditorLoadInvalid": "Aturan saat ini bukan tautan routing Happ atau profil JSON yang valid.",
|
||||
"subHappEditorLoadRemote": "URL jarak jauh tidak dapat dimuat di sini. Tempel tautan routing Happ atau profil JSON terlebih dahulu.",
|
||||
"subHappEditorLoadOff": "Routing dinonaktifkan. Terapkan preset atau kosongkan aturan routing untuk membuat profil.",
|
||||
"subHappEditorNew": "Belum ada aturan. Mulai dengan profil kustom yang kosong.",
|
||||
"subHappBuildDeeplink": "Buat Deeplink",
|
||||
"subHappModalTitle": "Pembuat Aturan Routing Visual Happ",
|
||||
"subHappDirectDomains": "Domain Langsung (Bypass)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Android パッケージ名",
|
||||
"subHappPerAppListDesc": "対象または除外する Android アプリのパッケージ名をカンマ区切りで指定します (例: org.telegram.messenger)。",
|
||||
"subHappPresetIran": "イラン向けバイパス",
|
||||
"subHappPresetChina": "中国向け直接接続",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "中国向け直接接続 (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "AdBlock を追加",
|
||||
"subHappApplyPreset": "プリセットを適用",
|
||||
"subHappPresetGlobal": "フルプロキシ",
|
||||
"subHappPresetLocal": "ローカルネットワークを除く全通信をプロキシ経由",
|
||||
"subHappPresetOff": "ルーティングを無効化 (happ://routing/off)",
|
||||
"subHappColorBlue": "ブルー (標準 / デフォルト)",
|
||||
"subHappColorGreen": "グリーン (成功)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "バイパス (リストされたアプリを除外)",
|
||||
"subHappPresetApplied": "Happ プリセットをルーティングルールに適用しました",
|
||||
"subHappPresets": "ルーティングプリセット",
|
||||
"subHappPresetsDesc": "Happ クライアント向けに事前設定されたルーティングルールのプリセット。",
|
||||
"subHappPresetsDesc": "基本プリセットと広告ブロックの有無を選択し、適用して現在のルーティングルールを置き換えます。",
|
||||
"subHappVisualBuilder": "ビジュアルルールジェネレーター",
|
||||
"subHappVisualBuilderDesc": "ドメインと IP のリストからカスタムルーティングのディープリンクを作成します。",
|
||||
"subHappVisualBuilderDesc": "現在のルーティングプロファイルを読み込み、ルールまたは JSON 全体を編集します。",
|
||||
"subHappEditorBasic": "基本ルール",
|
||||
"subHappEditorAdvanced": "詳細エディター",
|
||||
"subHappEditorListHint": "1 行に 1 つのルールを入力します。プロファイルのほかの項目は保持されます。",
|
||||
"subHappEditorAdvancedHint": "JSON プロファイル全体を編集します。変更は基本ルールタブと同期されます。",
|
||||
"subHappEditorInvalidJson": "JSON オブジェクトを入力してください。ルーティングリストがある場合は、文字列の配列または null にする必要があります。",
|
||||
"subHappEditorLoadInvalid": "現在のルールは、有効な Happ ルーティングリンクまたは JSON プロファイルではありません。",
|
||||
"subHappEditorLoadRemote": "ここではリモート URL を読み込めません。先に Happ ルーティングリンクまたは JSON プロファイルを貼り付けてください。",
|
||||
"subHappEditorLoadOff": "ルーティングは無効です。プリセットを適用するか、ルーティングルールを空にしてプロファイルを作成してください。",
|
||||
"subHappEditorNew": "現在のルールはありません。空のカスタムプロファイルから作成を始めます。",
|
||||
"subHappBuildDeeplink": "ディープリンクを生成",
|
||||
"subHappModalTitle": "Happ ビジュアルルーティングルールジェネレーター",
|
||||
"subHappDirectDomains": "直接接続するドメイン (バイパス)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Nomes de pacotes Android",
|
||||
"subHappPerAppListDesc": "Nomes de pacotes de aplicativos Android separados por vírgula a incluir ou excluir (ex.: org.telegram.messenger).",
|
||||
"subHappPresetIran": "Irã (desvio)",
|
||||
"subHappPresetChina": "China (direto)",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "China direto (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Incluir AdBlock",
|
||||
"subHappApplyPreset": "Aplicar predefinição",
|
||||
"subHappPresetGlobal": "Proxy total",
|
||||
"subHappPresetLocal": "Proxy global com exceção da rede local",
|
||||
"subHappPresetOff": "Desativar roteamento (happ://routing/off)",
|
||||
"subHappColorBlue": "Azul (padrão)",
|
||||
"subHappColorGreen": "Verde (sucesso)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Desviar (excluir apps listados)",
|
||||
"subHappPresetApplied": "Predefinição do Happ aplicada às regras de roteamento",
|
||||
"subHappPresets": "Predefinições de roteamento",
|
||||
"subHappPresetsDesc": "Predefinições de regras de roteamento pré-configuradas para clientes Happ.",
|
||||
"subHappPresetsDesc": "Escolha uma predefinição base e se deseja bloquear anúncios; depois aplique para substituir as regras de roteamento atuais.",
|
||||
"subHappVisualBuilder": "Gerador visual de regras",
|
||||
"subHappVisualBuilderDesc": "Crie um deeplink de roteamento personalizado a partir de listas de domínios e IPs.",
|
||||
"subHappVisualBuilderDesc": "Carregue o perfil de roteamento atual e edite suas regras ou o JSON completo.",
|
||||
"subHappEditorBasic": "Regras básicas",
|
||||
"subHappEditorAdvanced": "Editor avançado",
|
||||
"subHappEditorListHint": "Uma regra por linha. Os demais campos do perfil são preservados.",
|
||||
"subHappEditorAdvancedHint": "Edite o perfil JSON completo. As alterações são sincronizadas com a aba de regras básicas.",
|
||||
"subHappEditorInvalidJson": "Insira um objeto JSON. As listas de roteamento, quando presentes, devem ser arrays de strings ou null.",
|
||||
"subHappEditorLoadInvalid": "As regras atuais não são um link de roteamento do Happ nem um perfil JSON válidos.",
|
||||
"subHappEditorLoadRemote": "URLs remotas não podem ser carregadas aqui. Primeiro cole um link de roteamento do Happ ou um perfil JSON.",
|
||||
"subHappEditorLoadOff": "O roteamento está desativado. Aplique uma predefinição ou limpe as regras de roteamento para criar um perfil.",
|
||||
"subHappEditorNew": "Não há regras atuais. Comece com um perfil personalizado vazio.",
|
||||
"subHappBuildDeeplink": "Gerar deeplink",
|
||||
"subHappModalTitle": "Gerador visual de regras de roteamento do Happ",
|
||||
"subHappDirectDomains": "Domínios diretos (desvio)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Пакеты приложений Android",
|
||||
"subHappPerAppListDesc": "Имена пакетов Android-приложений для включения или исключения через запятую (например, org.telegram.messenger).",
|
||||
"subHappPresetIran": "Обход сайтов Ирана (Iran Bypass)",
|
||||
"subHappPresetChina": "Обход сайтов Китая (China Direct)",
|
||||
"subHappPresetAdblock": "Блокировка рекламы (AdBlock)",
|
||||
"subHappPresetChina": "Китай напрямую (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Добавить AdBlock",
|
||||
"subHappApplyPreset": "Применить шаблон",
|
||||
"subHappPresetGlobal": "Полный прокси (Global)",
|
||||
"subHappPresetLocal": "Всё через прокси, локальная сеть напрямую",
|
||||
"subHappPresetOff": "Отключить маршрутизацию (happ://routing/off)",
|
||||
"subHappColorBlue": "Синий (стандартный / по умолчанию)",
|
||||
"subHappColorGreen": "Зеленый (успех)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Обход (исключить выбранные приложения)",
|
||||
"subHappPresetApplied": "Пресет маршрутизации Happ успешно применен",
|
||||
"subHappPresets": "Пресеты маршрутизации",
|
||||
"subHappPresetsDesc": "Готовые конфигурации маршрутизации для клиентов Happ.",
|
||||
"subHappPresetsDesc": "Выберите базовый шаблон и при необходимости включите блокировку рекламы, затем примените его для замены текущих правил маршрутизации.",
|
||||
"subHappVisualBuilder": "Визуальный конструктор правил",
|
||||
"subHappVisualBuilderDesc": "Генератор диплинков маршрутизации на основе списков доменов и IP.",
|
||||
"subHappVisualBuilderDesc": "Загрузите текущий профиль маршрутизации и отредактируйте его правила или полный JSON.",
|
||||
"subHappEditorBasic": "Основные правила",
|
||||
"subHappEditorAdvanced": "Расширенный редактор",
|
||||
"subHappEditorListHint": "Одно правило на строку. Остальные поля профиля сохраняются.",
|
||||
"subHappEditorAdvancedHint": "Редактируйте полный JSON-профиль. Изменения синхронизируются с вкладкой основных правил.",
|
||||
"subHappEditorInvalidJson": "Введите объект JSON. Списки маршрутизации, если они присутствуют, должны быть массивами строк или null.",
|
||||
"subHappEditorLoadInvalid": "Текущие правила не являются допустимой ссылкой маршрутизации Happ или JSON-профилем.",
|
||||
"subHappEditorLoadRemote": "Здесь нельзя загрузить удалённые URL. Сначала вставьте ссылку маршрутизации Happ или JSON-профиль.",
|
||||
"subHappEditorLoadOff": "Маршрутизация отключена. Примените шаблон или очистите правила маршрутизации, чтобы создать профиль.",
|
||||
"subHappEditorNew": "Текущих правил нет. Начните с пустого пользовательского профиля.",
|
||||
"subHappBuildDeeplink": "Сгенерировать диплинк",
|
||||
"subHappModalTitle": "Визуальный конструктор правил маршрутизации Happ",
|
||||
"subHappDirectDomains": "Прямые домены (Direct)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Android Paket Adları",
|
||||
"subHappPerAppListDesc": "Dahil edilecek veya hariç tutulacak Android uygulamalarının virgülle ayrılmış paket adları (ör. org.telegram.messenger).",
|
||||
"subHappPresetIran": "İran'ı Atla",
|
||||
"subHappPresetChina": "Çin Doğrudan",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "Çin Doğrudan (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "AdBlock ekle",
|
||||
"subHappApplyPreset": "Hazır ayarı uygula",
|
||||
"subHappPresetGlobal": "Tam Proxy",
|
||||
"subHappPresetLocal": "Yerel Ağ Hariç Tüm Trafik için Proxy",
|
||||
"subHappPresetOff": "Yönlendirmeyi Devre Dışı Bırak (happ://routing/off)",
|
||||
"subHappColorBlue": "Mavi (Standart / Varsayılan)",
|
||||
"subHappColorGreen": "Yeşil (Başarılı)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Atla (Listelenen Uygulamaları Hariç Tut)",
|
||||
"subHappPresetApplied": "Happ hazır ayarı yönlendirme kurallarına uygulandı",
|
||||
"subHappPresets": "Yönlendirme Hazır Ayarları",
|
||||
"subHappPresetsDesc": "Happ istemcilerine özel, önceden yapılandırılmış yönlendirme kuralı hazır ayarları.",
|
||||
"subHappPresetsDesc": "Temel bir hazır ayar seçin ve isterseniz reklam engellemeyi etkinleştirin, ardından mevcut yönlendirme kurallarını değiştirmek için uygulayın.",
|
||||
"subHappVisualBuilder": "Görsel Kural Oluşturucu",
|
||||
"subHappVisualBuilderDesc": "Alan adı ve IP listelerinden özel yönlendirme derin bağlantısı oluşturur.",
|
||||
"subHappVisualBuilderDesc": "Mevcut yönlendirme profilini yükleyin ve kurallarını veya JSON içeriğinin tamamını düzenleyin.",
|
||||
"subHappEditorBasic": "Temel kurallar",
|
||||
"subHappEditorAdvanced": "Gelişmiş düzenleyici",
|
||||
"subHappEditorListHint": "Her satıra bir kural yazın. Profilin diğer alanları korunur.",
|
||||
"subHappEditorAdvancedHint": "JSON profilinin tamamını düzenleyin. Değişiklikler temel kurallar sekmesiyle eşitlenir.",
|
||||
"subHappEditorInvalidJson": "Bir JSON nesnesi girin. Yönlendirme listeleri varsa metin dizileri veya null olmalıdır.",
|
||||
"subHappEditorLoadInvalid": "Mevcut kurallar geçerli bir Happ yönlendirme bağlantısı veya JSON profili değil.",
|
||||
"subHappEditorLoadRemote": "Uzak URL'ler burada yüklenemez. Önce bir Happ yönlendirme bağlantısı veya JSON profili yapıştırın.",
|
||||
"subHappEditorLoadOff": "Yönlendirme devre dışı. Profil oluşturmak için bir hazır ayar uygulayın veya yönlendirme kurallarını temizleyin.",
|
||||
"subHappEditorNew": "Mevcut kural yok. Boş bir özel profille başlayın.",
|
||||
"subHappBuildDeeplink": "Derin Bağlantı Oluştur",
|
||||
"subHappModalTitle": "Happ Görsel Yönlendirme Kuralı Oluşturucu",
|
||||
"subHappDirectDomains": "Doğrudan Alan Adları (Atla)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Назви пакетів Android",
|
||||
"subHappPerAppListDesc": "Назви пакетів програм Android через кому для включення або виключення (напр. org.telegram.messenger).",
|
||||
"subHappPresetIran": "Обхід для Ірану",
|
||||
"subHappPresetChina": "Китай напряму",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "Китай напряму (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Додати AdBlock",
|
||||
"subHappApplyPreset": "Застосувати шаблон",
|
||||
"subHappPresetGlobal": "Усе через проксі",
|
||||
"subHappPresetLocal": "Усе через проксі, локальна мережа напряму",
|
||||
"subHappPresetOff": "Вимкнути маршрутизацію (happ://routing/off)",
|
||||
"subHappColorBlue": "Синій (стандартний / за замовчуванням)",
|
||||
"subHappColorGreen": "Зелений (успіх)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Обхід (виключити вказані програми)",
|
||||
"subHappPresetApplied": "Шаблон Happ застосовано до правил маршрутизації",
|
||||
"subHappPresets": "Шаблони маршрутизації",
|
||||
"subHappPresetsDesc": "Готові шаблони правил маршрутизації, адаптовані для клієнтів Happ.",
|
||||
"subHappPresetsDesc": "Виберіть базовий шаблон і за потреби увімкніть блокування реклами, потім застосуйте його, щоб замінити поточні правила маршрутизації.",
|
||||
"subHappVisualBuilder": "Візуальний генератор правил",
|
||||
"subHappVisualBuilderDesc": "Створіть власний діплінк маршрутизації зі списків доменів та IP.",
|
||||
"subHappVisualBuilderDesc": "Завантажте поточний профіль маршрутизації та відредагуйте його правила або повний JSON.",
|
||||
"subHappEditorBasic": "Базові правила",
|
||||
"subHappEditorAdvanced": "Розширений редактор",
|
||||
"subHappEditorListHint": "Одне правило на рядок. Решта полів профілю зберігається.",
|
||||
"subHappEditorAdvancedHint": "Редагуйте повний JSON-профіль. Зміни синхронізуються з вкладкою базових правил.",
|
||||
"subHappEditorInvalidJson": "Введіть об'єкт JSON. Списки маршрутизації, якщо вони є, мають бути масивами рядків або null.",
|
||||
"subHappEditorLoadInvalid": "Поточні правила не є дійсним посиланням маршрутизації Happ або JSON-профілем.",
|
||||
"subHappEditorLoadRemote": "Тут неможливо завантажити віддалені URL. Спочатку вставте посилання маршрутизації Happ або JSON-профіль.",
|
||||
"subHappEditorLoadOff": "Маршрутизацію вимкнено. Застосуйте шаблон або очистьте правила маршрутизації, щоб створити профіль.",
|
||||
"subHappEditorNew": "Поточних правил немає. Почніть із порожнього власного профілю.",
|
||||
"subHappBuildDeeplink": "Згенерувати діплінк",
|
||||
"subHappModalTitle": "Візуальний генератор правил маршрутизації Happ",
|
||||
"subHappDirectDomains": "Домени напряму (обхід)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Tên gói Android",
|
||||
"subHappPerAppListDesc": "Tên gói ứng dụng Android cách nhau bằng dấu phẩy để bao gồm hoặc loại trừ (ví dụ org.telegram.messenger).",
|
||||
"subHappPresetIran": "Bỏ qua Iran",
|
||||
"subHappPresetChina": "Trực tiếp Trung Quốc",
|
||||
"subHappPresetAdblock": "AdBlock",
|
||||
"subHappPresetChina": "Trực tiếp Trung Quốc (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "Thêm AdBlock",
|
||||
"subHappApplyPreset": "Áp dụng mẫu",
|
||||
"subHappPresetGlobal": "Proxy toàn bộ",
|
||||
"subHappPresetLocal": "Proxy toàn bộ, bỏ qua mạng cục bộ",
|
||||
"subHappPresetOff": "Tắt định tuyến (happ://routing/off)",
|
||||
"subHappColorBlue": "Xanh dương (Tiêu chuẩn / Mặc định)",
|
||||
"subHappColorGreen": "Xanh lá (Thành công)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "Bỏ qua (Loại trừ ứng dụng trong danh sách)",
|
||||
"subHappPresetApplied": "Đã áp dụng mẫu Happ vào quy tắc định tuyến",
|
||||
"subHappPresets": "Mẫu định tuyến",
|
||||
"subHappPresetsDesc": "Các mẫu quy tắc định tuyến cấu hình sẵn dành cho ứng dụng Happ.",
|
||||
"subHappPresetsDesc": "Chọn mẫu cơ bản và tùy chọn chặn quảng cáo, sau đó áp dụng để thay thế các quy tắc định tuyến hiện tại.",
|
||||
"subHappVisualBuilder": "Trình tạo quy tắc trực quan",
|
||||
"subHappVisualBuilderDesc": "Tạo deeplink định tuyến tùy chỉnh từ danh sách tên miền và IP.",
|
||||
"subHappVisualBuilderDesc": "Tải cấu hình định tuyến hiện tại và chỉnh sửa các quy tắc hoặc toàn bộ JSON.",
|
||||
"subHappEditorBasic": "Quy tắc cơ bản",
|
||||
"subHappEditorAdvanced": "Trình chỉnh sửa nâng cao",
|
||||
"subHappEditorListHint": "Mỗi dòng một quy tắc. Các trường khác của cấu hình được giữ nguyên.",
|
||||
"subHappEditorAdvancedHint": "Chỉnh sửa toàn bộ cấu hình JSON. Các thay đổi được đồng bộ với thẻ quy tắc cơ bản.",
|
||||
"subHappEditorInvalidJson": "Nhập một đối tượng JSON. Các danh sách định tuyến, nếu có, phải là mảng chuỗi hoặc null.",
|
||||
"subHappEditorLoadInvalid": "Các quy tắc hiện tại không phải là liên kết định tuyến Happ hoặc cấu hình JSON hợp lệ.",
|
||||
"subHappEditorLoadRemote": "Không thể tải URL từ xa tại đây. Hãy dán liên kết định tuyến Happ hoặc cấu hình JSON trước.",
|
||||
"subHappEditorLoadOff": "Định tuyến đã bị tắt. Áp dụng một mẫu hoặc xóa các quy tắc định tuyến để tạo cấu hình.",
|
||||
"subHappEditorNew": "Hiện chưa có quy tắc. Bắt đầu với cấu hình tùy chỉnh trống.",
|
||||
"subHappBuildDeeplink": "Tạo deeplink",
|
||||
"subHappModalTitle": "Trình tạo quy tắc định tuyến trực quan cho Happ",
|
||||
"subHappDirectDomains": "Tên miền trực tiếp (bỏ qua)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Android 应用包名列表",
|
||||
"subHappPerAppListDesc": "以逗号分隔的 Android 应用包名,用于包含或排除(例如 org.telegram.messenger)。",
|
||||
"subHappPresetIran": "伊朗直连规则 (Iran Bypass)",
|
||||
"subHappPresetChina": "大陆直连规则 (China Direct)",
|
||||
"subHappPresetAdblock": "广告拦截规则 (AdBlock)",
|
||||
"subHappPresetChina": "大陆直连规则 (Bypass-CN)",
|
||||
"subHappIncludeAdblock": "附加 AdBlock",
|
||||
"subHappApplyPreset": "应用预设",
|
||||
"subHappPresetGlobal": "全局代理规则 (Global)",
|
||||
"subHappPresetLocal": "全局代理,绕过局域网 (Global Bypass Local Network)",
|
||||
"subHappPresetOff": "禁用路由 (happ://routing/off)",
|
||||
"subHappColorBlue": "蓝色 (标准 / 默认)",
|
||||
"subHappColorGreen": "绿色 (成功)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "绕过 (排除列表中的应用)",
|
||||
"subHappPresetApplied": "Happ 预设规则已应用",
|
||||
"subHappPresets": "预设路由规则",
|
||||
"subHappPresetsDesc": "专为 Happ 客户端预设调优的常用分流规则。",
|
||||
"subHappPresetsDesc": "选择基础预设和是否附加广告拦截,然后应用以替换当前分流规则。",
|
||||
"subHappVisualBuilder": "可视化规则生成器",
|
||||
"subHappVisualBuilderDesc": "通过域名与 IP 列表快速生成自定义 Happ 路由 DeepLink。",
|
||||
"subHappVisualBuilderDesc": "加载当前路由配置,编辑其规则或完整 JSON。",
|
||||
"subHappEditorBasic": "基础规则",
|
||||
"subHappEditorAdvanced": "扩展编辑器",
|
||||
"subHappEditorListHint": "每行一条规则。配置中的其他字段会保留。",
|
||||
"subHappEditorAdvancedHint": "编辑完整的 JSON 配置。更改会与基础规则选项卡同步。",
|
||||
"subHappEditorInvalidJson": "请输入 JSON 对象。路由列表字段(如存在)必须为字符串数组或 null。",
|
||||
"subHappEditorLoadInvalid": "当前规则不是有效的 Happ 路由链接或 JSON 配置。",
|
||||
"subHappEditorLoadRemote": "此处无法加载远程 URL。请先粘贴 Happ 路由链接或 JSON 配置。",
|
||||
"subHappEditorLoadOff": "路由已禁用。请应用预设或清空路由规则,以创建配置。",
|
||||
"subHappEditorNew": "当前没有规则。从空的自定义配置开始。",
|
||||
"subHappBuildDeeplink": "生成 DeepLink",
|
||||
"subHappModalTitle": "Happ 路由规则可视化生成器",
|
||||
"subHappDirectDomains": "直连域名 (Direct)",
|
||||
|
||||
@@ -1572,9 +1572,11 @@
|
||||
"subHappPerAppList": "Android 套件名稱",
|
||||
"subHappPerAppListDesc": "要納入或排除的 Android 應用程式套件名稱,以逗號分隔(例如 org.telegram.messenger)。",
|
||||
"subHappPresetIran": "伊朗直連",
|
||||
"subHappPresetChina": "中國直連",
|
||||
"subHappPresetAdblock": "廣告封鎖(AdBlock)",
|
||||
"subHappPresetChina": "中國直連(Bypass-CN)",
|
||||
"subHappIncludeAdblock": "附加 AdBlock",
|
||||
"subHappApplyPreset": "套用預設",
|
||||
"subHappPresetGlobal": "全域代理",
|
||||
"subHappPresetLocal": "全域代理,略過區域網路 (Global Bypass Local Network)",
|
||||
"subHappPresetOff": "停用路由(happ://routing/off)",
|
||||
"subHappColorBlue": "藍色(標準 / 預設)",
|
||||
"subHappColorGreen": "綠色(成功)",
|
||||
@@ -1598,9 +1600,18 @@
|
||||
"subHappPerAppBypass": "繞過(排除清單中的應用程式)",
|
||||
"subHappPresetApplied": "已將 Happ 範本套用至路由規則",
|
||||
"subHappPresets": "路由範本",
|
||||
"subHappPresetsDesc": "為 Happ 用戶端量身打造的預先設定路由規則範本。",
|
||||
"subHappPresetsDesc": "選擇基礎預設及是否附加廣告封鎖,然後套用以取代目前的分流規則。",
|
||||
"subHappVisualBuilder": "視覺化規則產生器",
|
||||
"subHappVisualBuilderDesc": "從網域與 IP 清單建立自訂路由深層連結。",
|
||||
"subHappVisualBuilderDesc": "載入目前的路由設定檔,編輯其規則或完整 JSON。",
|
||||
"subHappEditorBasic": "基礎規則",
|
||||
"subHappEditorAdvanced": "進階編輯器",
|
||||
"subHappEditorListHint": "每行一條規則。設定檔中的其他欄位會保留。",
|
||||
"subHappEditorAdvancedHint": "編輯完整的 JSON 設定檔。變更會與基礎規則分頁同步。",
|
||||
"subHappEditorInvalidJson": "請輸入 JSON 物件。路由清單欄位(若存在)必須為字串陣列或 null。",
|
||||
"subHappEditorLoadInvalid": "目前的規則不是有效的 Happ 路由連結或 JSON 設定檔。",
|
||||
"subHappEditorLoadRemote": "此處無法載入遠端 URL。請先貼上 Happ 路由連結或 JSON 設定檔。",
|
||||
"subHappEditorLoadOff": "路由已停用。請套用預設或清空路由規則,以建立設定檔。",
|
||||
"subHappEditorNew": "目前沒有規則。從空白的自訂設定檔開始。",
|
||||
"subHappBuildDeeplink": "產生深層連結",
|
||||
"subHappModalTitle": "Happ 視覺化路由規則產生器",
|
||||
"subHappDirectDomains": "直連網域(繞過)",
|
||||
|
||||
Reference in New Issue
Block a user