Merge branch 'temp-upstream-rebrand' into main

Resolves merge conflicts:
- install.sh, build.sh, .github/workflows/build.yml: Reverted to Dushnilin's versions to avoid installing SourceForge mirrors.
- fe-app-tachyon: Resolved type, formatting and copyOutbound conflicts, keeping both Dushnilin's custom features and ushan0v's frontend performance optimizations.
- backend: Kept Dushnilin's ucode refactorings, Telegram bot integration, and release repository URL (Dushnilin/tachyon) while merging updates.uc cache format v8 & persistent cache format v7.
- tests: Merged tests from both branches, preserving ucode -L wrappers for local test execution.
This commit is contained in:
Dushnilin
2026-07-14 08:01:45 +03:00
175 changed files with 2899 additions and 1696 deletions
+3 -5
View File
@@ -7,6 +7,7 @@ on:
- 'tachyon/Makefile'
- 'build.sh'
- 'tests/**'
- '.github/workflows/build.yml'
- '.github/workflows/backend-ci.yml'
push:
branches:
@@ -16,6 +17,7 @@ on:
- 'tachyon/Makefile'
- 'build.sh'
- 'tests/**'
- '.github/workflows/build.yml'
- '.github/workflows/backend-ci.yml'
jobs:
@@ -54,8 +56,4 @@ jobs:
- name: Run backend tests
shell: bash
run: |
for test_script in tests/*.sh; do
echo "::group::$test_script"
bash "$test_script"
echo "::endgroup::"
done
printf '%s\0' tests/*.sh | xargs -0 -n1 -P4 bash
+1 -1
View File
@@ -1,4 +1,4 @@
import fs from 'fs/promises';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
+1 -1
View File
@@ -1,4 +1,4 @@
import fs from 'fs/promises';
import fs from 'fs/promises';
import path from 'path';
import glob from 'fast-glob';
import { parse } from '@babel/parser';
+157 -15
View File
@@ -1,15 +1,28 @@
<<<<<<<< HEAD:fe-app-tachyon/locales/tachyon.ru.po
# RU translations for TACHYON package.
# Copyright (C) 2026 THE TACHYON COPYRIGHT HOLDER
# This file is distributed under the same license as the TACHYON package.
# Dushnilin, 2026.
========
# RU translations for TACHYON package.
# Copyright (C) 2026 THE TACHYON COPYRIGHT HOLDER
# This file is distributed under the same license as the TACHYON package.
# ushan0v, 2026.
>>>>>>>> temp-upstream-rebrand:luci-app-tachyon/po/ru/tachyon.po
#
msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
<<<<<<<< HEAD:fe-app-tachyon/locales/tachyon.ru.po
"POT-Creation-Date: 2026-07-13 23:43+0300\n"
"PO-Revision-Date: 2026-07-13 23:43+0300\n"
"Last-Translator: Dushnilin\n"
========
"POT-Creation-Date: 2026-07-13 01:05+0300\n"
"PO-Revision-Date: 2026-07-13 01:05+0300\n"
"Last-Translator: ushan0v\n"
>>>>>>>> temp-upstream-rebrand:luci-app-tachyon/po/ru/tachyon.po
"Language-Team: none\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
@@ -77,6 +90,9 @@ msgstr "Добавьте URL или локальные пути к списка
msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила."
msgid "Add URLTest / Priority group"
msgstr "Добавить URLTest / Приоритет группу"
msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки"
@@ -86,6 +102,12 @@ msgstr "Дополнительно исключать серверы по про
msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters."
msgstr "Дополнительно скрывать серверы по протоколу, транспорту и защите. Скрывать серверы, совпавшие по указанным параметрам."
msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно показывать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Address"
msgstr "Адрес"
@@ -245,6 +267,9 @@ msgstr "Не удалось получить результаты проверк
msgid "Cannot save server"
msgstr "Не удалось сохранить сервер"
msgid "Cannot save settings"
msgstr "Не удалось сохранить настройки"
msgid "Cascade connection"
msgstr "Каскадное подключение"
@@ -665,6 +690,9 @@ msgstr "Исключить из этого уровня серверы с одн
msgid "Exclude servers with one of the selected transports from this level."
msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов."
msgid "Exclude URLTest / Priority group"
msgstr "Исключить URLTest / Приоритет группу"
msgid "Expecting: %s"
msgstr "Ожидается: %s"
@@ -716,6 +744,9 @@ msgstr "Интервал поиска более быстрого сервера
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Filter the servers that will be displayed on the dashboard."
msgstr "Отфильтровать серверы, которые будут отображаться в дашборде."
msgid "Firewall WAN port"
msgstr "Порт в firewall для WAN"
@@ -725,6 +756,66 @@ msgstr "Исправьте подсвеченные поля и сохранит
msgid "Forced device routing"
msgstr "Принудительная маршрутизация устройств"
<<<<<<<< HEAD:fe-app-tachyon/locales/tachyon.ru.po
========
msgid "Tachyon Error"
msgstr "Ошибка Tachyon"
msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here."
msgstr "Tachyon сам управляет pid-файлами ciadpi, поэтому параметры pidfile здесь запрещены."
msgid "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here."
msgstr "Tachyon сам управляет жизненным циклом ciadpi, поэтому режим daemon здесь запрещён."
msgid "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here."
msgstr "Tachyon сам управляет жизненным циклом nfqws, поэтому режим daemon здесь запрещён."
msgid "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here."
msgstr "Tachyon сам управляет жизненным циклом nfqws2, поэтому режим daemon здесь запрещён."
msgid "Tachyon service is stopped. Start the service to display connections."
msgstr "Служба Tachyon остановлена. Запустите сервис для отображения подключений."
msgid "Tachyon service is stopped. Start the service to display the dashboard."
msgstr "Служба Tachyon остановлена. Запустите сервис для отображения дашборда."
msgid "Tachyon Settings"
msgstr "Настройки Tachyon"
msgid "Tachyon will not modify your DHCP configuration"
msgstr "Tachyon не будет изменять вашу конфигурацию DHCP"
msgid "Tachyon-managed ciadpi runtime has restarted"
msgstr "ciadpi Tachyon перезапускался"
msgid "Tachyon-managed ciadpi runtime is not ready"
msgstr "ciadpi Tachyon не готов"
msgid "Tachyon-managed ciadpi runtime is not running"
msgstr "ciadpi Tachyon не запущен"
msgid "Tachyon-managed ciadpi runtime is ready"
msgstr "ciadpi Tachyon готов"
msgid "Tachyon-managed nfqws runtime is not ready"
msgstr "NFQWS Tachyon не готов"
msgid "Tachyon-managed nfqws runtime is not running"
msgstr "NFQWS Tachyon не запущен"
msgid "Tachyon-managed nfqws runtime is ready"
msgstr "NFQWS Tachyon готов"
msgid "Tachyon-managed nfqws2 runtime is not ready"
msgstr "NFQWS2 Tachyon не готов"
msgid "Tachyon-managed nfqws2 runtime is not running"
msgstr "NFQWS2 Tachyon не запущен"
msgid "Tachyon-managed nfqws2 runtime is ready"
msgstr "NFQWS2 Tachyon готов"
>>>>>>>> temp-upstream-rebrand:luci-app-tachyon/po/ru/tachyon.po
msgid "Fronting IP"
msgstr "IP фронтинга"
@@ -755,24 +846,36 @@ msgstr "gRPC-сервис"
msgid "Handshake timeout"
msgstr "Таймаут рукопожатия"
msgid "Hide added servers"
msgstr "Скрывать добавленные серверы"
msgid "Hide cascade connection nodes"
msgstr "Скрывать узлы каскадных подключений"
msgid "Hide dashboard servers added to Priority"
msgstr "Скрывать серверы, добавленные в Priority, из дашборда"
msgid "Hide dashboard servers added to URLTest"
msgstr "Скрывать серверы из дашборда, которые добавлены в URLTest"
msgid "Hide individual nodes that are already included in imported subscription URLTest groups"
msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки"
msgid "Hide intermediate nodes used as detours by other subscription nodes"
msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки"
msgid "Hide selected servers."
msgstr "Скрывать выбранные серверы."
msgid "Hide servers from selected groups without repeating their filtering criteria."
msgstr "Скрывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Hide servers from the specified countries."
msgstr "Скрывать серверы из указанных стран."
msgid "Hide servers whose names match the expression."
msgstr "Скрывать серверы, имена которых совпадают с выражением."
msgid "Hide servers with one of the selected protocols."
msgstr "Скрывать серверы с одним из выбранных протоколов."
msgid "Hide servers with one of the selected security types."
msgstr "Скрывать серверы с одним из выбранных типов защиты."
msgid "Hide servers with one of the selected transports."
msgstr "Скрывать серверы с одним из выбранных транспортов."
msgid "Hide URLTest group nodes"
msgstr "Скрывать узлы URLTest групп"
@@ -866,14 +969,14 @@ msgstr "Задержка при мониторинге интерфейсов"
msgid "Interface monitoring for Bad WAN"
msgstr "Мониторинг интерфейса для Bad WAN"
msgid "Interrupt existing connections"
msgstr "Прерывать существующие соединения"
msgid "Interrupt connections"
msgstr "Прерывать соединения"
msgid "Interrupt existing connections when priority failover switches server"
msgstr "Разрывать существующие соединения при переключении сервера приоритетом"
msgid "Interrupt connections when priority failover switches server"
msgstr "Разрывать соединения при переключении сервера приоритетом"
msgid "Interrupt existing connections when URLTest switches the selected server"
msgstr "Прерывать существующие соединения, когда URLTest переключает выбранный сервер"
msgid "Interrupt connections when URLTest switches the selected server"
msgstr "Прерывать соединения, когда URLTest переключает выбранный сервер"
msgid "Interval"
msgstr "Интервал"
@@ -1640,6 +1743,9 @@ msgstr "Выберите транзитную секцию"
msgid "Select an existing network interface"
msgstr "Выберите существующий сетевой интерфейс"
msgid "Select an existing URLTest or Priority group"
msgstr "Выберите существующую URLTest или Приоритет группу"
msgid "Select an intermediate section"
msgstr "Выберите промежуточную секцию"
@@ -1733,6 +1839,9 @@ msgstr "server_port должен быть числом от 1 до 65535"
msgid "Servers"
msgstr "Серверы"
msgid "Servers on dashboard"
msgstr "Серверы в дашборде"
msgid "Service action failed"
msgstr "Действие сервиса не выполнено"
@@ -1748,6 +1857,27 @@ msgstr "Настройки"
msgid "Show metadata on dashboard"
msgstr "Отображать метаданные в дашборде"
msgid "Show only selected servers."
msgstr "Показывать только выбранные серверы."
msgid "Show only servers with one of the selected protocols."
msgstr "Показывать только серверы с одним из выбранных протоколов."
msgid "Show only servers with one of the selected security types."
msgstr "Показывать только серверы с одним из выбранных типов защиты."
msgid "Show only servers with one of the selected transports."
msgstr "Показывать только серверы с одним из выбранных транспортов."
msgid "Show servers from selected groups without repeating their filtering criteria."
msgstr "Показывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Show servers only from the specified countries."
msgstr "Показывать серверы только из указанных стран."
msgid "Show servers whose names match the expression."
msgstr "Показывать серверы, имена которых совпадают с выражением."
msgid "Show sing-box config"
msgstr "Показать sing-box конфигурацию"
@@ -2093,6 +2223,18 @@ msgstr "Таймаут недоступности"
msgid "Unexpected ByeDPI strategy argument: %s"
msgstr "Неожиданный аргумент стратегии ByeDPI: %s"
<<<<<<<< HEAD:fe-app-tachyon/locales/tachyon.ru.po
========
msgid "Unexpected Tachyon-managed ciadpi runtime is running"
msgstr "Обнаружен лишний ciadpi Tachyon"
msgid "Unexpected Tachyon-managed nfqws runtime is running"
msgstr "Обнаружен лишний NFQWS Tachyon"
msgid "Unexpected Tachyon-managed nfqws2 runtime is running"
msgstr "Обнаружен лишний NFQWS2 Tachyon"
>>>>>>>> temp-upstream-rebrand:luci-app-tachyon/po/ru/tachyon.po
msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value."
msgstr "Неожиданный отдельный токен. Используйте явные флаги вида --имя или --имя=значение."
+1 -1
View File
@@ -8,7 +8,7 @@
"format:js": "prettier --write ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"build": "tsup src/main.ts",
"build": "tsc --noEmit && tsup src/main.ts",
"dev": "tsup src/main.ts --watch",
"test": "vitest",
"ci": "npm run format && npm run lint --max-warnings=0 && npm run test -- --run && npm run build",
-1
View File
@@ -2,7 +2,6 @@ export * from './parseValueList';
export * from './injectGlobalStyles';
export * from './withTimeout';
export * from './executeShellCommand';
export * from './maskIP';
export * from './getProxyUrlName';
export * from './onMount';
export * from './getClashApiUrl';
@@ -3,24 +3,6 @@ const COPYABLE_PROXY_URI_RE =
const HTTP_PROXY_URI_RE =
/^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i;
const COPYABLE_PROXY_OUTBOUND_TYPES = new Set([
'vless',
'vmess',
'trojan',
'shadowsocks',
'ss',
'shadowsocksr',
'ssr',
'hysteria2',
'hy2',
'tuic',
'socks',
'socks4',
'socks4a',
'socks5',
'http',
]);
export function isCopyableProxyLink(link?: string) {
const value = (link || '').trim();
if (COPYABLE_PROXY_URI_RE.test(value)) {
@@ -35,7 +17,3 @@ export function isCopyableProxyLink(link?: string) {
const port = Number(httpProxy[1]);
return Number.isInteger(port) && port >= 1 && port <= 65535;
}
export function isCopyableProxyOutboundType(type?: string) {
return COPYABLE_PROXY_OUTBOUND_TYPES.has((type || '').trim().toLowerCase());
}
-5
View File
@@ -1,5 +0,0 @@
export function maskIP(ip: string = ''): string {
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`);
}
@@ -1,8 +1,5 @@
import { describe, expect, it } from 'vitest';
import {
isCopyableProxyLink,
isCopyableProxyOutboundType,
} from '../isCopyableProxyLink';
import { isCopyableProxyLink } from '../isCopyableProxyLink';
const copyableLinks = [
'vless://uuid@example.com:443',
@@ -44,20 +41,3 @@ describe('isCopyableProxyLink', () => {
});
});
});
describe('isCopyableProxyOutboundType', () => {
it('accepts protocol outbound types from imported proxy links', () => {
expect(isCopyableProxyOutboundType('VLESS')).toBe(true);
expect(isCopyableProxyOutboundType('shadowsocks')).toBe(true);
expect(isCopyableProxyOutboundType('hysteria2')).toBe(true);
expect(isCopyableProxyOutboundType('socks')).toBe(true);
expect(isCopyableProxyOutboundType('http')).toBe(true);
});
it('rejects group, service and routing-only outbound types', () => {
expect(isCopyableProxyOutboundType('urltest')).toBe(false);
expect(isCopyableProxyOutboundType('selector')).toBe(false);
expect(isCopyableProxyOutboundType('direct')).toBe(false);
expect(isCopyableProxyOutboundType('block')).toBe(false);
});
});
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { maskIP } from '../maskIP';
export const validIPs = [
['Standard private IP', '192.168.0.1', 'XX.XX.XX.1'],
['Public IP', '8.8.8.8', 'XX.XX.XX.8'],
['Mixed digits', '10.0.255.99', 'XX.XX.XX.99'],
['Edge values', '255.255.255.255', 'XX.XX.XX.255'],
['Zeros', '0.0.0.0', 'XX.XX.XX.0'],
];
export const invalidIPs = [
['Empty string', '', ''],
['Missing octets', '192.168.1', '192.168.1'],
['Extra octets', '1.2.3.4.5', '1.2.3.4.5'],
['Letters inside', 'abc.def.ghi.jkl', 'abc.def.ghi.jkl'],
['Spaces inside', '1. 2.3.4', '1. 2.3.4'],
['Just dots', '...', '...'],
['IP with port', '127.0.0.1:8080', '127.0.0.1:8080'],
['IP with text', 'ip=192.168.0.1', 'ip=192.168.0.1'],
];
describe('maskIP', () => {
describe.each(validIPs)('Valid IPv4: %s', (_desc, ip, expected) => {
it(`masks "${ip}" → "${expected}"`, () => {
expect(maskIP(ip)).toBe(expected);
});
});
describe.each(invalidIPs)(
'Invalid or malformed IP: %s',
(_desc, ip, expected) => {
it(`returns original string for "${ip}"`, () => {
expect(maskIP(ip)).toBe(expected);
});
},
);
it('defaults to empty string if no param passed', () => {
expect(maskIP()).toBe('');
});
});
@@ -1,4 +1,4 @@
import fs from 'node:fs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@babel/parser';
+2 -1
View File
@@ -9,6 +9,7 @@ type HtmlAttributes<T extends HtmlTag = 'div'> = Partial<
'aria-busy'?: string;
'aria-disabled'?: string;
'aria-label'?: string;
'data-latency-section'?: string;
click?: (event: MouseEvent) => void;
onclick?: (event: MouseEvent) => void;
}
@@ -49,7 +50,7 @@ declare global {
const _ = (_key: string) => string;
const ui = {
showModal: (_title: string, _content: HtmlElement) => undefined,
showModal: (_title: string, _content: HTMLElement) => undefined,
hideModal: () => undefined,
addNotification: (
_title: string,
+2 -6
View File
@@ -1,4 +1,4 @@
'use strict';
'use strict';
'require baseclass';
'require fs';
'require uci';
@@ -7,11 +7,7 @@
if (typeof structuredClone !== 'function')
globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
export {
validateIP,
validateIPV4,
validateIPv6,
} from './validators/validateIp';
export { validateIP } from './validators/validateIp';
export { validateDomain } from './validators/validateDomain';
export { validateDNS } from './validators/validateDns';
export { validateUrl } from './validators/validateUrl';
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -1,4 +1,4 @@
import { getConfigSections } from './getConfigSections';
import { getConfigSections } from './getConfigSections';
import { ClashAPI, Tachyon } from '../../types';
import {
canUseDirectClashApi,
@@ -27,7 +27,6 @@ type DashboardSectionCache = {
version?: number;
section?: string;
links?: Record<string, string>;
linkRefs?: Record<string, unknown>;
outboundMetadata?: Tachyon.GetOutboundMetadata;
urltestGroups?: Record<string, UrlTestCacheGroup>;
priorityGroups?: Record<string, PriorityCacheGroup>;
@@ -70,7 +69,6 @@ type PriorityCacheGroup = {
fastest_check_interval?: string;
interrupt_exist_connections?: boolean;
pin_dashboard?: boolean;
hide_added_outbounds?: boolean;
outbounds?: string[];
levels?: PriorityCacheLevel[];
};
@@ -100,7 +98,6 @@ type UrlTestConfig = {
displayName: string;
settings: ItemSettings;
pinDashboard: boolean;
hideAddedOutbounds: boolean;
showDetectedCountries: boolean;
};
@@ -110,7 +107,6 @@ type PriorityConfig = {
displayName: string;
settings: ItemSettings;
pinDashboard: boolean;
hideAddedOutbounds: boolean;
healthUrl: string;
activeCheckInterval: string;
checkTimeout: string;
@@ -136,10 +132,6 @@ function getDisplayName(section: Tachyon.ConfigSection) {
return section.label || section['.name'];
}
function getSectionAction(section: Tachyon.ConfigSection) {
return section.action || '';
}
function getSettingsSection(configSections: Tachyon.ConfigSection[]) {
return configSections.find((section) => section['.type'] === 'settings');
}
@@ -286,7 +278,6 @@ function hydrateConfigSections(configSections: Tachyon.ConfigSection[]) {
idle_timeout: item.idle_timeout,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
urltest_filter_mode: item.filter_mode,
detect_server_country: item.detect_server_country,
urltest_include_countries: item.include_countries,
@@ -339,7 +330,6 @@ function hydrateConfigSections(configSections: Tachyon.ConfigSection[]) {
fastest_check_interval: item.fastest_check_interval,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
levels,
};
});
@@ -364,8 +354,10 @@ function getJsonOutbounds(section: Tachyon.ConfigSection) {
return values.length ? values : getListValues(section.outbound_json);
}
function isConnectionAction(action: string) {
return ['connection', 'proxy', 'outbound', 'vpn'].includes(action);
function isConnectionAction(action?: string) {
return Boolean(
action && ['connection', 'proxy', 'outbound', 'vpn'].includes(action),
);
}
function hasSubscriptionSources(section: Tachyon.ConfigSection) {
@@ -661,11 +653,6 @@ function getUrlTestConfigs(section: Tachyon.ConfigSection): UrlTestConfig[] {
displayName: getUrlTestDisplayName(section, id, settings),
settings,
pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true),
hideAddedOutbounds: itemSettingBoolean(
settings,
'hide_added_outbounds',
false,
),
showDetectedCountries:
filteringEnabled &&
itemSettingString(settings, 'detect_server_country', 'flag_emoji') ===
@@ -748,11 +735,6 @@ function getPriorityConfigs(section: Tachyon.ConfigSection): PriorityConfig[] {
displayName: itemSettingString(settings, 'name', id),
settings,
pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true),
hideAddedOutbounds: itemSettingBoolean(
settings,
'hide_added_outbounds',
false,
),
healthUrl: itemSettingString(
settings,
'health_url',
@@ -862,7 +844,6 @@ function buildUrlTestInfo({
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries,
}: {
code: string;
@@ -873,7 +854,6 @@ function buildUrlTestInfo({
manualLinkByCode: Map<string, string>;
cachedProxyLinks: Map<string, string>;
outboundMetadata?: Tachyon.GetOutboundMetadata;
subscriptionCopyableCodes: Set<string>;
showDetectedCountries: boolean;
}): Tachyon.UrlTestInfo {
const childCodes = uniqueCodes(
@@ -889,8 +869,7 @@ function buildUrlTestInfo({
manualLinkByCode.get(childCode) ||
cachedProxyLinks.get(childCode) ||
'';
const canCopyLink =
isCopyableProxyLink(link) || subscriptionCopyableCodes.has(childCode);
const canCopyLink = isCopyableProxyLink(link);
return [
{
@@ -900,7 +879,7 @@ function buildUrlTestInfo({
childEntry,
link,
outboundMetadata,
subscriptionCopyableCodes.has(childCode),
cachedProxyLinks.has(childCode),
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || '',
@@ -940,7 +919,6 @@ function buildPriorityInfo({
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries,
}: {
config: PriorityConfig;
@@ -950,7 +928,6 @@ function buildPriorityInfo({
manualLinkByCode: Map<string, string>;
cachedProxyLinks: Map<string, string>;
outboundMetadata?: Tachyon.GetOutboundMetadata;
subscriptionCopyableCodes: Set<string>;
showDetectedCountries: boolean;
}): Tachyon.PriorityInfo {
const selectedCode = entry?.value.now || '';
@@ -1006,8 +983,7 @@ function buildPriorityInfo({
manualLinkByCode.get(childCode) ||
cachedProxyLinks.get(childCode) ||
'';
const canCopyLink =
isCopyableProxyLink(link) || subscriptionCopyableCodes.has(childCode);
const canCopyLink = isCopyableProxyLink(link);
return {
code: childCode,
@@ -1016,7 +992,7 @@ function buildPriorityInfo({
childEntry,
link,
outboundMetadata,
subscriptionCopyableCodes.has(childCode),
cachedProxyLinks.has(childCode),
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || '',
@@ -1081,7 +1057,6 @@ function buildProxyGroupOutbounds(
outboundMetadata?: Tachyon.GetOutboundMetadata,
urltestGroups: Record<string, UrlTestCacheGroup> = {},
priorityGroups: Record<string, PriorityCacheGroup> = {},
subscriptionCopyableCodes: Set<string> = new Set(),
cachedProxyLinks: Map<string, string> = new Map(),
) {
const sectionName = section['.name'];
@@ -1108,31 +1083,6 @@ function buildProxyGroupOutbounds(
const selectorCodes = selector?.value?.all ?? [];
const urlTestCodes = urlTestConfigs.map((config) => config.code);
const priorityCodes = priorityConfigs.map((config) => config.code);
const urlTestCodeSet = new Set(urlTestCodes);
const priorityCodeSet = new Set(priorityCodes);
const hideAddedCodeSet = new Set<string>();
urlTestEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = urltestGroups[config.code]?.outbounds?.length
? urltestGroups[config.code].outbounds || []
: entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
priorityEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = priorityGroups[config.code]?.outbounds?.length
? priorityGroups[config.code].outbounds || []
: entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
const showDetectedCountries =
urlTestConfigs.some((config) => config.showDetectedCountries) ||
priorityConfigs.some((config) => config.showDetectedCountries);
@@ -1147,17 +1097,7 @@ function buildProxyGroupOutbounds(
...(selectorCodes.length ? selectorCodes : fallbackCodes),
...urlTestCodes,
...priorityCodes,
]).filter((code) => {
if (!hideAddedCodeSet.has(code)) {
return true;
}
return (
urlTestCodeSet.has(code) ||
priorityCodeSet.has(code) ||
isUrlTestProxyEntry(proxyByCode.get(code))
);
});
]);
const outbounds = uniqueCodes(groupCodes).flatMap((code) => {
const item = proxyByCode.get(code);
@@ -1169,8 +1109,7 @@ function buildProxyGroupOutbounds(
}
const link = manualLinkByCode.get(code) || cachedProxyLinks.get(code) || '';
const canCopyLink =
isCopyableProxyLink(link) || subscriptionCopyableCodes.has(code);
const canCopyLink = isCopyableProxyLink(link);
const displayName =
priorityConfig?.displayName ||
urlTestConfig?.displayName ||
@@ -1179,7 +1118,7 @@ function buildProxyGroupOutbounds(
item,
link,
outboundMetadata,
subscriptionCopyableCodes.has(code),
cachedProxyLinks.has(code),
);
const isRuntimeUrlTest = isUrlTestProxyEntry(item);
@@ -1207,7 +1146,6 @@ function buildProxyGroupOutbounds(
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries:
urlTestConfig?.showDetectedCountries || showDetectedCountries,
})
@@ -1221,7 +1159,6 @@ function buildProxyGroupOutbounds(
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries: priorityConfig.showDetectedCountries,
})
: undefined,
@@ -1402,22 +1339,6 @@ function getOutboundMetadata(dashboardCache?: DashboardSectionCache) {
};
}
function getSubscriptionCopyableCodes(dashboardCache?: DashboardSectionCache) {
const legacyLinks = objectMap(dashboardCache?.links);
const linkRefs = dashboardCache?.linkRefs;
const codes = new Set(
Object.entries(legacyLinks)
.filter(([, link]) => isCopyableProxyLink(link))
.map(([code]) => code),
);
if (linkRefs && typeof linkRefs === 'object' && !Array.isArray(linkRefs)) {
Object.keys(linkRefs).forEach((code) => codes.add(code));
}
return codes;
}
function getCachedProxyLinks(dashboardCache?: DashboardSectionCache) {
return new Map(
Object.entries(objectMap(dashboardCache?.links)).filter(([, link]) =>
@@ -1451,13 +1372,12 @@ export async function getDashboardSections(
configSections
.filter(
(section) =>
section.enabled !== '0' &&
isConnectionAction(getSectionAction(section)),
section.enabled !== '0' && isConnectionAction(section.action),
)
.map(async (section) => {
const displayName = getDisplayName(section);
const sectionName = section['.name'];
const sectionAction = getSectionAction(section);
const sectionAction = section.action;
const proxyConfigType = getSectionProxyConfigType(section);
if (isConnectionAction(sectionAction) && shouldUseProxyGroup(section)) {
@@ -1472,9 +1392,6 @@ export async function getDashboardSections(
dashboardCache,
)
: undefined;
const subscriptionCopyableCodes = includeSubscriptionCopyState
? getSubscriptionCopyableCodes(dashboardCache)
: new Set<string>();
const cachedProxyLinks = includeSubscriptionCopyState
? getCachedProxyLinks(dashboardCache)
: new Map<string, string>();
@@ -1487,7 +1404,6 @@ export async function getDashboardSections(
outboundMetadata,
urltestGroups,
priorityGroups,
subscriptionCopyableCodes,
cachedProxyLinks,
);
@@ -1,4 +1,4 @@
import { getConfigSections } from './getConfigSections';
import { getConfigSections } from './getConfigSections';
import { getDashboardSections } from './getDashboardSections';
import { getClashApiSecret } from './getClashApiSecret';
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getConfigSections: vi.fn(),
@@ -87,7 +87,6 @@ function priorityGroup(
fastest_check_interval: '3m',
interrupt_exist_connections: '1',
pin_dashboard: '1',
hide_added_outbounds: '0',
...options,
};
}
@@ -423,59 +422,6 @@ describe('getDashboardSections', () => {
]);
});
it('hides servers already added to the Priority group when enabled', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({ urltests: [], urltest_settings: undefined }),
priorityGroup('pg_main', { hide_added_outbounds: '1' }),
priorityLevel('pl_de'),
]);
mocks.getClashApiProxies.mockResolvedValue({
success: true,
data: {
proxies: {
...clashProxies,
'main-out': proxy('Selector', {
name: 'main-out',
now: 'main-priority-pg_main-out',
all: ['main-1-out', 'main-2-out', 'main-priority-pg_main-out'],
}),
'main-priority-pg_main-out': proxy('Selector', {
name: 'main-priority-pg_main-out',
now: 'main-1-out',
all: ['main-1-out', 'main-2-out'],
}),
},
},
});
mocks.fsRead.mockResolvedValue(
JSON.stringify({
priorityGroups: {
'main-priority-pg_main-out': {
displayName: 'Codex Priority',
outbounds: ['main-1-out', 'main-2-out'],
levels: [
{
id: 'pl_de',
displayName: 'Germany',
order: 0,
outbounds: ['main-1-out', 'main-2-out'],
},
],
},
},
}),
);
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.outbounds.map((item) => item.code)).toEqual([
'main-priority-pg_main-out',
]);
expect(section.latencyTestCodes).toBeUndefined();
});
it('keeps an empty Priority group visible when sing-box skipped the selector', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
@@ -522,31 +468,6 @@ describe('getDashboardSections', () => {
expect(priority?.priorityInfo?.outbounds).toEqual([]);
});
it('hides servers already added to the URLTest group when enabled', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
urltest_settings: urlTestSettings('urltest', {
display_name: 'Fastest',
hide_added_outbounds: '1',
}),
}),
]);
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.latencyTestCode).toBe('main-out');
expect(section.latencyTestCodes).toEqual([
'main-urltest-out',
'main-2-out',
]);
expect(section.outbounds.map((item) => item.code)).toEqual([
'main-urltest-out',
'main-2-out',
]);
});
it('keeps legacy URLTest sections compatible before migration', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
@@ -636,31 +557,6 @@ describe('getDashboardSections', () => {
]);
});
it.each(['exclude', 'include', 'mixed', 'disabled'] as const)(
'hides added URLTest members independently from %s filter mode',
async (urltest_filter_mode) => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
urltest_settings: urlTestSettings('urltest', {
display_name: 'Fastest',
urltest_filter_mode,
hide_added_outbounds: '1',
}),
}),
]);
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.latencyTestCode).toBe('main-out');
expect(section.latencyTestCodes).toEqual([
'main-urltest-out',
'main-2-out',
]);
},
);
it('keeps non-built-in URLTest groups in selector order when latency sorting is disabled', async () => {
mocks.getConfigSections.mockResolvedValue([proxySection()]);
mocks.getClashApiProxies.mockResolvedValue({
@@ -822,72 +718,6 @@ describe('getDashboardSections', () => {
]);
});
it('keeps visible URLTest groups when added leaf servers are hidden', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
urltest_settings: urlTestSettings('urltest', {
display_name: 'Fastest',
hide_added_outbounds: '1',
}),
}),
]);
mocks.getClashApiProxies.mockResolvedValue({
success: true,
data: {
proxies: {
'main-out': proxy('Selector', {
name: 'main-out',
now: 'main-1-out',
all: [
'main-1-out',
'main-provider-urltest-out',
'main-2-out',
'main-3-out',
'main-urltest-out',
],
}),
'main-urltest-out': proxy('URLTest', {
name: 'main-urltest-out',
history: [{ time: '2026-06-11T00:00:00Z', delay: 10 }],
all: ['main-1-out', 'main-3-out'],
}),
'main-provider-urltest-out': proxy('URLTest', {
name: 'Provider URLTest',
history: [{ time: '2026-06-11T00:00:00Z', delay: 150 }],
all: ['provider-hidden-1-out'],
}),
'main-1-out': proxy('VLESS', {
name: 'Included 1',
history: [{ time: '2026-06-11T00:00:00Z', delay: 100 }],
}),
'main-2-out': proxy('VLESS', {
name: 'Filtered 2',
history: [{ time: '2026-06-11T00:00:00Z', delay: 200 }],
}),
'main-3-out': proxy('VLESS', {
name: 'Included 3',
history: [{ time: '2026-06-11T00:00:00Z', delay: 300 }],
}),
},
},
});
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.latencyTestCodes).toEqual([
'main-urltest-out',
'main-provider-urltest-out',
'main-2-out',
]);
expect(section.outbounds.map((item) => item.code)).toEqual([
'main-urltest-out',
'main-provider-urltest-out',
'main-2-out',
]);
});
it('does not expose flag-emoji detected countries on dashboard outbounds', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
@@ -944,7 +774,7 @@ describe('getDashboardSections', () => {
).toBe('US');
});
it('marks subscription outbounds as copyable when section cache has link refs', async () => {
it('ignores legacy link refs without a resolved proxy link', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
subscription_urls: ['https://subscription.example/list'],
@@ -970,7 +800,7 @@ describe('getDashboardSections', () => {
expect(result.success).toBe(true);
expect(subscriptionOutbound?.link).toBe('');
expect(subscriptionOutbound?.canCopyLink).toBe(true);
expect(subscriptionOutbound?.canCopyLink).toBe(false);
});
it('uses cached subscription links without a click-time backend lookup', async () => {
@@ -989,12 +819,6 @@ describe('getDashboardSections', () => {
'main-2-out':
'vless://00000000-0000-4000-8000-000000000002@example.com:443#Subscription',
},
linkRefs: {
'main-2-out': {
sourceSection: 'main-subscription-1',
sourceIndex: 1,
},
},
}),
);
@@ -235,25 +235,16 @@ export const TachyonShellMethods = {
),
getStatus: async () =>
callBaseMethod<Tachyon.GetStatus>(Tachyon.AvailableMethods.GET_STATUS),
getOutboundLink: async (section: string, tag: string) =>
callBaseMethod<Tachyon.GetOutboundLink>(
Tachyon.AvailableMethods.GET_OUTBOUND_LINK,
[section, tag],
),
getOutboundLinkStates: async (section: string) =>
callBaseMethod<Tachyon.GetOutboundLinkStates>(
Tachyon.AvailableMethods.GET_OUTBOUND_LINK_STATES,
[section],
),
getOutboundMetadata: async (section: string) =>
callBaseMethod<Tachyon.GetOutboundMetadata>(
Tachyon.AvailableMethods.GET_OUTBOUND_METADATA,
[section],
),
getSubscriptionMetadata: async (section: string) =>
callBaseMethod<
Tachyon.SubscriptionMetadata | Tachyon.SubscriptionMetadata[]
>(Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA, [section]),
callBaseMethod<Tachyon.SubscriptionMetadata | Tachyon.SubscriptionMetadata[]>(
Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA,
[section],
),
checkSingBox: async () =>
callBaseMethod<Tachyon.SingBoxCheckResult>(
Tachyon.AvailableMethods.CHECK_SING_BOX,
@@ -546,11 +537,7 @@ export const TachyonShellMethods = {
) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [
Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC,
component,
action,
],
args: [Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseComponentActionStartResult(response);
@@ -758,8 +745,7 @@ export const TachyonShellMethods = {
while (true) {
await sleep(SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS);
const response =
await TachyonShellMethods.subscriptionUpdateStatus(jobId);
const response = await TachyonShellMethods.subscriptionUpdateStatus(jobId);
if (!response.success) {
if (transientRpc.shouldContinue(response.error)) {
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -1,4 +1,4 @@
import { TabServiceInstance } from './tab.service';
import { TabServiceInstance } from './tab.service';
import { store } from './store.service';
import { logger } from './logger.service';
import { TachyonLogWatcher } from './tachyonLogWatcher.service';
@@ -105,8 +105,8 @@ class SocketManager {
this.sockets.set(url, ws);
this.connected.set(url, false);
this.listeners.set(url, new Set());
this.errorListeners.set(url, new Set());
if (!this.listeners.has(url)) this.listeners.set(url, new Set());
if (!this.errorListeners.has(url)) this.errorListeners.set(url, new Set());
ws.addEventListener('open', () => {
this.connected.set(url, true);
@@ -1,4 +1,4 @@
import { logger } from './logger.service';
import { logger } from './logger.service';
type LogFetcher = () => Promise<string> | string;
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
getLogNotificationKey,
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { socket } from '../socket.service';
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
private listeners = new Map<string, Array<(event: Event) => void>>();
constructor(_url: string) {
FakeWebSocket.instances.push(this);
}
addEventListener(type: string, listener: (event: Event) => void) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
emit(type: string) {
for (const listener of this.listeners.get(type) || []) {
listener(new Event(type));
}
}
close() {}
send() {}
}
describe('socket service', () => {
beforeEach(() => {
socket.resetAll();
FakeWebSocket.instances = [];
vi.stubGlobal('WebSocket', FakeWebSocket);
});
afterEach(() => {
socket.resetAll();
vi.unstubAllGlobals();
});
it('keeps the initial subscriber when the first connection fails', () => {
const onError = vi.fn();
socket.subscribe('ws://router.test', vi.fn(), onError);
FakeWebSocket.instances[0].emit('error');
expect(onError).toHaveBeenCalledOnce();
});
});
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { TachyonLogWatcher } from '../tachyonLogWatcher.service';
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { applyUiStateToStore } from '../uiState.service';
import { store } from '../store.service';
import { Tachyon } from '../../types';
@@ -1,4 +1,4 @@
import { getComponentActionKey } from '../helpers/getComponentActionKey';
import { getComponentActionKey } from '../helpers/getComponentActionKey';
import { normalizeSingBoxVariantFields } from '../helpers/singBoxVariant';
import type { Tachyon } from '../types';
import { getLocalActionOverlay } from './localActionOverlay.service';
@@ -737,27 +737,13 @@ async function handleTestLatency(
}
}
async function handleCopyOutbound(
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) {
function handleCopyOutbound(outbound: Tachyon.Outbound) {
const link = outbound.link;
if (link && isCopyableProxyLink(link)) {
copyToClipboard(link);
return;
}
const response = await TachyonShellMethods.getOutboundLink(
section.sectionName,
outbound.code,
);
if (response.success && isCopyableProxyLink(response.data.link)) {
copyToClipboard(response.data.link);
return;
}
showToast(_('Proxy link is unavailable'), 'error');
}
@@ -899,7 +885,6 @@ function renderUrlTestCopyButton(
function renderCommonDetailsModal(
info: any,
section: Tachyon.OutboundGroup,
fields: Array<{
label: string;
value?: unknown;
@@ -1009,7 +994,7 @@ function renderCommonDetailsModal(
member.canCopyLink
? renderUrlTestCopyButton(_('Copy proxy link'), (event) => {
event.preventDefault();
void handleCopyOutbound(section, member);
void handleCopyOutbound(member);
})
: E('span', {
class:
@@ -1043,10 +1028,7 @@ function renderCommonDetailsModal(
]);
}
function renderUrlTestInfoModal(
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) {
function renderUrlTestInfoModal(outbound: Tachyon.Outbound) {
const info = outbound.urlTestInfo;
if (!info) {
@@ -1067,24 +1049,20 @@ function renderUrlTestInfoModal(
{ label: _('Tolerance'), value: info.tolerance },
{ label: _('Idle timeout'), value: info.idleTimeout },
{
label: _('Interrupt existing connections'),
label: _('Interrupt connections'),
value: info.interruptExistConnections,
},
];
return renderCommonDetailsModal(
info,
section,
fields,
(member) => renderDetailsMemberName(member),
false,
);
}
function handleShowUrlTestInfo(
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) {
function handleShowUrlTestInfo(outbound: Tachyon.Outbound) {
if (!outbound.urlTestInfo) {
return;
}
@@ -1093,7 +1071,7 @@ function handleShowUrlTestInfo(
`${_('URLTest details')}: ${
outbound.urlTestInfo.displayName || outbound.displayName
}`,
renderUrlTestInfoModal(section, outbound),
renderUrlTestInfoModal(outbound),
);
}
@@ -1171,10 +1149,7 @@ function renderPriorityMemberName(member: Tachyon.PriorityMember) {
];
}
function renderPriorityInfoModal(
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) {
function renderPriorityInfoModal(outbound: Tachyon.Outbound) {
const info = outbound.priorityInfo;
if (!info) {
@@ -1217,24 +1192,20 @@ function renderPriorityInfoModal(
]
: []),
{
label: _('Interrupt existing connections'),
label: _('Interrupt connections'),
value: info.interruptExistConnections,
},
];
return renderCommonDetailsModal(
info,
section,
fields,
(member) => renderPriorityMemberName(member),
true,
);
}
function handleShowPriorityInfo(
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) {
function handleShowPriorityInfo(outbound: Tachyon.Outbound) {
if (!outbound.priorityInfo) {
return;
}
@@ -1243,7 +1214,7 @@ function handleShowPriorityInfo(
`${_('Priority details')}: ${
outbound.priorityInfo.displayName || outbound.displayName
}`,
renderPriorityInfoModal(section, outbound),
renderPriorityInfoModal(outbound),
);
}
@@ -1454,14 +1425,14 @@ async function renderSectionsWidget() {
onChooseOutbound: (sectionName, selector, tag) => {
void handleChooseOutbound(sectionName, selector, tag);
},
onCopyOutbound: (section, outbound) => {
void handleCopyOutbound(section, outbound);
onCopyOutbound: (_section, outbound) => {
handleCopyOutbound(outbound);
},
onShowUrlTestInfo: (section, outbound) => {
handleShowUrlTestInfo(section, outbound);
onShowUrlTestInfo: (outbound) => {
handleShowUrlTestInfo(outbound);
},
onShowPriorityInfo: (section, outbound) => {
handleShowPriorityInfo(section, outbound);
onShowPriorityInfo: (outbound) => {
handleShowPriorityInfo(outbound);
},
onUpdateSubscription: (section) => {
void handleUpdateSubscription(section);
@@ -22,14 +22,8 @@ interface IRenderSectionsProps {
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) => void;
onShowUrlTestInfo: (
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) => void;
onShowPriorityInfo: (
section: Tachyon.OutboundGroup,
outbound: Tachyon.Outbound,
) => void;
onShowUrlTestInfo: (outbound: Tachyon.Outbound) => void;
onShowPriorityInfo: (outbound: Tachyon.Outbound) => void;
onUpdateSubscription: (section: Tachyon.OutboundGroup) => void;
latencyFetching: boolean;
latencyProgress?: Tachyon.LatencyActionProgress;
@@ -391,7 +385,7 @@ function renderDefaultState({
'aria-label': _('URLTest details'),
click: (event: MouseEvent) => {
event.stopPropagation();
onShowUrlTestInfo(section, outbound);
onShowUrlTestInfo(outbound);
},
},
renderInfoIcon24(),
@@ -410,7 +404,7 @@ function renderDefaultState({
'aria-label': _('Priority details'),
click: (event: MouseEvent) => {
event.stopPropagation();
onShowPriorityInfo(section, outbound);
onShowPriorityInfo(outbound);
},
},
renderInfoIcon24(),
@@ -1,4 +1,4 @@
import { renderSections, renderWidget } from './partials';
import { renderSections, renderWidget } from './partials';
export function render() {
return E(
@@ -39,15 +39,15 @@ vi.mock('../../../services/runtimeUiState.service', () => ({
describe('dashboard initController', () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.E = vi
(globalThis as any).E = vi
.fn()
.mockImplementation((tag) => document.createElement(tag));
globalThis.ui = {
(globalThis as any).ui = {
showModal: vi.fn(),
hideModal: vi.fn(),
addNotification: vi.fn(),
} as any;
globalThis.uci = {
(globalThis as any).uci = {
sections: vi.fn().mockResolvedValue([]),
} as any;
});
@@ -1,4 +1,4 @@
import { insertIf } from '../../../../helpers';
import { insertIf } from '../../../../helpers';
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { TachyonShellMethods } from '../../../methods';
import type { IDiagnosticsChecksItem } from '../../../services';
@@ -1,4 +1,4 @@
import { insertIf } from '../../../../helpers';
import { insertIf } from '../../../../helpers';
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { TachyonShellMethods, RemoteFakeIPMethods } from '../../../methods';
import type { IDiagnosticsChecksItem } from '../../../services';
@@ -4,9 +4,13 @@ import { getDnsCheckPresentation } from '../getDnsCheckPresentation';
const baseDnsResult = {
dns_type: 'udp' as const,
dns_server: '77.88.8.8',
dns_server_index: 0,
dns_server_count: 1,
dns_status: 1 as const,
dns_on_router: 1 as const,
bootstrap_dns_server: '77.88.8.8',
bootstrap_dns_server_index: 0,
bootstrap_dns_server_count: 1,
bootstrap_dns_status: 1 as const,
dhcp_config_status: 0 as const,
dont_touch_dhcp: 0 as const,
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getZapretStatus: vi.fn(),
@@ -58,7 +58,7 @@ describe('provider diagnostics checks', () => {
await expect(runZapretCheck()).resolves.toBeUndefined();
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
expect(result).toMatchObject({
state: 'success',
@@ -95,7 +95,7 @@ describe('provider diagnostics checks', () => {
await expect(runZapretCheck()).resolves.toBeUndefined();
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
expect(result).toMatchObject({
state: 'error',
@@ -122,7 +122,7 @@ describe('provider diagnostics checks', () => {
await expect(runZapret2Check()).resolves.toBeUndefined();
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
expect(result).toMatchObject({
state: 'success',
@@ -171,7 +171,7 @@ describe('provider diagnostics checks', () => {
await expect(runByedpiCheck()).resolves.toBeUndefined();
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
expect(result).toMatchObject({
state: 'success',
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getDashboardSections: vi.fn(),
@@ -1,4 +1,4 @@
import {
import {
DIAGNOSTICS_CHECKS,
DIAGNOSTICS_CHECKS_MAP,
} from './checks/contstants';
@@ -1,4 +1,4 @@
import type { StoreType } from '../../services';
import type { StoreType } from '../../services';
import type { DiagnosticsProviderOptions } from './diagnostic.store';
import { DIAGNOSTICS_CHECKS } from './checks/contstants';
@@ -1,4 +1,4 @@
const MASKED_VALUE = 'MASKED';
const MASKED_VALUE = 'MASKED';
const SING_BOX_MASKED_KEYS = new Set([
'auth_key',
@@ -30,6 +30,7 @@ const SING_BOX_MASKED_KEYS = new Set([
const TACHYON_MASK_AFTER_TOKEN = [
'option proxy_string',
'option hwid',
'option subscription_url',
'list subscription_urls',
'list urltest_proxy_links',
@@ -764,8 +764,7 @@ async function handleShowSingBoxConfig() {
setDiagnosticActionLoading('showSingBoxConfig', true);
try {
const showSingBoxConfig =
await TachyonShellMethods.showSingBoxConfig(false);
const showSingBoxConfig = await TachyonShellMethods.showSingBoxConfig(false);
if (showSingBoxConfig.success) {
const rawSingBoxConfigText = stringifySingBoxConfig(
@@ -1,4 +1,4 @@
import { renderButton } from '../../../../partials';
import { renderButton } from '../../../../partials';
import {
renderCircleCheckBigIcon24,
renderCirclePlayIcon24,
@@ -1,4 +1,4 @@
type LoadingActionState = {
type LoadingActionState = {
loading: boolean;
};
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
clearPersistedDiagnosticRun,
@@ -60,6 +60,7 @@ describe('diagnostic masking', () => {
const raw = [
"config section 'main'",
"\toption proxy_string 'vless://secret@example.com:443'",
"\toption hwid 'device-secret'",
"\tlist domain 'example.com'",
"\toption outbound_json '{",
' "server": "example.com",',
@@ -77,6 +78,7 @@ describe('diagnostic masking', () => {
expect(masked.split('\n')).toHaveLength(raw.split('\n').length);
expect(masked).not.toContain('vless://secret');
expect(masked).not.toContain('device-secret');
expect(masked).not.toContain('example.com",');
expect(masked).not.toContain('192.168.1.1');
expect(masked).not.toContain('provider-password');
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
getAvailableActionsDisabledState,
getServiceTransition,
@@ -1,4 +1,4 @@
import { canUseDirectClashApi, getClashWsUrl, onMount } from '../../../helpers';
import { canUseDirectClashApi, getClashWsUrl, onMount } from '../../../helpers';
import { prettyBytes } from '../../../helpers/prettyBytes';
import { showToast } from '../../../helpers/showToast';
import {
@@ -532,9 +532,7 @@ async function completeComponentActionJob(
});
}
async function followComponentActionState(
state: Tachyon.ComponentActionResult,
) {
async function followComponentActionState(state: Tachyon.ComponentActionResult) {
const jobId = state.job_id;
const key = getComponentActionKey(state.component, state.action);
+21 -10
View File
@@ -1,4 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ClashAPI {
interface ProxyHistoryEntry {
time: string;
@@ -60,8 +60,6 @@ export namespace Tachyon {
CHECK_BYEDPI_RUNTIME = 'check_byedpi_runtime',
CHECK_INBOUNDS_CONFIG = 'check_inbounds_config',
GET_STATUS = 'get_status',
GET_OUTBOUND_LINK = 'get_outbound_link',
GET_OUTBOUND_LINK_STATES = 'get_outbound_link_states',
GET_OUTBOUND_METADATA = 'get_outbound_metadata',
GET_SUBSCRIPTION_METADATA = 'get_subscription_metadata',
CHECK_SING_BOX = 'check_sing_box',
@@ -268,6 +266,24 @@ export namespace Tachyon {
urltest_settings?: string;
priority_groups?: string[];
priority_group_settings?: string;
dashboard_filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed';
dashboard_detect_server_country?: 'flag_emoji' | 'country_is';
dashboard_include_countries?: string[];
dashboard_include_outbounds?: string[];
dashboard_include_regex?: string[];
dashboard_include_proxy_parameters?: '0' | '1';
dashboard_include_protocols?: string[];
dashboard_include_transports?: string[];
dashboard_include_securities?: string[];
dashboard_include_groups?: string[];
dashboard_exclude_countries?: string[];
dashboard_exclude_outbounds?: string[];
dashboard_exclude_regex?: string[];
dashboard_exclude_proxy_parameters?: '0' | '1';
dashboard_exclude_protocols?: string[];
dashboard_exclude_transports?: string[];
dashboard_exclude_securities?: string[];
dashboard_exclude_groups?: string[];
urltest_proxy_links?: string[];
subscription_url?: string;
subscription_user_agent?: string;
@@ -299,7 +315,6 @@ export namespace Tachyon {
idle_timeout?: string;
interrupt_exist_connections?: '0' | '1';
pin_dashboard?: '0' | '1';
hide_added_outbounds?: '0' | '1';
filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed';
include_countries?: string[];
include_outbounds?: string[];
@@ -438,12 +453,6 @@ export namespace Tachyon {
dns_configured?: number;
}
export interface GetOutboundLink {
link: string;
}
export type GetOutboundLinkStates = Record<string, boolean>;
export interface GetOutboundMetadata {
names?: Record<string, string>;
countries?: Record<string, string>;
@@ -623,6 +632,8 @@ export namespace Tachyon {
legacy_runtime_present: 0 | 1;
ready: 0 | 1;
conflict: 0 | 1;
outbounds_configured: 0 | 1;
routes_configured: 0 | 1;
status_message: string;
}
+10 -2
View File
@@ -24,6 +24,12 @@ export function isValidHost(host: string): boolean {
);
}
export function isValidPort(port: unknown): boolean {
const normalized = String(port ?? '');
const value = Number(normalized);
return /^\d+$/.test(normalized) && value >= 1 && value <= 65535;
}
export function parseHostPort(value: string): ParsedHostPort | null {
if (!value) {
return null;
@@ -35,10 +41,11 @@ export function parseHostPort(value: string): ParsedHostPort | null {
return null;
}
return {
const parsed = {
host: value.slice(1, end),
port: value.slice(end + 2),
};
return isValidHost(parsed.host) ? parsed : null;
}
const firstColon = value.indexOf(':');
@@ -47,8 +54,9 @@ export function parseHostPort(value: string): ParsedHostPort | null {
return null;
}
return {
const parsed = {
host: value.slice(0, firstColon),
port: value.slice(firstColon + 1),
};
return isValidHost(parsed.host) ? parsed : null;
}
@@ -19,7 +19,13 @@ export const additionalValidDns = [
const validDns = [...validIPs, ...validDomains, ...additionalValidDns];
const invalidDns = [...invalidIPs, ...invalidDomains];
const invalidDns = [
...invalidIPs,
...invalidDomains,
['Non-numeric port', '8.8.8.8:abc'],
['Port too high', 'dns.example.com:99999'],
['Zero IPv6 port', '[2606:4700:4700::1111]:0'],
];
describe('validateDns', () => {
describe.each(validDns)('Valid dns: %s', (_desc, domain) => {
@@ -46,6 +46,7 @@ const invalidUrls = [
['Missing password', 'hysteria2://@example.com:443/'],
['Missing host', 'hysteria2://pw@:443/'],
['Missing port', 'hysteria2://pw@example.com/'],
['Invalid host', 'hysteria2://pw@bad_host:443/'],
['Non-numeric port', 'hysteria2://pw@example.com:port/'],
['Port out of range', 'hysteria2://pw@example.com:99999/'],
['Invalid port range order', 'hysteria2://pw@example.com:6000-5000/'],
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { validateIP, validateIPV4, validateIPv6 } from '../validateIp';
import { validateIP } from '../validateIp';
export const validIPs = [
['Private LAN', '192.168.1.1'],
@@ -21,23 +21,23 @@ export const invalidIPs = [
['Trailing dot', '1.2.3.'],
];
describe('validateIPV4', () => {
describe('validateIP IPv4', () => {
describe.each(validIPs)('Valid IP: %s', (_desc, ip) => {
it(`returns {valid:true} for "${ip}"`, () => {
const res = validateIPV4(ip);
const res = validateIP(ip);
expect(res.valid).toBe(true);
});
});
describe.each(invalidIPs)('Invalid IP: %s', (_desc, ip) => {
it(`returns {valid:false} for "${ip}"`, () => {
const res = validateIPV4(ip);
const res = validateIP(ip);
expect(res.valid).toBe(false);
});
});
});
describe('validateIPv6', () => {
describe('validateIP IPv6', () => {
const validIPv6Addresses = [
['Loopback', '::1'],
['ULA', 'fc00::1'],
@@ -53,14 +53,13 @@ describe('validateIPv6', () => {
describe.each(validIPv6Addresses)('Valid IPv6: %s', (_desc, ip) => {
it(`returns {valid:true} for "${ip}"`, () => {
expect(validateIPv6(ip).valid).toBe(true);
expect(validateIP(ip).valid).toBe(true);
});
});
describe.each(invalidIPv6Addresses)('Invalid IPv6: %s', (_desc, ip) => {
it(`returns {valid:false} for "${ip}"`, () => {
expect(validateIPv6(ip).valid).toBe(false);
expect(validateIP(ip).valid).toBe(false);
});
});
});
@@ -67,4 +67,12 @@ describe('validateProxyUrl', () => {
expect(res.valid).toBe(false);
expect(res.message).toContain('path');
});
it('rejects VMess configs with an invalid server host', () => {
const config = Buffer.from(
JSON.stringify({ add: 'bad_host', port: '443', id: 'uuid' }),
).toString('base64');
expect(validateProxyUrl(`vmess://${config}`).valid).toBe(false);
});
});
@@ -24,6 +24,7 @@ const invalidUrls = [
['No prefix', 'uuid@127.0.0.1:443?type=tcp'],
['No host', 'ss://password@:443?type=tcp'],
['No port', 'ss://password@127.0.0.1?type=tcp'],
['Invalid host', 'ss://password@bad_host:443?type=tcp'],
['Invalid port', 'ss://password@127.0.0.1:abc?type=tcp'],
['Missing type', 'ss://password@127.0.0.1:443'],
['Contains space', 'ss://password@127.0.0.1:443?type=tcp #extra'],
@@ -104,6 +104,7 @@ const invalidUrls = [
['No password', 'trojan://@127.0.0.1:443?type=tcp&security=tls'],
['No host', 'trojan://pass@:443?type=tcp&security=tls'],
['No port', 'trojan://pass@127.0.0.1?type=tcp&security=tls'],
['Invalid host', 'trojan://pass@bad_host:443?type=tcp&security=tls'],
['Invalid port', 'trojan://pass@127.0.0.1:abc?type=tcp&security=tls'],
[
'Unbracketed IPv6 with port',
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from 'vitest';
import { validateUrl } from '../validateUrl';
const validUrls = [
@@ -67,6 +67,7 @@ const invalidUrls = [
['No uuid', 'vless://@127.0.0.1:443?type=tcp&security=tls'],
['No host', 'vless://uuid@:443?type=tcp&security=tls'],
['No port', 'vless://uuid@127.0.0.1?type=tcp&security=tls'],
['Invalid host', 'vless://uuid@bad_host:443?type=tcp&security=tls'],
['Invalid port', 'vless://uuid@127.0.0.1:abc?type=tcp&security=tls'],
[
'Unbracketed IPv6 with port',
+5 -1
View File
@@ -1,5 +1,5 @@
import { validateDomain } from './validateDomain';
import { parseHostPort, unbracketHost } from './hostPort';
import { isValidPort, parseHostPort, unbracketHost } from './hostPort';
import { validateIP } from './validateIp';
import { ValidationResult } from './types';
@@ -17,6 +17,10 @@ export function validateDNS(value: string): ValidationResult {
? host + (pathParts.length > 0 ? `/${pathParts.join('/')}` : '')
: value.replace(/:(\d+)(?=\/|$)/, '');
if (parsedHostPort && !isValidPort(parsedHostPort.port)) {
return { valid: false, message: _('Invalid DNS server port') };
}
if (validateIP(host).valid) {
return { valid: true, message: _('Valid') };
}
@@ -1,5 +1,5 @@
import { ValidationResult } from './types';
import { isValidHost, parseHostPort } from './hostPort';
import { isValidPort, parseHostPort } from './hostPort';
export function validateHttpProxyUrl(url: string): ValidationResult {
try {
@@ -73,20 +73,12 @@ export function validateHttpProxyUrl(url: string): ValidationResult {
};
}
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: _('Invalid HTTP proxy URL: invalid port number'),
};
}
if (!isValidHost(host)) {
return {
valid: false,
message: _('Invalid HTTP proxy URL: invalid host format'),
};
}
} catch (_e) {
return {
valid: false,
@@ -1,6 +1,6 @@
import { ValidationResult } from './types';
import { parseQueryString } from '../helpers/parseQueryString';
import { parseHostPort } from './hostPort';
import { isValidPort, parseHostPort } from './hostPort';
export function validateHysteria2Url(url: string): ValidationResult {
try {
@@ -64,22 +64,13 @@ export function validateHysteria2Url(url: string): ValidationResult {
const cleanedPort = port.replace('/', '');
const portEntries = cleanedPort.split(',');
const isValidPortNumber = (value: string) => {
if (!/^\d+$/.test(value)) {
return false;
}
const portNum = Number(value);
return Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
};
const isValidPortEntry = (entry: string) => {
if (!entry) {
return false;
}
if (!entry.includes('-')) {
return isValidPortNumber(entry);
return isValidPort(entry);
}
const rangeParts = entry.split('-');
@@ -89,9 +80,7 @@ export function validateHysteria2Url(url: string): ValidationResult {
const [start, end] = rangeParts;
return (
isValidPortNumber(start) &&
isValidPortNumber(end) &&
Number(start) <= Number(end)
isValidPort(start) && isValidPort(end) && Number(start) <= Number(end)
);
};
@@ -20,22 +20,6 @@ export function isIPv6(ip: string): boolean {
}
}
export function validateIPV4(ip: string): ValidationResult {
if (isIPv4(ip)) {
return { valid: true, message: _('Valid') };
}
return { valid: false, message: _('Invalid IP address') };
}
export function validateIPv6(ip: string): ValidationResult {
if (isIPv6(ip)) {
return { valid: true, message: _('Valid') };
}
return { valid: false, message: _('Invalid IP address') };
}
export function validateIP(ip: string): ValidationResult {
if (isIPv4(ip) || isIPv6(ip)) {
return { valid: true, message: _('Valid') };
@@ -1,5 +1,5 @@
import { ValidationResult } from './types';
import { parseHostPort } from './hostPort';
import { isValidPort, parseHostPort } from './hostPort';
// TODO refactor current validation and add tests
export function validateShadowsocksUrl(url: string): ValidationResult {
@@ -85,9 +85,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
};
}
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: _('Invalid port number. Must be between 1 and 65535'),
@@ -1,5 +1,5 @@
import { ValidationResult } from './types';
import { isValidHost, parseHostPort } from './hostPort';
import { isValidPort, parseHostPort } from './hostPort';
export function validateSocksUrl(url: string): ValidationResult {
try {
@@ -62,20 +62,12 @@ export function validateSocksUrl(url: string): ValidationResult {
return { valid: false, message: _('Invalid SOCKS URL: missing port') };
}
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: _('Invalid SOCKS URL: invalid port number'),
};
}
if (!isValidHost(host)) {
return {
valid: false,
message: _('Invalid SOCKS URL: invalid host format'),
};
}
} catch (_e) {
return { valid: false, message: _('Invalid SOCKS URL: parsing failed') };
}
@@ -1,5 +1,5 @@
import { ValidationResult } from './types';
import { parseHostPort } from './hostPort';
import { isValidPort, parseHostPort } from './hostPort';
export function validateTrojanUrl(url: string): ValidationResult {
try {
@@ -52,9 +52,7 @@ export function validateTrojanUrl(url: string): ValidationResult {
if (!port)
return { valid: false, message: 'Invalid Trojan URL: missing port' };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
if (!isValidPort(port))
return {
valid: false,
message: 'Invalid Trojan URL: invalid port number',
+2 -4
View File
@@ -1,5 +1,5 @@
import { ValidationResult } from './types';
import { isValidHost } from './hostPort';
import { isValidHost, isValidPort } from './hostPort';
export function validateUrl(
url: string,
@@ -25,11 +25,9 @@ export function validateUrl(
const host = parsed.hostname.startsWith('[')
? parsed.hostname.slice(1, -1)
: parsed.hostname;
const portNum = parsed.port ? Number(parsed.port) : 0;
if (
(isValidHost(host) || host === 'localhost') &&
(!parsed.port ||
(Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535))
(!parsed.port || isValidPort(parsed.port))
) {
return { valid: true, message: _('Valid') };
}
@@ -1,6 +1,6 @@
import { ValidationResult } from './types';
import { parseQueryString } from '../helpers/parseQueryString';
import { parseHostPort } from './hostPort';
import { isValidPort, parseHostPort } from './hostPort';
export function validateVlessUrl(url: string): ValidationResult {
try {
@@ -51,8 +51,7 @@ export function validateVlessUrl(url: string): ValidationResult {
return { valid: false, message: 'Invalid VLESS URL: missing port' };
const cleanedPort = port.replace('/', '');
const portNum = Number(cleanedPort);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
if (!isValidPort(cleanedPort))
return {
valid: false,
message: 'Invalid VLESS URL: invalid port number',
@@ -1,4 +1,5 @@
import { ValidationResult } from './types';
import { isValidHost, isValidPort } from './hostPort';
function decodeBase64Json(value: string): unknown {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
@@ -47,12 +48,11 @@ export function validateVmessUrl(url: string): ValidationResult {
}
const { add, port, id } = config as Record<string, unknown>;
if (!add || typeof add !== 'string') {
return { valid: false, message: 'Invalid VMess URL: missing server' };
if (!add || typeof add !== 'string' || !isValidHost(add)) {
return { valid: false, message: 'Invalid VMess URL: invalid server' };
}
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: 'Invalid VMess URL: invalid port number',
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'tsup';
import { defineConfig } from 'tsup';
import fs from 'fs';
import path from 'path';
+28 -15
View File
@@ -12,27 +12,37 @@ const config = {
port: Number(process.env.SFTP_PORT || 22),
username: process.env.SFTP_USER,
...(process.env.SFTP_PRIVATE_KEY
? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) }
: { password: process.env.SFTP_PASS }),
? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) }
: { password: process.env.SFTP_PASS }),
};
const syncDirs = [
{
local: path.resolve(process.env.LOCAL_DIR_FE ?? '../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon'),
remote: process.env.REMOTE_DIR_FE ?? '/www/luci-static/resources/view/tachyon',
local: path.resolve(
process.env.LOCAL_DIR_FE ??
'../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon',
),
remote:
process.env.REMOTE_DIR_FE ?? '/www/luci-static/resources/view/tachyon',
},
{
local: path.resolve(process.env.LOCAL_DIR_BIN ?? '../tachyon/files/usr/bin/'),
local: path.resolve(
process.env.LOCAL_DIR_BIN ?? '../tachyon/files/usr/bin/',
),
remote: process.env.REMOTE_DIR_BIN ?? '/usr/bin/',
},
{
local: path.resolve(process.env.LOCAL_DIR_LIB ?? '../tachyon/files/usr/lib/'),
local: path.resolve(
process.env.LOCAL_DIR_LIB ?? '../tachyon/files/usr/lib/',
),
remote: process.env.REMOTE_DIR_LIB ?? '/usr/lib/tachyon/',
},
{
local: path.resolve(process.env.LOCAL_DIR_INIT ?? '../tachyon/files/etc/init.d/'),
local: path.resolve(
process.env.LOCAL_DIR_INIT ?? '../tachyon/files/etc/init.d/',
),
remote: process.env.REMOTE_DIR_INIT ?? '/etc/init.d/',
}
},
];
async function uploadFile(filePath, baseDir, remoteBase) {
@@ -79,13 +89,15 @@ async function main() {
await uploadAllFiles();
for (const { local, remote } of syncDirs) {
chokidar.watch(local, { ignoreInitial: true }).on('all', async (event, filePath) => {
if (event === 'add' || event === 'change') {
await uploadFile(filePath, local, remote);
} else if (event === 'unlink') {
await deleteFile(filePath, local, remote);
}
});
chokidar
.watch(local, { ignoreInitial: true })
.on('all', async (event, filePath) => {
if (event === 'add' || event === 'change') {
await uploadFile(filePath, local, remote);
} else if (event === 'unlink') {
await deleteFile(filePath, local, remote);
}
});
}
process.on('SIGINT', async () => {
@@ -96,5 +108,6 @@ async function main() {
}
main().catch((err) => {
process.exitCode = 1;
console.error('💥 Fatal:', err);
});
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require baseclass";
"require form";
"require ui";
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require baseclass";
"require form";
"require ui";
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require baseclass";
"require rpc";
"require ui";
@@ -21,18 +21,6 @@ function isIPv6(ip) {
return false;
}
}
function validateIPV4(ip) {
if (isIPv4(ip)) {
return { valid: true, message: _("Valid") };
}
return { valid: false, message: _("Invalid IP address") };
}
function validateIPv6(ip) {
if (isIPv6(ip)) {
return { valid: true, message: _("Valid") };
}
return { valid: false, message: _("Invalid IP address") };
}
function validateIP(ip) {
if (isIPv4(ip) || isIPv6(ip)) {
return { valid: true, message: _("Valid") };
@@ -104,6 +92,11 @@ function isValidHost(host) {
const normalizedHost = unbracketHost(host);
return isIPv4(normalizedHost) || isIPv6(normalizedHost) || validateDomain(normalizedHost).valid;
}
function isValidPort(port) {
const normalized = String(port ?? "");
const value = Number(normalized);
return /^\d+$/.test(normalized) && value >= 1 && value <= 65535;
}
function parseHostPort(value) {
if (!value) {
return null;
@@ -113,20 +106,22 @@ function parseHostPort(value) {
if (end <= 1 || value.slice(end + 1, end + 2) !== ":") {
return null;
}
return {
const parsed2 = {
host: value.slice(1, end),
port: value.slice(end + 2)
};
return isValidHost(parsed2.host) ? parsed2 : null;
}
const firstColon = value.indexOf(":");
const lastColon = value.lastIndexOf(":");
if (firstColon < 0 || firstColon !== lastColon) {
return null;
}
return {
const parsed = {
host: value.slice(0, firstColon),
port: value.slice(firstColon + 1)
};
return isValidHost(parsed.host) ? parsed : null;
}
// src/validators/validateDns.ts
@@ -138,6 +133,9 @@ function validateDNS(value) {
const parsedHostPort = parseHostPort(addressPart);
const host = parsedHostPort ? parsedHostPort.host : unbracketHost(addressPart);
const domainValue = parsedHostPort ? host + (pathParts.length > 0 ? `/${pathParts.join("/")}` : "") : value.replace(/:(\d+)(?=\/|$)/, "");
if (parsedHostPort && !isValidPort(parsedHostPort.port)) {
return { valid: false, message: _("Invalid DNS server port") };
}
if (validateIP(host).valid) {
return { valid: true, message: _("Valid") };
}
@@ -166,8 +164,7 @@ function validateUrl(url, protocols = ["http:", "https:"]) {
try {
const parsed = new URL(url);
const host = parsed.hostname.startsWith("[") ? parsed.hostname.slice(1, -1) : parsed.hostname;
const portNum = parsed.port ? Number(parsed.port) : 0;
if ((isValidHost(host) || host === "localhost") && (!parsed.port || Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535)) {
if ((isValidHost(host) || host === "localhost") && (!parsed.port || isValidPort(parsed.port))) {
return { valid: true, message: _("Valid") };
}
} catch (_e) {
@@ -396,8 +393,7 @@ function validateShadowsocksUrl(url) {
message: _("Invalid Shadowsocks URL: missing port")
};
}
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: _("Invalid port number. Must be between 1 and 65535")
@@ -467,8 +463,7 @@ function validateVlessUrl(url) {
if (!port)
return { valid: false, message: "Invalid VLESS URL: missing port" };
const cleanedPort = port.replace("/", "");
const portNum = Number(cleanedPort);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
if (!isValidPort(cleanedPort))
return {
valid: false,
message: "Invalid VLESS URL: invalid port number"
@@ -566,11 +561,10 @@ function validateVmessUrl(url) {
return { valid: false, message: "Invalid VMess URL: invalid config" };
}
const { add, port, id } = config;
if (!add || typeof add !== "string") {
return { valid: false, message: "Invalid VMess URL: missing server" };
if (!add || typeof add !== "string" || !isValidHost(add)) {
return { valid: false, message: "Invalid VMess URL: invalid server" };
}
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: "Invalid VMess URL: invalid port number"
@@ -627,8 +621,7 @@ function validateTrojanUrl(url) {
return { valid: false, message: "Invalid Trojan URL: missing hostname" };
if (!port)
return { valid: false, message: "Invalid Trojan URL: missing port" };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
if (!isValidPort(port))
return {
valid: false,
message: "Invalid Trojan URL: invalid port number"
@@ -691,19 +684,12 @@ function validateSocksUrl(url) {
if (!port) {
return { valid: false, message: _("Invalid SOCKS URL: missing port") };
}
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: _("Invalid SOCKS URL: invalid port number")
};
}
if (!isValidHost(host)) {
return {
valid: false,
message: _("Invalid SOCKS URL: invalid host format")
};
}
} catch (_e) {
return { valid: false, message: _("Invalid SOCKS URL: parsing failed") };
}
@@ -760,26 +746,19 @@ function validateHysteria2Url(url) {
}
const cleanedPort = port.replace("/", "");
const portEntries = cleanedPort.split(",");
const isValidPortNumber = (value) => {
if (!/^\d+$/.test(value)) {
return false;
}
const portNum = Number(value);
return Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
};
const isValidPortEntry = (entry) => {
if (!entry) {
return false;
}
if (!entry.includes("-")) {
return isValidPortNumber(entry);
return isValidPort(entry);
}
const rangeParts = entry.split("-");
if (rangeParts.length !== 2) {
return false;
}
const [start, end] = rangeParts;
return isValidPortNumber(start) && isValidPortNumber(end) && Number(start) <= Number(end);
return isValidPort(start) && isValidPort(end) && Number(start) <= Number(end);
};
if (!portEntries.every(isValidPortEntry)) {
return {
@@ -894,19 +873,12 @@ function validateHttpProxyUrl(url) {
message: _("Invalid HTTP proxy URL: missing port")
};
}
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
if (!isValidPort(port)) {
return {
valid: false,
message: _("Invalid HTTP proxy URL: invalid port number")
};
}
if (!isValidHost(host)) {
return {
valid: false,
message: _("Invalid HTTP proxy URL: invalid host format")
};
}
} catch (_e) {
return {
valid: false,
@@ -2167,7 +2139,7 @@ function renderDefaultState({
"aria-label": _("URLTest details"),
click: (event) => {
event.stopPropagation();
onShowUrlTestInfo(section, outbound);
onShowUrlTestInfo(outbound);
}
},
renderInfoIcon24()
@@ -2183,7 +2155,7 @@ function renderDefaultState({
"aria-label": _("Priority details"),
click: (event) => {
event.stopPropagation();
onShowPriorityInfo(section, outbound);
onShowPriorityInfo(outbound);
}
},
renderInfoIcon24()
@@ -2576,8 +2548,6 @@ var Tachyon;
AvailableMethods2["CHECK_BYEDPI_RUNTIME"] = "check_byedpi_runtime";
AvailableMethods2["CHECK_INBOUNDS_CONFIG"] = "check_inbounds_config";
AvailableMethods2["GET_STATUS"] = "get_status";
AvailableMethods2["GET_OUTBOUND_LINK"] = "get_outbound_link";
AvailableMethods2["GET_OUTBOUND_LINK_STATES"] = "get_outbound_link_states";
AvailableMethods2["GET_OUTBOUND_METADATA"] = "get_outbound_metadata";
AvailableMethods2["GET_SUBSCRIPTION_METADATA"] = "get_subscription_metadata";
AvailableMethods2["CHECK_SING_BOX"] = "check_sing_box";
@@ -2800,19 +2770,14 @@ var TachyonShellMethods = {
Tachyon.AvailableMethods.CHECK_INBOUNDS_CONFIG
),
getStatus: async () => callBaseMethod(Tachyon.AvailableMethods.GET_STATUS),
getOutboundLink: async (section, tag) => callBaseMethod(
Tachyon.AvailableMethods.GET_OUTBOUND_LINK,
[section, tag]
),
getOutboundLinkStates: async (section) => callBaseMethod(
Tachyon.AvailableMethods.GET_OUTBOUND_LINK_STATES,
[section]
),
getOutboundMetadata: async (section) => callBaseMethod(
Tachyon.AvailableMethods.GET_OUTBOUND_METADATA,
[section]
),
getSubscriptionMetadata: async (section) => callBaseMethod(Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA, [section]),
getSubscriptionMetadata: async (section) => callBaseMethod(
Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA,
[section]
),
checkSingBox: async () => callBaseMethod(
Tachyon.AvailableMethods.CHECK_SING_BOX
),
@@ -3040,11 +3005,7 @@ var TachyonShellMethods = {
componentActionStart: async (component, action) => {
const response = await executeShellCommand({
command: "/usr/bin/tachyon",
args: [
Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC,
component,
action
],
args: [Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS
});
const parsedResponse = parseComponentActionStartResult(response);
@@ -3219,9 +3180,6 @@ var DASHBOARD_SECTION_CACHE_DIR = "/var/run/tachyon/section-cache";
function getDisplayName(section) {
return section.label || section[".name"];
}
function getSectionAction(section) {
return section.action || "";
}
function getSettingsSection(configSections) {
return configSections.find((section) => section[".type"] === "settings");
}
@@ -3328,7 +3286,6 @@ function hydrateConfigSections(configSections) {
idle_timeout: item.idle_timeout,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
urltest_filter_mode: item.filter_mode,
detect_server_country: item.detect_server_country,
urltest_include_countries: item.include_countries,
@@ -3375,7 +3332,6 @@ function hydrateConfigSections(configSections) {
fastest_check_interval: item.fastest_check_interval,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
levels
};
});
@@ -3396,7 +3352,9 @@ function getJsonOutbounds(section) {
return values.length ? values : getListValues(section.outbound_json);
}
function isConnectionAction(action) {
return ["connection", "proxy", "outbound", "vpn"].includes(action);
return Boolean(
action && ["connection", "proxy", "outbound", "vpn"].includes(action)
);
}
function hasSubscriptionSources(section) {
return getSubscriptionSourceCount(section) > 0;
@@ -3591,11 +3549,6 @@ function getUrlTestConfigs(section) {
displayName: getUrlTestDisplayName(section, id, settings),
settings,
pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true),
hideAddedOutbounds: itemSettingBoolean(
settings,
"hide_added_outbounds",
false
),
showDetectedCountries: filteringEnabled && itemSettingString(settings, "detect_server_country", "flag_emoji") === "country_is"
};
});
@@ -3654,11 +3607,6 @@ function getPriorityConfigs(section) {
displayName: itemSettingString(settings, "name", id),
settings,
pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true),
hideAddedOutbounds: itemSettingBoolean(
settings,
"hide_added_outbounds",
false
),
healthUrl: itemSettingString(
settings,
"health_url",
@@ -3742,7 +3690,6 @@ function buildUrlTestInfo({
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries
}) {
const childCodes = uniqueCodes(
@@ -3753,7 +3700,7 @@ function buildUrlTestInfo({
childCodes.flatMap((childCode) => {
const childEntry = proxyByCode.get(childCode);
const link = manualLinkByCode.get(childCode) || cachedProxyLinks.get(childCode) || "";
const canCopyLink = isCopyableProxyLink(link) || subscriptionCopyableCodes.has(childCode);
const canCopyLink = isCopyableProxyLink(link);
return [
{
code: childCode,
@@ -3762,7 +3709,7 @@ function buildUrlTestInfo({
childEntry,
link,
outboundMetadata,
subscriptionCopyableCodes.has(childCode)
cachedProxyLinks.has(childCode)
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || "",
@@ -3796,7 +3743,6 @@ function buildPriorityInfo({
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries
}) {
const selectedCode = entry?.value.now || "";
@@ -3832,7 +3778,7 @@ function buildPriorityInfo({
const members = uniqueCodes(level.outbounds || []).map((childCode) => {
const childEntry = proxyByCode.get(childCode);
const link = manualLinkByCode.get(childCode) || cachedProxyLinks.get(childCode) || "";
const canCopyLink = isCopyableProxyLink(link) || subscriptionCopyableCodes.has(childCode);
const canCopyLink = isCopyableProxyLink(link);
return {
code: childCode,
displayName: getOutboundDisplayName(
@@ -3840,7 +3786,7 @@ function buildPriorityInfo({
childEntry,
link,
outboundMetadata,
subscriptionCopyableCodes.has(childCode)
cachedProxyLinks.has(childCode)
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || "",
@@ -3878,7 +3824,7 @@ function buildPriorityInfo({
outbounds
};
}
function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGroups = {}, priorityGroups = {}, subscriptionCopyableCodes = /* @__PURE__ */ new Set(), cachedProxyLinks = /* @__PURE__ */ new Map()) {
function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGroups = {}, priorityGroups = {}, cachedProxyLinks = /* @__PURE__ */ new Map()) {
const sectionName = section[".name"];
const proxyByCode = getProxyEntryByCode(proxies);
const selectorTag = getOutboundTagBySection(sectionName);
@@ -3903,23 +3849,6 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
const selectorCodes = selector?.value?.all ?? [];
const urlTestCodes = urlTestConfigs.map((config) => config.code);
const priorityCodes = priorityConfigs.map((config) => config.code);
const urlTestCodeSet = new Set(urlTestCodes);
const priorityCodeSet = new Set(priorityCodes);
const hideAddedCodeSet = /* @__PURE__ */ new Set();
urlTestEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = urltestGroups[config.code]?.outbounds?.length ? urltestGroups[config.code].outbounds || [] : entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
priorityEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = priorityGroups[config.code]?.outbounds?.length ? priorityGroups[config.code].outbounds || [] : entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
const showDetectedCountries = urlTestConfigs.some((config) => config.showDetectedCountries) || priorityConfigs.some((config) => config.showDetectedCountries);
const builtInUrltestCode = urlTestCodes[0] || "";
const fallbackCodes = uniqueCodes([
@@ -3932,12 +3861,7 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
...selectorCodes.length ? selectorCodes : fallbackCodes,
...urlTestCodes,
...priorityCodes
]).filter((code) => {
if (!hideAddedCodeSet.has(code)) {
return true;
}
return urlTestCodeSet.has(code) || priorityCodeSet.has(code) || isUrlTestProxyEntry(proxyByCode.get(code));
});
]);
const outbounds = uniqueCodes(groupCodes).flatMap((code) => {
const item = proxyByCode.get(code);
const urlTestConfig = urlTestConfigByCode.get(code);
@@ -3946,13 +3870,13 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
return [];
}
const link = manualLinkByCode.get(code) || cachedProxyLinks.get(code) || "";
const canCopyLink = isCopyableProxyLink(link) || subscriptionCopyableCodes.has(code);
const canCopyLink = isCopyableProxyLink(link);
const displayName = priorityConfig?.displayName || urlTestConfig?.displayName || getOutboundDisplayName(
code,
item,
link,
outboundMetadata,
subscriptionCopyableCodes.has(code)
cachedProxyLinks.has(code)
);
const isRuntimeUrlTest = isUrlTestProxyEntry(item);
return [
@@ -3975,7 +3899,6 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries: urlTestConfig?.showDetectedCountries || showDetectedCountries
}) : void 0,
priorityInfo: priorityConfig ? buildPriorityInfo({
@@ -3986,7 +3909,6 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries: priorityConfig.showDetectedCountries
}) : void 0
}
@@ -4091,17 +4013,6 @@ function getOutboundMetadata(dashboardCache) {
countries: objectMap(metadata.countries)
};
}
function getSubscriptionCopyableCodes(dashboardCache) {
const legacyLinks = objectMap(dashboardCache?.links);
const linkRefs = dashboardCache?.linkRefs;
const codes = new Set(
Object.entries(legacyLinks).filter(([, link]) => isCopyableProxyLink(link)).map(([code]) => code)
);
if (linkRefs && typeof linkRefs === "object" && !Array.isArray(linkRefs)) {
Object.keys(linkRefs).forEach((code) => codes.add(code));
}
return codes;
}
function getCachedProxyLinks(dashboardCache) {
return new Map(
Object.entries(objectMap(dashboardCache?.links)).filter(
@@ -4127,11 +4038,11 @@ async function getDashboardSections(options = {}) {
);
const data = await Promise.all(
configSections.filter(
(section) => section.enabled !== "0" && isConnectionAction(getSectionAction(section))
(section) => section.enabled !== "0" && isConnectionAction(section.action)
).map(async (section) => {
const displayName = getDisplayName(section);
const sectionName = section[".name"];
const sectionAction = getSectionAction(section);
const sectionAction = section.action;
const proxyConfigType = getSectionProxyConfigType(section);
if (isConnectionAction(sectionAction) && shouldUseProxyGroup(section)) {
const subscriptionSourceCount = getSubscriptionSourceCount(section);
@@ -4143,7 +4054,6 @@ async function getDashboardSections(options = {}) {
subscriptionSourceCount,
dashboardCache
) : void 0;
const subscriptionCopyableCodes = includeSubscriptionCopyState ? getSubscriptionCopyableCodes(dashboardCache) : /* @__PURE__ */ new Set();
const cachedProxyLinks = includeSubscriptionCopyState ? getCachedProxyLinks(dashboardCache) : /* @__PURE__ */ new Map();
const urltestGroups = getUrlTestGroups(dashboardCache);
const priorityGroups = getPriorityGroups(dashboardCache);
@@ -4153,7 +4063,6 @@ async function getDashboardSections(options = {}) {
outboundMetadata,
urltestGroups,
priorityGroups,
subscriptionCopyableCodes,
cachedProxyLinks
);
return {
@@ -5410,8 +5319,8 @@ var SocketManager = class _SocketManager {
}
this.sockets.set(url, ws);
this.connected.set(url, false);
this.listeners.set(url, /* @__PURE__ */ new Set());
this.errorListeners.set(url, /* @__PURE__ */ new Set());
if (!this.listeners.has(url)) this.listeners.set(url, /* @__PURE__ */ new Set());
if (!this.errorListeners.has(url)) this.errorListeners.set(url, /* @__PURE__ */ new Set());
ws.addEventListener("open", () => {
this.connected.set(url, true);
this.reconnectAttempts.set(url, 0);
@@ -6217,20 +6126,12 @@ async function handleTestLatency(latencyType, sectionName, tag, timeout) {
}
}
}
async function handleCopyOutbound(section, outbound) {
function handleCopyOutbound(outbound) {
const link = outbound.link;
if (link && isCopyableProxyLink(link)) {
copyToClipboard(link);
return;
}
const response = await TachyonShellMethods.getOutboundLink(
section.sectionName,
outbound.code
);
if (response.success && isCopyableProxyLink(response.data.link)) {
copyToClipboard(response.data.link);
return;
}
showToast(_("Proxy link is unavailable"), "error");
}
function formatUrlTestModalValue(value) {
@@ -6342,7 +6243,7 @@ function renderUrlTestCopyButton(title, onClick) {
renderCopyIcon24()
);
}
function renderCommonDetailsModal(info, section, fields, renderMemberName, isPriority) {
function renderCommonDetailsModal(info, fields, renderMemberName, isPriority) {
return E("div", { class: "tachyon_dashboard-page__urltest-details" }, [
E(
"dl",
@@ -6427,7 +6328,7 @@ function renderCommonDetailsModal(info, section, fields, renderMemberName, isPri
),
member.canCopyLink ? renderUrlTestCopyButton(_("Copy proxy link"), (event) => {
event.preventDefault();
void handleCopyOutbound(section, member);
void handleCopyOutbound(member);
}) : E("span", {
class: "tachyon_dashboard-page__urltest-details__copy-placeholder"
})
@@ -6457,7 +6358,7 @@ function renderCommonDetailsModal(info, section, fields, renderMemberName, isPri
])
]);
}
function renderUrlTestInfoModal(section, outbound) {
function renderUrlTestInfoModal(outbound) {
const info = outbound.urlTestInfo;
if (!info) {
return E("div", {}, _("URLTest details are unavailable"));
@@ -6472,25 +6373,24 @@ function renderUrlTestInfoModal(section, outbound) {
{ label: _("Tolerance"), value: info.tolerance },
{ label: _("Idle timeout"), value: info.idleTimeout },
{
label: _("Interrupt existing connections"),
label: _("Interrupt connections"),
value: info.interruptExistConnections
}
];
return renderCommonDetailsModal(
info,
section,
fields,
(member) => renderDetailsMemberName(member),
false
);
}
function handleShowUrlTestInfo(section, outbound) {
function handleShowUrlTestInfo(outbound) {
if (!outbound.urlTestInfo) {
return;
}
ui.showModal(
`${_("URLTest details")}: ${outbound.urlTestInfo.displayName || outbound.displayName}`,
renderUrlTestInfoModal(section, outbound)
renderUrlTestInfoModal(outbound)
);
}
function renderPrioritySelectedValue(info) {
@@ -6553,7 +6453,7 @@ function renderPriorityMemberName(member) {
)
];
}
function renderPriorityInfoModal(section, outbound) {
function renderPriorityInfoModal(outbound) {
const info = outbound.priorityInfo;
if (!info) {
return E("div", {}, _("Priority details are unavailable"));
@@ -6588,25 +6488,24 @@ function renderPriorityInfoModal(section, outbound) {
}
] : [],
{
label: _("Interrupt existing connections"),
label: _("Interrupt connections"),
value: info.interruptExistConnections
}
];
return renderCommonDetailsModal(
info,
section,
fields,
(member) => renderPriorityMemberName(member),
true
);
}
function handleShowPriorityInfo(section, outbound) {
function handleShowPriorityInfo(outbound) {
if (!outbound.priorityInfo) {
return;
}
ui.showModal(
`${_("Priority details")}: ${outbound.priorityInfo.displayName || outbound.displayName}`,
renderPriorityInfoModal(section, outbound)
renderPriorityInfoModal(outbound)
);
}
async function handleUpdateSubscription(section) {
@@ -6772,14 +6671,14 @@ async function renderSectionsWidget() {
onChooseOutbound: (sectionName, selector, tag) => {
void handleChooseOutbound(sectionName, selector, tag);
},
onCopyOutbound: (section2, outbound) => {
void handleCopyOutbound(section2, outbound);
onCopyOutbound: (_section, outbound) => {
handleCopyOutbound(outbound);
},
onShowUrlTestInfo: (section2, outbound) => {
handleShowUrlTestInfo(section2, outbound);
onShowUrlTestInfo: (outbound) => {
handleShowUrlTestInfo(outbound);
},
onShowPriorityInfo: (section2, outbound) => {
handleShowPriorityInfo(section2, outbound);
onShowPriorityInfo: (outbound) => {
handleShowPriorityInfo(outbound);
},
onUpdateSubscription: (section2) => {
void handleUpdateSubscription(section2);
@@ -9639,6 +9538,7 @@ var SING_BOX_MASKED_KEYS = /* @__PURE__ */ new Set([
]);
var TACHYON_MASK_AFTER_TOKEN = [
"option proxy_string",
"option hwid",
"option subscription_url",
"list subscription_urls",
"list urltest_proxy_links",
@@ -14343,8 +14243,6 @@ return baseclass.extend({
validateDNS,
validateDomain,
validateIP,
validateIPV4,
validateIPv6,
validateOutboundJson,
validatePath,
validateProxyUrl,
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require baseclass";
"require form";
"require view.tachyon.local_devices as localDevices";
@@ -431,6 +431,8 @@ let actionProvidersAvailabilityLoader = null;
const outboundNameChoicesCache = new Map();
const outboundNameChoicesInflight = new Map();
const outboundNameSourceOptions = new Map();
const sectionGroupSourceOptions = new Map();
const dashboardFilterChoiceRefreshers = new Map();
const SECTION_CACHE_DIR = "/var/run/tachyon/section-cache";
const COUNTRY_CODES =
"AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW XK".split(
@@ -1323,7 +1325,14 @@ const SettingsDynamicList = form.DynamicList.extend({
},
});
return widget.render();
const node = widget.render();
if (typeof this.onListChange === "function") {
node.addEventListener("cbi-dynlist-change", () => {
this.onListChange(section_id);
});
}
return node;
},
parse(section_id) {
@@ -1415,6 +1424,78 @@ const ButtonAddSettingsDynamicList = SettingsDynamicList.extend({
buttonAdd: true,
});
function configureLiveDynamicListChoices(option, getChoices) {
option.renderWidget = function (section_id, _option_index, cfgvalue) {
const values = L.toArray(cfgvalue != null ? cfgvalue : this.default);
const choices = getChoices(section_id, values);
const labels = {};
choices.forEach((choice) => {
labels[choice.value] = choice.label;
});
refreshOptionChoices(this, choices);
let choiceSignature = JSON.stringify(
choices.map((choice) => [choice.value, choice.label]),
);
// form.DynamicList passes null when there are no choices, which permanently
// renders a plain input. An empty object keeps the stock combobox alive so
// later live choices can be added without reopening the modal.
const widget = new ui.DynamicList(values, labels, {
id: this.cbid(section_id),
sort: this.keylist,
allowduplicates: this.allowduplicates,
optional: this.optional || this.rmempty,
datatype: this.datatype,
placeholder: this.placeholder,
validate: L.bind(this.validate, this, section_id),
disabled: this.readonly != null ? this.readonly : this.map.readonly,
});
const node = widget.render();
const refreshChoices = () => {
if (!node.isConnected) {
return false;
}
const currentValues = widget.getValue();
const currentChoices = getChoices(section_id, currentValues);
const currentLabels = {};
currentChoices.forEach((choice) => {
currentLabels[choice.value] = choice.label;
});
const currentSignature = JSON.stringify(
currentChoices.map((choice) => [choice.value, choice.label]),
);
if (currentSignature === choiceSignature) {
return;
}
choiceSignature = currentSignature;
refreshOptionChoices(this, currentChoices);
widget.choices = currentLabels;
widget.clearChoices();
widget.addChoices(
currentChoices.map((choice) => choice.value),
currentLabels,
);
return true;
};
const refreshBeforeOpening = (event) => {
if (event.target && event.target.closest(".add-item")) {
refreshChoices();
}
};
node.addEventListener("mousedown", refreshBeforeOpening, true);
node.addEventListener("focusin", refreshBeforeOpening, true);
if (!dashboardFilterChoiceRefreshers.has(section_id)) {
dashboardFilterChoiceRefreshers.set(section_id, new Set());
}
dashboardFilterChoiceRefreshers.get(section_id).add(refreshChoices);
return node;
};
}
function countryChoices() {
return COUNTRY_CODES.map((code) => ({
value: code,
@@ -1529,6 +1610,94 @@ function currentDraftOutboundNames(section_id) {
return names;
}
function currentSectionGroupValues(section_id, typeName) {
const liveValues = currentLiveDynamicListValues(section_id, typeName);
if (liveValues != null) {
return liveValues;
}
const option = sectionGroupSourceOptions.get(typeName);
if (option && typeof option.formvalue === "function") {
try {
const value = option.formvalue(section_id);
if (value != null) {
return normalizeDynamicListItems(value);
}
} catch (_error) {
// Fall back to the child sections when the widget is not mounted.
}
}
return getChildItemIds(section_id, typeName);
}
function sectionGroupDisplayName(section_id, typeName, value) {
const itemId = `${value || ""}`.trim();
if (isExistingChildItem(section_id, itemId, typeName)) {
return `${uci.get(UCI_PACKAGE, itemId, "name") || itemId}`.trim();
}
const option = sectionGroupSourceOptions.get(typeName);
const pending =
option && option.pendingChildSettings
? option.pendingChildSettings[section_id]
: null;
const settings = pending && pending[itemId];
return `${(settings && settings.name) || itemId}`.trim();
}
function currentSectionGroupChoices(section_id, selectedValues = []) {
const seen = new Set();
const result = [];
const append = (typeName, value) => {
value = `${value || ""}`.trim();
if (!value) {
return;
}
const name = sectionGroupDisplayName(section_id, typeName, value) || value;
if (seen.has(name)) {
return;
}
seen.add(name);
result.push({ value: name, label: name });
};
currentSectionGroupValues(section_id, "urltest").forEach((value) =>
append("urltest", value),
);
currentSectionGroupValues(section_id, "priority_group").forEach((value) =>
append("priority_group", value),
);
normalizeDynamicListItems(selectedValues).forEach((value) => {
value = `${value || ""}`.trim();
if (value && !seen.has(value)) {
seen.add(value);
result.push({ value, label: value });
}
});
return result.sort((left, right) => left.label.localeCompare(right.label));
}
function refreshDashboardFilterChoiceWidgets(section_id) {
const refreshers = dashboardFilterChoiceRefreshers.get(section_id);
if (!refreshers) {
return;
}
refreshers.forEach((refresh) => {
if (refresh() === false) {
refreshers.delete(refresh);
}
});
if (refreshers.size === 0) {
dashboardFilterChoiceRefreshers.delete(section_id);
}
}
function isDownloadThroughTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section);
const action = (section && section.action) || "";
@@ -1916,7 +2085,6 @@ function urlTestSettingsKeys() {
"idle_timeout",
"interrupt_exist_connections",
"pin_dashboard",
"hide_added_outbounds",
"filter_mode",
"detect_server_country",
"include_countries",
@@ -1945,7 +2113,6 @@ function defaultUrlTestSettings(name) {
idle_timeout: "30m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
filter_mode: "disabled",
detect_server_country: "flag_emoji",
};
@@ -1959,7 +2126,6 @@ function urlTestChildDefaults() {
idle_timeout: "30m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
filter_mode: "disabled",
detect_server_country: "flag_emoji",
};
@@ -1977,7 +2143,6 @@ function priorityGroupSettingsKeys() {
"fastest_check_interval",
"interrupt_exist_connections",
"pin_dashboard",
"hide_added_outbounds",
];
}
@@ -1993,7 +2158,6 @@ function defaultPriorityGroupSettings() {
fastest_check_interval: "3m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
};
}
@@ -2008,7 +2172,6 @@ function priorityGroupChildDefaults() {
fastest_check_interval: "3m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
};
}
@@ -2351,9 +2514,9 @@ function addUrlTestItemOptions(itemSection, options = {}) {
o = itemSection.option(
form.Flag,
"interrupt_exist_connections",
_("Interrupt existing connections"),
_("Interrupt connections"),
_(
"Interrupt existing connections when URLTest switches the selected server",
"Interrupt connections when URLTest switches the selected server",
),
);
o.default = "1";
@@ -2368,15 +2531,6 @@ function addUrlTestItemOptions(itemSection, options = {}) {
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"hide_added_outbounds",
_("Hide added servers"),
_("Hide dashboard servers added to URLTest"),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.ListValue,
"filter_mode",
@@ -2507,6 +2661,9 @@ function addUrlTestItemOptions(itemSection, options = {}) {
});
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(parentSectionForItem(itemId), values),
);
}
if (validator) {
list.validate = function (_itemId, value) {
@@ -2717,6 +2874,9 @@ function addPriorityLevelItemOptions(itemSection, options = {}) {
});
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(parentSectionForItem(itemId), values),
);
}
if (validator) {
list.validate = function (_itemId, value) {
@@ -2842,8 +3002,8 @@ function addPriorityGroupItemOptions(itemSection, options = {}) {
o = itemSection.option(
form.Flag,
"interrupt_exist_connections",
_("Interrupt existing connections"),
_("Interrupt existing connections when priority failover switches server"),
_("Interrupt connections"),
_("Interrupt connections when priority failover switches server"),
);
o.default = "1";
o.rmempty = false;
@@ -2857,15 +3017,6 @@ function addPriorityGroupItemOptions(itemSection, options = {}) {
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"hide_added_outbounds",
_("Hide added servers"),
_("Hide dashboard servers added to Priority"),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
ButtonAddSettingsDynamicList,
"priority_level",
@@ -2937,6 +3088,229 @@ function addPriorityGroupItemOptions(itemSection, options = {}) {
};
}
function addDashboardGroupFilterOption(
optionSection,
key,
label,
description,
modes,
) {
const list = optionSection.option(form.DynamicList, key, label, description);
modes.forEach((mode) => {
list.depends({
action: "connection",
dashboard_filter_mode: mode,
urltest: /.+/,
});
list.depends({
action: "connection",
dashboard_filter_mode: mode,
priority_group: /.+/,
});
});
list.rmempty = true;
list.placeholder = _("-- Select --");
list.load = function (section_id) {
const values = getConfigListValues(section_id, key);
const choices = currentSectionGroupChoices(section_id, values);
refreshOptionChoices(this, choices);
return values;
};
list.validate = function (section_id, value) {
const selected = new Set(
currentSectionGroupChoices(section_id).map((choice) => choice.value),
);
return normalizeDynamicListItems(value).every((item) => selected.has(item))
? true
: _("Select an existing URLTest or Priority group");
};
configureLiveDynamicListChoices(list, currentSectionGroupChoices);
}
function addDashboardServerFilterOptions(section) {
const optionSection = {
option: (optionType, ...args) => {
const option = section.taboption("settings", optionType, ...args);
option.modalonly = true;
return option;
},
};
let o = optionSection.option(
form.ListValue,
"dashboard_filter_mode",
_("Servers on dashboard"),
_("Filter the servers that will be displayed on the dashboard."),
);
urlTestFilterModeChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "disabled";
o.depends("action", "connection");
o = optionSection.option(
form.ListValue,
"dashboard_detect_server_country",
_("Detect server country"),
);
["exclude", "include", "mixed"].forEach((mode) =>
o.depends({ action: "connection", dashboard_filter_mode: mode }),
);
serverCountryDetectionChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "flag_emoji";
const includeProxyParameterOptions = {
prefix: "dashboard_include",
toggleLabel: _("Include by proxy parameters"),
toggleDescription: _(
"Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters.",
),
dependencies: [
{ action: "connection", dashboard_filter_mode: "include" },
{ action: "connection", dashboard_filter_mode: "mixed" },
],
protocolDescription: _(
"Show only servers with one of the selected protocols.",
),
transportDescription: _(
"Show only servers with one of the selected transports.",
),
securityDescription: _(
"Show only servers with one of the selected security types.",
),
};
const excludeProxyParameterOptions = {
prefix: "dashboard_exclude",
toggleLabel: _("Exclude by proxy parameters"),
toggleDescription: _(
"Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters.",
),
dependencies: [
{ action: "connection", dashboard_filter_mode: "exclude" },
{ action: "connection", dashboard_filter_mode: "mixed" },
],
protocolDescription: _("Hide servers with one of the selected protocols."),
transportDescription: _(
"Hide servers with one of the selected transports.",
),
securityDescription: _(
"Hide servers with one of the selected security types.",
),
};
[
[
"dashboard_include_countries",
_("Include countries"),
_("Show servers only from the specified countries."),
countryChoices(),
validateCountryCode,
["include", "mixed"],
],
[
"dashboard_include_outbounds",
_("Include servers"),
_("Show only selected servers."),
null,
null,
["include", "mixed"],
],
[
"dashboard_include_regex",
_("Include by regular expression"),
_("Show servers whose names match the expression."),
null,
validateRegex,
["include", "mixed"],
],
[
"dashboard_exclude_countries",
_("Exclude countries"),
_("Hide servers from the specified countries."),
countryChoices(),
validateCountryCode,
["exclude", "mixed"],
],
[
"dashboard_exclude_outbounds",
_("Exclude servers"),
_("Hide selected servers."),
null,
null,
["exclude", "mixed"],
],
[
"dashboard_exclude_regex",
_("Exclude by regular expression"),
_("Hide servers whose names match the expression."),
null,
validateRegex,
["exclude", "mixed"],
],
].forEach(([key, label, description, choices, validator, modes]) => {
const list = optionSection.option(
form.DynamicList,
key,
label,
description,
);
modes.forEach((mode) =>
list.depends({ action: "connection", dashboard_filter_mode: mode }),
);
list.rmempty = true;
if (choices) {
choices.forEach((choice) => list.value(choice.value, choice.label));
list.placeholder = _("-- Select --");
}
if (key.endsWith("_outbounds")) {
list.load = function (section_id) {
const values = getConfigListValues(section_id, key);
loadOutboundNameChoices(section_id).then(() => {
refreshDashboardFilterChoiceWidgets(section_id);
});
return values;
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, currentOutboundNameChoices);
}
if (validator) {
list.validate = function (_section_id, value) {
return validator(null, value);
};
}
if (key === "dashboard_include_regex") {
addDashboardGroupFilterOption(
optionSection,
"dashboard_include_groups",
_("Add URLTest / Priority group"),
_(
"Show servers from selected groups without repeating their filtering criteria.",
),
["include", "mixed"],
);
addProxyParameterFilterOptions(
optionSection,
includeProxyParameterOptions,
);
} else if (key === "dashboard_exclude_regex") {
addDashboardGroupFilterOption(
optionSection,
"dashboard_exclude_groups",
_("Exclude URLTest / Priority group"),
_(
"Hide servers from selected groups without repeating their filtering criteria.",
),
["exclude", "mixed"],
);
addProxyParameterFilterOptions(
optionSection,
excludeProxyParameterOptions,
);
}
});
}
function settingValueEquals(left, right) {
const normalize = (value) => {
if (Array.isArray(value)) {
@@ -3017,6 +3391,39 @@ function renderStackedJsonSettingsModal(title, map, onSave) {
const originalButtonNodes = Array.from(buttonRow.childNodes);
let closed = false;
let saveButton;
let validationSummary;
const clearValidationSummary = () => {
if (validationSummary && validationSummary.parentNode) {
validationSummary.parentNode.removeChild(validationSummary);
}
validationSummary = null;
};
const showValidationSummary = (error) => {
clearValidationSummary();
const message = error?.message || "";
validationSummary = E(
"div",
{
class:
"alert-message warning fkp-stacked-settings-validation-summary",
},
[
E("strong", {}, _("Cannot save settings")),
E("div", {}, _("Fix the highlighted fields and save again.")),
message ? E("small", {}, message) : "",
],
);
buttonRow.parentNode.insertBefore(validationSummary, buttonRow);
const invalidInput = nodes.querySelector(".cbi-input-invalid");
if (invalidInput) {
invalidInput.scrollIntoView({ block: "center", behavior: "smooth" });
invalidInput.focus({ preventScroll: true });
}
};
const restoreButtonRow = () => {
buttonRow.textContent = "";
@@ -3030,6 +3437,7 @@ function renderStackedJsonSettingsModal(title, map, onSave) {
}
closed = true;
clearValidationSummary();
if (nodes.parentNode) {
nodes.parentNode.removeChild(nodes);
@@ -3046,6 +3454,7 @@ function renderStackedJsonSettingsModal(title, map, onSave) {
if (saveButton) {
saveButton.disabled = true;
}
clearValidationSummary();
return map
.parse()
@@ -3053,10 +3462,11 @@ function renderStackedJsonSettingsModal(title, map, onSave) {
onSave(cleanFormSectionData(map.data.get(map.config, "settings")));
close();
})
.catch(() => {
.catch((error) => {
if (saveButton) {
saveButton.disabled = false;
}
showValidationSummary(error);
});
};
@@ -3685,20 +4095,6 @@ function populateActionOptionValues(option) {
}
}
function setFlagOptionWidgetValue(section_id, optionName, enabled) {
const frame = document.getElementById(
`cbid.${UCI_PACKAGE}.${section_id}.${optionName}`,
);
const checkbox = frame ? frame.querySelector('input[type="checkbox"]') : null;
if (!checkbox || checkbox.checked === Boolean(enabled)) {
return;
}
checkbox.checked = Boolean(enabled);
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
}
function getConfigListValues(section_id, key) {
return normalizeOptionValues(uci.get(UCI_PACKAGE, section_id, key));
}
@@ -3988,21 +4384,7 @@ function validateKeyword(_section_id, value) {
}
function isSingBoxDuration(value) {
return /^([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
}
function readDurationOptionWithDefault(section_id, key, defaultValue) {
if (uci.get(UCI_PACKAGE, section_id, `${key}_disabled`) === "1") {
return "";
}
const rawValue = uci.get(UCI_PACKAGE, section_id, key);
if (rawValue == null) {
return defaultValue;
}
return `${rawValue}`;
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
}
function writeOptionalDurationOption(section_id, key, value) {
@@ -4018,10 +4400,6 @@ function writeOptionalDurationOption(section_id, key, value) {
}
}
function removeOptionalDurationOption(section_id, key) {
writeOptionalDurationOption(section_id, key, "");
}
function validateOptionalSingBoxDuration(value) {
const normalized = value ? `${value}`.trim() : "";
@@ -4081,39 +4459,6 @@ function validateSubscriptionUrlEntry(_section_id, value) {
return validation.valid ? true : validation.message;
}
function parseRequiredValueOnSave(section_id) {
const active = this.isActive(section_id);
if (active && !this.isValid(section_id)) {
const title = this.stripTags(this.title).trim();
const error = this.getValidationError(section_id);
return Promise.reject(
new TypeError(
`${_('Option "%s" contains an invalid input value.').format(title || this.option)} ${error}`,
),
);
}
if (active) {
const formValue = this.formvalue(section_id);
const normalized = formValue ? `${formValue}`.trim() : "";
if (!normalized.length) {
return Promise.reject(
new TypeError(_("Subscription URL cannot be empty")),
);
}
return Promise.resolve(this.write(section_id, normalized));
}
if (!this.retain) {
return Promise.resolve(this.remove(section_id));
}
return Promise.resolve();
}
function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) {
const seen = new Set();
const duplicates = [];
@@ -4134,57 +4479,6 @@ function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) {
return duplicates.map((item) => `${item}: ${duplicateMessage}`);
}
function validateTextList(
_section_id,
value,
validateItem,
emptyMessage,
options = {},
) {
if (!value || value.length === 0) {
return true;
}
const values = main.parseValueList(value);
if (!values.length) {
return emptyMessage;
}
if (!validateItem) {
const duplicateErrors = getDuplicateTextListErrors(
values,
options.normalizeDuplicateValue,
options.duplicateMessage || _("Duplicate value"),
);
if (!duplicateErrors.length) {
return true;
}
return [_("Validation errors:"), ...duplicateErrors].join("\n");
}
const { valid, results } = main.bulkValidate(values, validateItem);
const duplicateErrors = getDuplicateTextListErrors(
values,
options.normalizeDuplicateValue,
options.duplicateMessage || _("Duplicate value"),
);
if (valid && !duplicateErrors.length) {
return true;
}
const errors = results
.filter((item) => !item.valid)
.map((item) => `${item.value}: ${item.message}`)
.concat(duplicateErrors);
return [_("Validation errors:"), ...errors].join("\n");
}
function getValidationHeaderText() {
return _("Validation errors:");
}
@@ -6855,6 +7149,9 @@ function createSectionContent(section) {
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
o.onchange = function (_event, section_id) {
refreshDashboardFilterChoiceWidgets(section_id);
};
outboundNameSourceOptions.set("selector_proxy_links", o);
o = section.taboption(
@@ -6943,6 +7240,7 @@ function createSectionContent(section) {
delete this.pendingChildSettings[section_id];
}
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("interfaces", o);
o = section.taboption(
@@ -6969,6 +7267,7 @@ function createSectionContent(section) {
const validation = main.validateOutboundJson(value);
return validation.valid ? true : validation.message;
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("outbound_jsons", o);
o = section.taboption(
@@ -7027,6 +7326,8 @@ function createSectionContent(section) {
this.inputValueForItem(section_id, itemId),
);
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("urltest", o);
o = section.taboption(
"settings",
@@ -7064,6 +7365,8 @@ function createSectionContent(section) {
this.inputValueForItem(section_id, itemId),
);
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("priority_group", o);
o = section.taboption(
"settings",
@@ -7444,6 +7747,8 @@ function createSectionContent(section) {
),
);
ruleSetOption.modalonly = true;
// Both widgets map to rule_set, so neither inactive view may erase shared storage.
ruleSetOption.retain = true;
dependsOnRoutingAction(ruleSetOption);
ruleSetOption.renderItemSettingsModal = showRuleSetSettingsModal;
ruleSetOption.load = function (section_id) {
@@ -7472,6 +7777,7 @@ function createSectionContent(section) {
);
dnsRuleSetOption.depends("action", "dns");
dnsRuleSetOption.modalonly = true;
dnsRuleSetOption.retain = true;
dnsRuleSetOption.load = function (section_id) {
return getCustomRulesetReferences(section_id);
};
@@ -7527,6 +7833,8 @@ function createSectionContent(section) {
dynamicValidate: validatePortCondition,
});
dependsOnRoutingAction(portsOption);
addDashboardServerFilterOptions(section);
}
function loadSectionTableOptions(sectionRef) {
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require form";
"require baseclass";
"require dom";
@@ -496,10 +496,6 @@ function getFirstListValue(sectionId, option) {
return asList(uci.get(UCI_PACKAGE, sectionId, option))[0] || "";
}
function serverInboundTag(sectionId) {
return `server-${sectionId}-in`;
}
function getProtocol(sectionId) {
return uci.get(UCI_PACKAGE, sectionId, "protocol") || DEFAULT_SERVER_PROTOCOL;
}
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require form";
"require uci";
"require baseclass";
@@ -8,7 +8,7 @@
const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE;
function isSingBoxDuration(value) {
return /^([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
}
function latencyTestUrlChoices() {
@@ -442,7 +442,6 @@ const EntryPoint = {
return ["telegram"];
};
settings.createTelegramContent(telegramSection);
const diagnosticSection = tachyonMap.section(
form.TypedSection,
"diagnostic",
@@ -1,4 +1,4 @@
"use strict";
"use strict";
"require baseclass";
"require form";
"require ui";
+157 -15
View File
@@ -1,15 +1,28 @@
<<<<<<<< HEAD:luci-app-tachyon/po/ru/tachyon.po
# RU translations for TACHYON package.
# Copyright (C) 2026 THE TACHYON COPYRIGHT HOLDER
# This file is distributed under the same license as the TACHYON package.
# Dushnilin, 2026.
========
# RU translations for TACHYON package.
# Copyright (C) 2026 THE TACHYON COPYRIGHT HOLDER
# This file is distributed under the same license as the TACHYON package.
# ushan0v, 2026.
>>>>>>>> temp-upstream-rebrand:fe-app-tachyon/locales/tachyon.ru.po
#
msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
<<<<<<<< HEAD:luci-app-tachyon/po/ru/tachyon.po
"POT-Creation-Date: 2026-07-13 23:43+0300\n"
"PO-Revision-Date: 2026-07-13 23:43+0300\n"
"Last-Translator: Dushnilin\n"
========
"POT-Creation-Date: 2026-07-13 01:05+0300\n"
"PO-Revision-Date: 2026-07-13 01:05+0300\n"
"Last-Translator: ushan0v\n"
>>>>>>>> temp-upstream-rebrand:fe-app-tachyon/locales/tachyon.ru.po
"Language-Team: none\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
@@ -77,6 +90,9 @@ msgstr "Добавьте URL или локальные пути к списка
msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила."
msgid "Add URLTest / Priority group"
msgstr "Добавить URLTest / Приоритет группу"
msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки"
@@ -86,6 +102,12 @@ msgstr "Дополнительно исключать серверы по про
msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters."
msgstr "Дополнительно скрывать серверы по протоколу, транспорту и защите. Скрывать серверы, совпавшие по указанным параметрам."
msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно показывать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Address"
msgstr "Адрес"
@@ -245,6 +267,9 @@ msgstr "Не удалось получить результаты проверк
msgid "Cannot save server"
msgstr "Не удалось сохранить сервер"
msgid "Cannot save settings"
msgstr "Не удалось сохранить настройки"
msgid "Cascade connection"
msgstr "Каскадное подключение"
@@ -665,6 +690,9 @@ msgstr "Исключить из этого уровня серверы с одн
msgid "Exclude servers with one of the selected transports from this level."
msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов."
msgid "Exclude URLTest / Priority group"
msgstr "Исключить URLTest / Приоритет группу"
msgid "Expecting: %s"
msgstr "Ожидается: %s"
@@ -716,6 +744,9 @@ msgstr "Интервал поиска более быстрого сервера
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Filter the servers that will be displayed on the dashboard."
msgstr "Отфильтровать серверы, которые будут отображаться в дашборде."
msgid "Firewall WAN port"
msgstr "Порт в firewall для WAN"
@@ -725,6 +756,66 @@ msgstr "Исправьте подсвеченные поля и сохранит
msgid "Forced device routing"
msgstr "Принудительная маршрутизация устройств"
<<<<<<<< HEAD:luci-app-tachyon/po/ru/tachyon.po
========
msgid "Tachyon Error"
msgstr "Ошибка Tachyon"
msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here."
msgstr "Tachyon сам управляет pid-файлами ciadpi, поэтому параметры pidfile здесь запрещены."
msgid "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here."
msgstr "Tachyon сам управляет жизненным циклом ciadpi, поэтому режим daemon здесь запрещён."
msgid "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here."
msgstr "Tachyon сам управляет жизненным циклом nfqws, поэтому режим daemon здесь запрещён."
msgid "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here."
msgstr "Tachyon сам управляет жизненным циклом nfqws2, поэтому режим daemon здесь запрещён."
msgid "Tachyon service is stopped. Start the service to display connections."
msgstr "Служба Tachyon остановлена. Запустите сервис для отображения подключений."
msgid "Tachyon service is stopped. Start the service to display the dashboard."
msgstr "Служба Tachyon остановлена. Запустите сервис для отображения дашборда."
msgid "Tachyon Settings"
msgstr "Настройки Tachyon"
msgid "Tachyon will not modify your DHCP configuration"
msgstr "Tachyon не будет изменять вашу конфигурацию DHCP"
msgid "Tachyon-managed ciadpi runtime has restarted"
msgstr "ciadpi Tachyon перезапускался"
msgid "Tachyon-managed ciadpi runtime is not ready"
msgstr "ciadpi Tachyon не готов"
msgid "Tachyon-managed ciadpi runtime is not running"
msgstr "ciadpi Tachyon не запущен"
msgid "Tachyon-managed ciadpi runtime is ready"
msgstr "ciadpi Tachyon готов"
msgid "Tachyon-managed nfqws runtime is not ready"
msgstr "NFQWS Tachyon не готов"
msgid "Tachyon-managed nfqws runtime is not running"
msgstr "NFQWS Tachyon не запущен"
msgid "Tachyon-managed nfqws runtime is ready"
msgstr "NFQWS Tachyon готов"
msgid "Tachyon-managed nfqws2 runtime is not ready"
msgstr "NFQWS2 Tachyon не готов"
msgid "Tachyon-managed nfqws2 runtime is not running"
msgstr "NFQWS2 Tachyon не запущен"
msgid "Tachyon-managed nfqws2 runtime is ready"
msgstr "NFQWS2 Tachyon готов"
>>>>>>>> temp-upstream-rebrand:fe-app-tachyon/locales/tachyon.ru.po
msgid "Fronting IP"
msgstr "IP фронтинга"
@@ -755,24 +846,36 @@ msgstr "gRPC-сервис"
msgid "Handshake timeout"
msgstr "Таймаут рукопожатия"
msgid "Hide added servers"
msgstr "Скрывать добавленные серверы"
msgid "Hide cascade connection nodes"
msgstr "Скрывать узлы каскадных подключений"
msgid "Hide dashboard servers added to Priority"
msgstr "Скрывать серверы, добавленные в Priority, из дашборда"
msgid "Hide dashboard servers added to URLTest"
msgstr "Скрывать серверы из дашборда, которые добавлены в URLTest"
msgid "Hide individual nodes that are already included in imported subscription URLTest groups"
msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки"
msgid "Hide intermediate nodes used as detours by other subscription nodes"
msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки"
msgid "Hide selected servers."
msgstr "Скрывать выбранные серверы."
msgid "Hide servers from selected groups without repeating their filtering criteria."
msgstr "Скрывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Hide servers from the specified countries."
msgstr "Скрывать серверы из указанных стран."
msgid "Hide servers whose names match the expression."
msgstr "Скрывать серверы, имена которых совпадают с выражением."
msgid "Hide servers with one of the selected protocols."
msgstr "Скрывать серверы с одним из выбранных протоколов."
msgid "Hide servers with one of the selected security types."
msgstr "Скрывать серверы с одним из выбранных типов защиты."
msgid "Hide servers with one of the selected transports."
msgstr "Скрывать серверы с одним из выбранных транспортов."
msgid "Hide URLTest group nodes"
msgstr "Скрывать узлы URLTest групп"
@@ -866,14 +969,14 @@ msgstr "Задержка при мониторинге интерфейсов"
msgid "Interface monitoring for Bad WAN"
msgstr "Мониторинг интерфейса для Bad WAN"
msgid "Interrupt existing connections"
msgstr "Прерывать существующие соединения"
msgid "Interrupt connections"
msgstr "Прерывать соединения"
msgid "Interrupt existing connections when priority failover switches server"
msgstr "Разрывать существующие соединения при переключении сервера приоритетом"
msgid "Interrupt connections when priority failover switches server"
msgstr "Разрывать соединения при переключении сервера приоритетом"
msgid "Interrupt existing connections when URLTest switches the selected server"
msgstr "Прерывать существующие соединения, когда URLTest переключает выбранный сервер"
msgid "Interrupt connections when URLTest switches the selected server"
msgstr "Прерывать соединения, когда URLTest переключает выбранный сервер"
msgid "Interval"
msgstr "Интервал"
@@ -1640,6 +1743,9 @@ msgstr "Выберите транзитную секцию"
msgid "Select an existing network interface"
msgstr "Выберите существующий сетевой интерфейс"
msgid "Select an existing URLTest or Priority group"
msgstr "Выберите существующую URLTest или Приоритет группу"
msgid "Select an intermediate section"
msgstr "Выберите промежуточную секцию"
@@ -1733,6 +1839,9 @@ msgstr "server_port должен быть числом от 1 до 65535"
msgid "Servers"
msgstr "Серверы"
msgid "Servers on dashboard"
msgstr "Серверы в дашборде"
msgid "Service action failed"
msgstr "Действие сервиса не выполнено"
@@ -1748,6 +1857,27 @@ msgstr "Настройки"
msgid "Show metadata on dashboard"
msgstr "Отображать метаданные в дашборде"
msgid "Show only selected servers."
msgstr "Показывать только выбранные серверы."
msgid "Show only servers with one of the selected protocols."
msgstr "Показывать только серверы с одним из выбранных протоколов."
msgid "Show only servers with one of the selected security types."
msgstr "Показывать только серверы с одним из выбранных типов защиты."
msgid "Show only servers with one of the selected transports."
msgstr "Показывать только серверы с одним из выбранных транспортов."
msgid "Show servers from selected groups without repeating their filtering criteria."
msgstr "Показывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Show servers only from the specified countries."
msgstr "Показывать серверы только из указанных стран."
msgid "Show servers whose names match the expression."
msgstr "Показывать серверы, имена которых совпадают с выражением."
msgid "Show sing-box config"
msgstr "Показать sing-box конфигурацию"
@@ -2093,6 +2223,18 @@ msgstr "Таймаут недоступности"
msgid "Unexpected ByeDPI strategy argument: %s"
msgstr "Неожиданный аргумент стратегии ByeDPI: %s"
<<<<<<<< HEAD:luci-app-tachyon/po/ru/tachyon.po
========
msgid "Unexpected Tachyon-managed ciadpi runtime is running"
msgstr "Обнаружен лишний ciadpi Tachyon"
msgid "Unexpected Tachyon-managed nfqws runtime is running"
msgstr "Обнаружен лишний NFQWS Tachyon"
msgid "Unexpected Tachyon-managed nfqws2 runtime is running"
msgstr "Обнаружен лишний NFQWS2 Tachyon"
>>>>>>>> temp-upstream-rebrand:fe-app-tachyon/locales/tachyon.ru.po
msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value."
msgstr "Неожиданный отдельный токен. Используйте явные флаги вида --имя или --имя=значение."
+7 -2
View File
@@ -1,4 +1,4 @@
config settings 'settings'
config settings 'settings'
option config_version '1.0.2'
list applied_migrations 'interface_sections'
list applied_migrations 'enable_component_checks'
@@ -44,6 +44,12 @@ config settings 'settings'
# list community_lists 'russia_inside'
# list rule_set 'https://example.com/rules.srs'
# list rule_set_with_subnets 'https://example.com/rules-with-subnets.json'
# option dashboard_filter_mode 'disabled'
# option dashboard_detect_server_country 'flag_emoji'
# #list dashboard_include_outbounds 'Example server name'
# #list dashboard_include_groups 'Fastest'
# #list dashboard_exclude_outbounds 'Backup server name'
# #list dashboard_exclude_groups 'Backup'
# option outbound_detour_enabled '0'
# #option outbound_detour_section 'main'
# option sort_by_latency '0'
@@ -81,7 +87,6 @@ config settings 'settings'
# option idle_timeout '30m'
# option interrupt_exist_connections '1'
# option pin_dashboard '1'
# option hide_added_outbounds '0'
# option filter_mode 'disabled'
# option detect_server_country 'flag_emoji'
# list exclude_countries 'US'
Executable → Regular
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh /etc/rc.common
#!/bin/sh /etc/rc.common
# shellcheck disable=SC2034
START=99
+3 -4
View File
@@ -91,7 +91,7 @@ function show_help() {
print(" show_sing_box_config Show sing-box configuration\n");
print(" show_sing_box_version Show sing-box version\n");
print(" get_status Get Tachyon service status\n");
print(" get_outbound_link Get source proxy link for a generated outbound\n");
print(" get_outbound_metadata Get generated outbound display names and countries\n");
print(" get_subscription_metadata\n");
print(" Get UI-only metadata for a subscription rule\n");
@@ -168,8 +168,6 @@ function command_spec(command) {
show_sing_box_config: [ "diagnostics/runtime.uc", "show-sing-box-config", 1 ],
show_sing_box_version: [ "diagnostics/runtime.uc", "show-sing-box-version", 0 ],
get_status: [ "diagnostics/runtime.uc", "get-status", 0 ],
get_outbound_link: [ "diagnostics/runtime.uc", "get-outbound-link", 2 ],
get_outbound_link_states: [ "diagnostics/runtime.uc", "get-outbound-link-states", 1 ],
get_outbound_metadata: [ "diagnostics/runtime.uc", "get-outbound-metadata", 1 ],
get_subscription_metadata: [ "diagnostics/runtime.uc", "get-subscription-metadata", 1 ],
get_sing_box_status: [ "diagnostics/runtime.uc", "get-sing-box-status", 0 ],
@@ -201,7 +199,8 @@ function command_spec(command) {
validate_nfqws_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws-strategy-json", 1 ],
validate_nfqws2_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws2-strategy-json", 1 ],
validate_byedpi_strategy_json: [ "providers/byedpi/validator.uc", "validate-json", 1 ],
package_prerm: [ "service/package.uc", "prerm", 0 ],
package_prerm: [ "service/package.uc", "prerm", 1 ],
package_postinst: [ "service/package.uc", "postinst", 0 ],
luci_postinst: [ "service/package.uc", "luci-postinst", 0 ]
};
+3 -16
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env ucode
#!/usr/bin/env ucode
let fs = require("fs");
@@ -92,19 +92,6 @@ function path_basename(value) {
return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : "";
}
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
if (needle == "")
return length(value);
for (let i = length(value) - length(needle); i >= 0; i--)
if (substr(value, i, length(needle)) == needle)
return i;
return -1;
}
function arg_bool(value) {
return value === true || value == "true" || value == "1" || value == 1;
}
@@ -264,7 +251,7 @@ function openwrt_release_value(path, key) {
continue;
let rest = substr(line, length(prefix));
let quote = str_last_index(rest, "'");
let quote = rindex(rest, "'");
if (quote >= 0)
print(substr(rest, 0, quote), "\n");
return;
@@ -282,7 +269,7 @@ function openwrt_release_series(path) {
continue;
let value = substr(line, length(prefix));
let quote = str_last_index(value, "'");
let quote = rindex(value, "'");
if (quote >= 0)
value = substr(value, 0, quote);
+6 -6
View File
@@ -33,9 +33,10 @@ const SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") ||
const OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata";
const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache";
const RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format";
const RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "7";
const RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8";
const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache";
const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format";
const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7";
const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending";
const RELOAD_STATE_FILE = getenv("TACHYON_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state";
const RELOAD_STATE_FORMAT = getenv("TACHYON_RELOAD_STATE_FORMAT") || "1";
@@ -330,10 +331,6 @@ function module_output(args) {
return command_output(module_command(args));
}
function module_env_status(env, args) {
return command_status(command_env(env) + " " + module_command(args));
}
function nft_module_success(args) {
let command_args = [ LIB_DIR + "/nft/apply.uc" ];
for (let arg in args)
@@ -1020,7 +1017,8 @@ function ensure_subscription_runtime_dirs() {
TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE,
TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT
}) + " " + command_from_args([
"ucode",
"-L", LIB_DIR,
@@ -1166,6 +1164,7 @@ function subscription_worker_env() {
TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT,
TACHYON_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES: SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES,
TACHYON_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES: SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES,
TACHYON_UI_ACTION_STALE_GRACE_SECONDS: SUBSCRIPTION_JOB_STALE_GRACE_SECONDS
@@ -2510,6 +2509,7 @@ function subscription_cache_env() {
TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE,
TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT,
TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE,
TACHYON_SERVICE_INIT: SERVICE_INIT,
SB_SERVICE_MIXED_INBOUND_ADDRESS,
+91 -15
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env ucode
#!/usr/bin/env ucode
let common = require("core.common");
let uci_core = require("core.uci");
@@ -358,6 +358,78 @@ function priority_groups(section) {
return result;
}
function dashboard_filter_mode(section) {
return option(section, "dashboard_filter_mode", "disabled") || "disabled";
}
function dashboard_detect_server_country(section) {
return option(section, "dashboard_detect_server_country", "flag_emoji") || "flag_emoji";
}
function dashboard_include_countries(section) {
return list_value(section, "dashboard_include_countries");
}
function dashboard_include_outbounds(section) {
return list_value(section, "dashboard_include_outbounds");
}
function dashboard_include_regex(section) {
return list_value(section, "dashboard_include_regex");
}
function dashboard_include_proxy_parameters(section) {
return bool_option(section, "dashboard_include_proxy_parameters", false);
}
function dashboard_include_protocols(section) {
return list_value(section, "dashboard_include_protocols");
}
function dashboard_include_transports(section) {
return list_value(section, "dashboard_include_transports");
}
function dashboard_include_securities(section) {
return list_value(section, "dashboard_include_securities");
}
function dashboard_include_groups(section) {
return list_value(section, "dashboard_include_groups");
}
function dashboard_exclude_countries(section) {
return list_value(section, "dashboard_exclude_countries");
}
function dashboard_exclude_outbounds(section) {
return list_value(section, "dashboard_exclude_outbounds");
}
function dashboard_exclude_regex(section) {
return list_value(section, "dashboard_exclude_regex");
}
function dashboard_exclude_proxy_parameters(section) {
return bool_option(section, "dashboard_exclude_proxy_parameters", false);
}
function dashboard_exclude_protocols(section) {
return list_value(section, "dashboard_exclude_protocols");
}
function dashboard_exclude_transports(section) {
return list_value(section, "dashboard_exclude_transports");
}
function dashboard_exclude_securities(section) {
return list_value(section, "dashboard_exclude_securities");
}
function dashboard_exclude_groups(section) {
return list_value(section, "dashboard_exclude_groups");
}
function priority_group_child(section, value) {
return owned_child_section(section, "priority_group", value);
}
@@ -672,13 +744,6 @@ function urltest_detect_server_country(section, value) {
option(section, "detect_server_country", "flag_emoji") || "flag_emoji");
}
function urltest_hide_added_outbounds(section, value) {
let child = urltest_child(section, value);
if (child != null)
return child_bool(child, "hide_added_outbounds", false);
return item_bool(section, "urltest_settings", value, "hide_added_outbounds", false);
}
function urltest_pin_dashboard(section, value) {
let child = urltest_child(section, value);
if (child != null)
@@ -819,11 +884,6 @@ function priority_group_interrupt_exist_connections(section, value) {
return child != null ? child_bool(child, "interrupt_exist_connections", true) : true;
}
function priority_group_hide_added_outbounds(section, value) {
let child = priority_group_child(section, value);
return child != null ? child_bool(child, "hide_added_outbounds", false) : false;
}
function priority_group_pin_dashboard(section, value) {
let child = priority_group_child(section, value);
return child != null ? child_bool(child, "pin_dashboard", true) : true;
@@ -995,6 +1055,24 @@ return {
interfaces,
outbound_jsons,
urltests,
dashboard_filter_mode,
dashboard_detect_server_country,
dashboard_include_countries,
dashboard_include_outbounds,
dashboard_include_regex,
dashboard_include_proxy_parameters,
dashboard_include_protocols,
dashboard_include_transports,
dashboard_include_securities,
dashboard_include_groups,
dashboard_exclude_countries,
dashboard_exclude_outbounds,
dashboard_exclude_regex,
dashboard_exclude_proxy_parameters,
dashboard_exclude_protocols,
dashboard_exclude_transports,
dashboard_exclude_securities,
dashboard_exclude_groups,
community_lists,
rule_sets,
rule_sets_with_subnets,
@@ -1029,7 +1107,6 @@ return {
urltest_interrupt_exist_connections,
urltest_filter_mode,
urltest_detect_server_country,
urltest_hide_added_outbounds,
urltest_pin_dashboard,
urltest_include_countries,
urltest_include_outbounds,
@@ -1057,7 +1134,6 @@ return {
priority_group_switch_to_faster_same_priority,
priority_group_fastest_check_interval,
priority_group_interrupt_exist_connections,
priority_group_hide_added_outbounds,
priority_group_pin_dashboard,
priority_level_display_name,
priority_level_order,
+2 -15
View File
@@ -184,19 +184,6 @@ function strip_first_scheme_marker(value) {
return marker >= 0 ? substr(value, marker + 3) : value;
}
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
if (needle == "")
return length(value);
for (let i = length(value) - length(needle); i >= 0; i--)
if (substr(value, i, length(needle)) == needle)
return i;
return -1;
}
function url_get_scheme(value) {
value = as_string(value);
let marker = index(value, "://");
@@ -268,7 +255,7 @@ function url_get_query_param(value, param) {
function url_file_extension(value) {
let basename = as_string(value);
let slash = str_last_index(basename, "/");
let slash = rindex(basename, "/");
if (slash >= 0)
basename = substr(basename, slash + 1);
@@ -280,7 +267,7 @@ function url_file_extension(value) {
if (fragment >= 0)
basename = substr(basename, 0, fragment);
let dot = str_last_index(basename, ".");
let dot = rindex(basename, ".");
if (dot >= 0)
print(substr(basename, dot + 1), "\n");
else
-16
View File
@@ -5,22 +5,6 @@ let as_string = common.as_string;
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
let result = -1;
let start = 0;
while (true) {
let offset = index(substr(value, start), needle);
if (offset < 0)
return result;
result = start + offset;
start = result + length(needle);
}
}
function decimal_text(value, strict) {
value = as_string(value);
if (value == "" || match(value, /^[0-9]+$/) == null)
+27 -2
View File
@@ -86,6 +86,31 @@ function apk_list_version(package_name, output) {
return "";
}
function apk_query_version(package_name, output) {
package_name = as_string(package_name);
let packages;
try {
packages = json(as_string(output));
}
catch (e) {
return "";
}
if (type(packages) != "array")
return "";
for (let package in packages) {
if (type(package) != "object" || as_string(package.name) != package_name)
continue;
let version = as_string(package.version);
if (version != "")
return version;
}
return "";
}
function apk_version(package_name) {
package_name = as_string(package_name);
if (!apk_installed(package_name))
@@ -110,9 +135,9 @@ function apk_available_version(package_name) {
package_name = as_string(package_name);
if (!command_exists("apk"))
return "";
return apk_manifest_version(
return apk_query_version(
package_name,
command_output([ "apk", "list", "--available", "--manifest", package_name ])
command_output([ "apk", "query", "--from", "repositories", "--available", "--format", "json", "--fields", "name,version", package_name ])
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env ucode
#!/usr/bin/env ucode
let fs = require("fs");
let common = require("core.common");
+4 -17
View File
@@ -4,19 +4,6 @@ let common = require("core.common");
let as_string = common.as_string;
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
if (needle == "")
return length(value);
for (let i = length(value) - length(needle); i >= 0; i--)
if (substr(value, i, length(needle)) == needle)
return i;
return -1;
}
function first_index_any(value, needles, start) {
value = as_string(value);
start = int(start || 0);
@@ -104,7 +91,7 @@ function strip_first_scheme_marker(value) {
function authority(value) {
value = strip_first_scheme_marker(value);
let at = str_last_index(value, "@");
let at = rindex(value, "@");
if (at >= 0)
value = substr(value, at + 1);
@@ -123,7 +110,7 @@ function host(value) {
if (colon < 0)
return value_authority;
return str_last_index(value_authority, ":") == colon ? substr(value_authority, 0, colon) : value_authority;
return rindex(value_authority, ":") == colon ? substr(value_authority, 0, colon) : value_authority;
}
function port(value) {
@@ -136,7 +123,7 @@ function port(value) {
}
let colon = index(value_authority, ":");
if (colon < 0 || str_last_index(value_authority, ":") != colon)
if (colon < 0 || rindex(value_authority, ":") != colon)
return "";
return substr(value_authority, colon + 1);
@@ -146,7 +133,7 @@ function userinfo(value) {
let value_authority = strip_first_scheme_marker(strip_fragment(value));
let end = first_index_any(value_authority, ["/", "?", "#"], 0);
value_authority = end >= 0 ? substr(value_authority, 0, end) : value_authority;
let at = str_last_index(value_authority, "@");
let at = rindex(value_authority, "@");
return at >= 0 ? decode(substr(value_authority, 0, at)) : "";
}
+4 -16
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env ucode
#!/usr/bin/env ucode
let fs = require("fs");
let core_ip = require("core.ip");
@@ -323,6 +323,7 @@ function mask_option_path(line, token) {
function tachyon_config_masked_line(line) {
line = mask_after_token(line, "option proxy_string");
line = mask_after_token(line, "option hwid");
line = mask_after_token(line, "option subscription_url");
line = mask_after_token(line, "list subscription_urls");
line = mask_after_token(line, "list urltest_proxy_links");
@@ -602,19 +603,6 @@ function str_remove_suffix(value, suffix) {
return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value;
}
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
if (needle == "")
return length(value);
for (let i = length(value) - length(needle); i >= 0; i--)
if (substr(value, i, length(needle)) == needle)
return i;
return -1;
}
function contains(values, needle) {
needle = as_string(needle);
for (let value in array_or_empty(values))
@@ -630,7 +618,7 @@ function netstat_fields(line) {
function netstat_addr_port(addr) {
addr = as_string(addr);
let colon = str_last_index(addr, ":");
let colon = rindex(addr, ":");
return colon >= 0 ? substr(addr, colon + 1) : addr;
}
@@ -640,7 +628,7 @@ function netstat_addr_host(addr) {
let end = index(addr, "]");
return end > 0 ? substr(addr, 1, end - 1) : addr;
}
let colon = str_last_index(addr, ":");
let colon = rindex(addr, ":");
return colon >= 0 ? substr(addr, 0, colon) : addr;
}
+1 -14
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env ucode
#!/usr/bin/env ucode
let fs = require("fs");
let uci = require("core.uci");
@@ -15,19 +15,6 @@ function run(command) {
return system(command) == 0;
}
function output(command) {
let pipe = fs.popen(command, "r");
if (!pipe)
return "";
let data = pipe.read("all");
let status = pipe.close();
if (status != 0 || data == null)
return "";
return replace(as_string(data), /[\r\n]+$/g, "");
}
function uci_available() {
return uci.available();
}
@@ -22,32 +22,12 @@ function trim_left_space(value) {
return substr(value, i);
}
function last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
let result = -1;
let offset = 0;
if (needle == "")
return -1;
while (offset <= length(value)) {
let found = index(substr(value, offset), needle);
if (found < 0)
break;
result = offset + found;
offset = result + length(needle);
}
return result;
}
function strip_binary_prefix(line, binary) {
line = as_string(line);
binary = as_string(binary || "nfqws");
let path_marker = "/" + binary + ":";
let path_index = last_index(line, path_marker);
let path_index = rindex(line, path_marker);
if (path_index >= 0)
return trim_left_space(substr(line, path_index + length(path_marker)));
@@ -109,7 +89,7 @@ function validation_summary(binary) {
}
function value_after_last_colon(summary) {
let colon = last_index(summary, ":");
let colon = rindex(summary, ":");
if (colon < 0)
return "";
@@ -1,4 +1,4 @@
#!/usr/bin/env ucode
#!/usr/bin/env ucode
let common = require("core.common");
let as_string = common.as_string;

Some files were not shown because too many files have changed in this diff Show More