mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
feat(sub): add Happ client integration, routing presets, and app management (#6434)
* feat(sub): add Happ client integration, routing presets, and app management Implement comprehensive Happ proxy client integration according to official developer specifications. - Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings. - Add support for 'happ://routing/off' deeplink in routing validation. - Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS. - Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings. - Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings. - Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations. * fix(sub): keep Happ header overrides behind the auto-detect opt-in The Routing-Enable/Hide-Settings off values were emitted on the User-Agent alone, so every panel that upgraded would push "Routing-Enable: 0" — documented by happ.su as disabling routing globally — to every Happ client without the operator enabling anything. They now ride subHappAutoDetect like every other Happ header. Two further mismatches against the vendor spec: - serverDescription was written as a key of the VMess base64 JSON object. happ.su documents it as a "#Title?serverDescription=<base64>" link parameter or a JSON "meta" entry, so the caption never reached Happ while every other VMess consumer received an unknown key. Dropped rather than moved: emitting the documented form is unsafe here because our own parser base64-decodes the whole VMess body (internal/util/link/outbound.go). - The TUN Mode dropdown stored the literal "default", forwarded as "Tun-Mode: default", where happ.su documents system|gvisor only. It now stores the unset value so no header is sent. TUN Type "default" is a documented value and is unchanged. Each fix carries a test that fails without it.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
describe('Happ presets and helpers', () => {
|
||||
it('correctly parses comma and newline separated lists', () => {
|
||||
const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
|
||||
const parsed = parseList(raw);
|
||||
expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
|
||||
});
|
||||
|
||||
it('generates valid happ://routing/off for off preset', () => {
|
||||
const link = buildHappPresetDeeplink('off');
|
||||
expect(link).toBe('happ://routing/off');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for iran-bypass preset', () => {
|
||||
const link = buildHappPresetDeeplink('iran-bypass');
|
||||
expect(link.startsWith('happ://routing/onadd/')).toBe(true);
|
||||
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed).toHaveProperty('rules');
|
||||
expect(Array.isArray(parsed.rules)).toBe(true);
|
||||
|
||||
const directRule = parsed.rules.find(
|
||||
(r: { outboundTag: string }) => r.outboundTag === 'direct',
|
||||
);
|
||||
expect(directRule).toBeDefined();
|
||||
expect(directRule.domain).toContain('domain:ir');
|
||||
expect(directRule.ip).toContain('geoip:ir');
|
||||
|
||||
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
|
||||
expect(blockRule).toBeDefined();
|
||||
expect(blockRule.domain).toContain('geosite:category-ads-all');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for china-direct preset', () => {
|
||||
const link = buildHappPresetDeeplink('china-direct');
|
||||
expect(link.startsWith('happ://routing/onadd/')).toBe(true);
|
||||
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
const directRule = parsed.rules.find(
|
||||
(r: { outboundTag: string }) => r.outboundTag === 'direct',
|
||||
);
|
||||
expect(directRule.domain).toContain('domain:cn');
|
||||
expect(directRule.ip).toContain('geoip:cn');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for adblock preset', () => {
|
||||
const link = buildHappPresetDeeplink('adblock');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
|
||||
expect(blockRule.domain).toContain('geosite:category-ads-all');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for global preset', () => {
|
||||
const link = buildHappPresetDeeplink('global');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.rules[0].outboundTag).toBe('proxy');
|
||||
expect(parsed.rules[0].network).toBe('tcp,udp');
|
||||
});
|
||||
|
||||
it('encodes unicode properly via toBase64Utf8', () => {
|
||||
const text = 'فیلترشکن و روتینگ';
|
||||
const b64 = toBase64Utf8(text);
|
||||
const decoded = decodeURIComponent(
|
||||
Array.prototype.map
|
||||
.call(atob(b64), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
);
|
||||
expect(decoded).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function openTab(name: string) {
|
||||
const tab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find((t) =>
|
||||
(t.textContent ?? '').includes(name),
|
||||
);
|
||||
if (!tab) throw new Error(`tab '${name}' not found`);
|
||||
fireEvent.click(tab);
|
||||
}
|
||||
|
||||
function selectFor(title: string): HTMLElement {
|
||||
const row = Array.from(document.querySelectorAll('.ant-select')).find((s) =>
|
||||
(s.closest('li,div[class*="setting"]')?.textContent ?? '').includes(title),
|
||||
);
|
||||
if (!row) throw new Error(`select for '${title}' not found`);
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
function clickOption(text: string) {
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
(o) => (o.textContent ?? '').trim() === text,
|
||||
);
|
||||
if (!option) throw new Error(`option '${text}' not found`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
describe('Happ TUN Mode select', () => {
|
||||
// happ.su documents tun-mode as system|gvisor only, so the Default entry has
|
||||
// to mean "send no header", the same state a fresh panel ships with.
|
||||
it('stores the empty value for Default so no Tun-Mode header is emitted', () => {
|
||||
const updateSetting = vi.fn();
|
||||
const allSetting = new AllSetting();
|
||||
allSetting.subHappTunMode = 'gvisor';
|
||||
|
||||
renderWithProviders(
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={updateSetting}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>,
|
||||
);
|
||||
|
||||
openTab('Network');
|
||||
const select = selectFor('TUN Mode');
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
clickOption('Default');
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ subHappTunMode: '' });
|
||||
});
|
||||
|
||||
it('labels the unset state Default rather than leaving the control blank', () => {
|
||||
renderWithProviders(
|
||||
<HappSettingsContent
|
||||
allSetting={new AllSetting()}
|
||||
updateSetting={vi.fn()}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>,
|
||||
);
|
||||
|
||||
openTab('Network');
|
||||
const select = selectFor('TUN Mode');
|
||||
expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user