Rebrand upstream: переименовать forkop в tachyon

This commit is contained in:
Dushnilin
2026-07-14 07:48:33 +03:00
parent 2a1c97b592
commit 14df12a18d
353 changed files with 116869 additions and 1522 deletions
+16
View File
@@ -0,0 +1,16 @@
SFTP_HOST=192.168.160.129
SFTP_PORT=22
SFTP_USER=root
SFTP_PASS=
# you can use key if needed
# SFTP_PRIVATE_KEY=~/.ssh/id_rsa
LOCAL_DIR_FE=../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon
REMOTE_DIR_FE=/www/luci-static/resources/view/tachyon
LOCAL_DIR_BIN=../tachyon/files/usr/bin/
REMOTE_DIR_BIN=/usr/bin/
LOCAL_DIR_LIB=../tachyon/files/usr/lib/
REMOTE_DIR_LIB=/usr/lib/tachyon/
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true
}
+38
View File
@@ -0,0 +1,38 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourceDir = path.resolve(__dirname, 'locales');
const targetRoot = path.resolve(__dirname, '../luci-app-tachyon/po');
async function main() {
const files = await fs.readdir(sourceDir);
for (const file of files) {
const filePath = path.join(sourceDir, file);
if (file === 'tachyon.pot') {
const potTarget = path.join(targetRoot, 'templates', 'tachyon.pot');
await fs.mkdir(path.dirname(potTarget), { recursive: true });
await fs.copyFile(filePath, potTarget);
console.log(`✅ Copied POT: ${filePath} → ${potTarget}`);
}
const match = file.match(/^tachyon\.([a-zA-Z_]+)\.po$/);
if (match) {
const lang = match[1];
const poTarget = path.join(targetRoot, lang, 'tachyon.po');
await fs.mkdir(path.dirname(poTarget), { recursive: true });
await fs.copyFile(filePath, poTarget);
console.log(`✅ Copied ${lang.toUpperCase()}: ${filePath} → ${poTarget}`);
}
}
}
main().catch((err) => {
console.error('❌ Ошибка при распространении переводов:', err);
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['node_modules', 'watch-upload.js'],
},
{
files: ['src/**/*.test.js'],
languageOptions: {
globals: {
Buffer: 'readonly',
URL: 'readonly',
},
},
},
{
rules: {
'no-console': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
prettier,
];
+75
View File
@@ -0,0 +1,75 @@
import fs from 'fs/promises';
import path from 'path';
import glob from 'fast-glob';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function stripIllegalReturn(code) {
return code.replace(/^\s*return\s+[^;]+;\s*$/gm, (match, offset, input) => {
const after = input.slice(offset + match.length).trim();
return after === '' ? '' : match;
});
}
const files = await glob([
'src/**/*.ts',
'../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/**/*.js',
], {
ignore: [
'**/*.test.ts',
'**/main.js',
'../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js',
],
absolute: true,
});
const results = {};
for (const file of files) {
const contentRaw = await fs.readFile(file, 'utf8');
const content = stripIllegalReturn(contentRaw);
const relativePath = path.relative(process.cwd(), file).replaceAll('\\', '/');
let ast;
try {
ast = parse(content, {
sourceType: 'module',
plugins: file.endsWith('.ts') ? ['typescript'] : [],
});
} catch (e) {
console.warn(`⚠️ Parse error in ${relativePath}, skipping`);
continue;
}
traverse.default(ast, {
CallExpression(path) {
if (t.isIdentifier(path.node.callee, { name: '_' })) {
const arg = path.node.arguments[0];
if (t.isStringLiteral(arg)) {
const key = arg.value.trim();
if (!key) return; // ❌ пропустить пустые ключи
const location = `${relativePath}:${path.node.loc?.start.line ?? '?'}`;
if (!results[key]) {
results[key] = { call: key, key, places: [] };
}
results[key].places.push(location);
}
}
},
});
}
const outFile = 'locales/calls.json';
const sorted = Object.values(results).sort((a, b) => a.key.localeCompare(b.key)); // 🔤 сортировка по ключу
await fs.mkdir(path.dirname(outFile), { recursive: true });
await fs.writeFile(outFile, JSON.stringify(sorted, null, 2), 'utf8');
console.log(`✅ Extracted ${sorted.length} translations to ${outFile}`);
+117
View File
@@ -0,0 +1,117 @@
import fs from 'fs/promises';
import { execSync } from 'child_process';
const lang = process.argv[2];
if (!lang) {
console.error('❌ Укажи язык, например: node generate-po.js ru');
process.exit(1);
}
const callsPath = 'locales/calls.json';
const poPath = `locales/tachyon.${lang}.po`;
function getGitUser() {
try {
return execSync('git config user.name').toString().trim();
} catch {
return 'Automatically generated';
}
}
function getHeader(lang) {
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0].slice(0, 5);
const tzOffset = (() => {
const offset = -now.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
return `${sign}${hours}${minutes}`;
})();
const translator = getGitUser();
const pluralForms =
lang === 'ru'
? 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);'
: 'nplurals=2; plural=(n != 1);';
return [
`# ${lang.toUpperCase()} translations for TACHYON package.`,
`# Copyright (C) ${now.getFullYear()} THE TACHYON COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the TACHYON package.`,
`# ${translator}, ${now.getFullYear()}.`,
'#',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: TACHYON\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date} ${time}${tzOffset}\\n"`,
`"PO-Revision-Date: ${date} ${time}${tzOffset}\\n"`,
`"Last-Translator: ${translator}\\n"`,
`"Language-Team: none\\n"`,
`"Language: ${lang}\\n"`,
`"MIME-Version: 1.0\\n"`,
`"Content-Type: text/plain; charset=UTF-8\\n"`,
`"Content-Transfer-Encoding: 8bit\\n"`,
`"Plural-Forms: ${pluralForms}\\n"`,
'',
];
}
function parsePo(content) {
const lines = content.split('\n');
const translations = new Map();
let msgid = null;
let msgstr = null;
for (const line of lines) {
if (line.startsWith('msgid ')) {
msgid = JSON.parse(line.slice(6));
} else if (line.startsWith('msgstr ') && msgid !== null) {
msgstr = JSON.parse(line.slice(7));
translations.set(msgid, msgstr);
msgid = null;
msgstr = null;
}
}
return translations;
}
function escapePoString(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
async function generatePo() {
const [callsRaw, oldPoRaw] = await Promise.all([
fs.readFile(callsPath, 'utf8'),
fs.readFile(poPath, 'utf8').catch(() => ''),
]);
const calls = JSON.parse(callsRaw);
const oldTranslations = parsePo(oldPoRaw);
const header = getHeader(lang);
const body = calls
.map(({ key }) => {
const msgid = key;
const msgstr = oldTranslations.get(msgid) || '';
return [
`msgid "${escapePoString(msgid)}"`,
`msgstr "${escapePoString(msgstr)}"`,
'',
].join('\n');
})
.join('\n');
const finalPo = header.join('\n') + '\n' + body;
await fs.writeFile(poPath, finalPo, 'utf8');
console.log(
`✅ Файл ${poPath} успешно сгенерирован. Переведено ${[...oldTranslations.keys()].length}/${calls.length}`,
);
}
generatePo().catch((err) => {
process.exitCode = 1;
console.error('Ошибка генерации PO файла:', err);
});
+91
View File
@@ -0,0 +1,91 @@
import fs from 'fs/promises';
import { execSync } from 'child_process';
const inputFile = 'locales/calls.json';
const outputFile = 'locales/tachyon.pot';
const projectId = 'TACHYON';
function getGitUser() {
try {
const name = execSync('git config user.name', {
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
const email = execSync('git config user.email', {
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
if (name && email) {
return { name, email };
}
} catch (error) {
// Fall through to deterministic defaults when git identity is not configured.
}
return {
name: 'Tachyon',
email: 'ushan0v@users.noreply.github.com',
};
}
function getPotHeader({ name, email }) {
const now = new Date();
const date = now.toISOString().replace('T', ' ').slice(0, 16);
const offset = -now.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
const timezone = `${sign}${hours}${minutes}`;
return [
'# SOME DESCRIPTIVE TITLE.',
`# Copyright (C) ${now.getFullYear()} THE PACKAGE'S COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the ${projectId} package.`,
`# ${name} <${email}>, ${now.getFullYear()}.`,
'#, fuzzy',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: ${projectId}\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date}${timezone}\\n"`,
`"PO-Revision-Date: ${date}${timezone}\\n"`,
`"Last-Translator: ${name} <${email}>\\n"`,
`"Language-Team: LANGUAGE <LL@li.org>\\n"`,
`"Language: \\n"`,
`"MIME-Version: 1.0\\n"`,
`"Content-Type: text/plain; charset=UTF-8\\n"`,
`"Content-Transfer-Encoding: 8bit\\n"`,
'',
].join('\n');
}
function escapePoString(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function generateEntry(item) {
const locations = item.places.map((loc) => `#: ${loc}`).join('\n');
const msgid = escapePoString(item.key);
return [locations, `msgid "${msgid}"`, `msgstr ""`, ''].join('\n');
}
async function generatePot() {
const gitUser = getGitUser();
const raw = await fs.readFile(inputFile, 'utf8');
const entries = JSON.parse(raw);
const header = getPotHeader(gitUser);
const body = entries.map(generateEntry).join('\n');
await fs.writeFile(outputFile, `${header}\n${body}`, 'utf8');
console.log(`✅ POT-файл успешно создан: ${outputFile}`);
}
generatePot().catch((error) => {
console.error(error);
process.exitCode = 1;
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "fe-app-tachyon",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"format": "prettier --write src",
"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": "tsc --noEmit && tsup src/main.ts",
"dev": "tsup src/main.ts --watch",
"test": "vitest",
"ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build",
"watch:sftp": "node watch-upload.js",
"locales:extract-calls": "node extract-calls.js",
"locales:generate-pot": "node generate-pot.js",
"locales:generate-po:ru": "node generate-po.js ru",
"locales:distribute": "node distribute-locales.js",
"locales:actualize": "yarn locales:extract-calls && yarn locales:generate-pot && yarn locales:generate-po:ru && yarn locales:distribute"
},
"devDependencies": {
"@babel/parser": "7.28.4",
"@babel/traverse": "7.28.4",
"@babel/types": "7.28.4",
"@eslint/js": "9.36.0",
"chokidar": "4.0.3",
"dotenv": "17.2.3",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"fast-glob": "3.3.3",
"glob": "11.0.3",
"prettier": "3.6.2",
"ssh2-sftp-client": "12.0.1",
"tsup": "8.5.0",
"typescript": "5.9.3",
"typescript-eslint": "8.45.0",
"vitest": "3.2.4"
}
}
+88
View File
@@ -0,0 +1,88 @@
export const TACHYON_UCI_PACKAGE = 'tachyon';
export const TACHYON_LUCI_APP_VERSION = '__COMPILED_VERSION_VARIABLE__';
export const TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT =
'tachyon:action-providers-availability';
export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi';
export const IP_CHECK_DOMAIN = 'ip.podkop.fyi';
export const DEFAULT_LATENCY_TEST_URL = 'https://www.gstatic.com/generate_204';
export const LATENCY_TEST_URL_OPTIONS = [
DEFAULT_LATENCY_TEST_URL,
'https://cp.cloudflare.com/generate_204',
'https://captive.apple.com',
'https://connectivity-check.ubuntu.com',
];
export const REGIONAL_OPTIONS = [
'russia_inside',
'russia_outside',
'ukraine_inside',
];
export const ALLOWED_WITH_RUSSIA_INSIDE = [
'russia_inside',
'meta',
'twitter',
'discord',
'telegram',
'cloudflare',
'google_ai',
'google_play',
'hetzner',
'ovh',
'hodca',
'roblox',
'digitalocean',
'cloudfront',
];
export const DOMAIN_LIST_OPTIONS = {
russia_inside: 'Russia inside',
russia_outside: 'Russia outside',
ukraine_inside: 'Ukraine',
geoblock: 'Geo Block',
block: 'Block',
porn: 'Porn',
news: 'News',
anime: 'Anime',
youtube: 'Youtube',
discord: 'Discord',
meta: 'Meta',
twitter: 'Twitter (X)',
hdrezka: 'HDRezka',
tiktok: 'Tik-Tok',
telegram: 'Telegram',
cloudflare: 'Cloudflare',
google_ai: 'Google AI',
google_play: 'Google Play',
hodca: 'H.O.D.C.A',
roblox: 'Roblox',
ads_hagezi_pro: 'Ads (Hagezi Pro)',
supercell: 'Supercell',
github: 'GitHub',
hetzner: 'Hetzner ASN',
ovh: 'OVH ASN',
digitalocean: 'Digital Ocean ASN',
cloudfront: 'CloudFront ASN',
};
export const DNS_SERVER_OPTIONS = {
'1.1.1.1': '1.1.1.1 (Cloudflare)',
'8.8.8.8': '8.8.8.8 (Google)',
'9.9.9.9': '9.9.9.9 (Quad9)',
'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)',
'unfiltered.adguard-dns.com':
'unfiltered.adguard-dns.com (AdGuard Unfiltered)',
'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)',
};
export const BOOTSTRAP_DNS_SERVER_OPTIONS = {
'77.88.8.8': '77.88.8.8 (Yandex DNS)',
'77.88.8.1': '77.88.8.1 (Yandex DNS)',
'1.1.1.1': '1.1.1.1 (Cloudflare DNS)',
'1.0.0.1': '1.0.0.1 (Cloudflare DNS)',
'8.8.8.8': '8.8.8.8 (Google DNS)',
'8.8.4.4': '8.8.4.4 (Google DNS)',
'9.9.9.9': '9.9.9.9 (Quad9 DNS)',
'9.9.9.11': '9.9.9.11 (Quad9 DNS)',
};
export const COMMAND_TIMEOUT = 10000; // 10 seconds
@@ -0,0 +1,16 @@
import { showToast } from './showToast';
export function copyToClipboard(text: string) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showToast(_('Copied'), 'success');
} catch (_err) {
showToast(_('Failed to copy!'), 'error');
console.error('copyToClipboard - e', _err);
}
document.body.removeChild(textarea);
}
@@ -0,0 +1,15 @@
export function downloadAsTxt(text: string, filename: string) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
const safeName = filename.endsWith('.txt') ? filename : `${filename}.txt`;
link.download = safeName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
@@ -0,0 +1,33 @@
import { COMMAND_TIMEOUT } from '../constants';
import { withTimeout } from './withTimeout';
interface ExecuteShellCommandParams {
command: string;
args: string[];
timeout?: number;
}
interface ExecuteShellCommandResponse {
stdout: string;
stderr: string;
code?: number;
}
export async function executeShellCommand({
command,
args,
timeout = COMMAND_TIMEOUT,
}: ExecuteShellCommandParams): Promise<ExecuteShellCommandResponse> {
try {
return await withTimeout(
fs.exec(command, args),
timeout,
[command, ...args].join(' '),
);
} catch (err) {
const error = err as Error & { code?: unknown };
const code = typeof error?.code === 'number' ? error.code : 1;
return { stdout: '', stderr: error?.message, code };
}
}
@@ -0,0 +1,31 @@
function getWindowLocation(): Location | undefined {
return typeof window !== 'undefined' ? window.location : undefined;
}
export function canUseDirectClashApi(): boolean {
const location = getWindowLocation();
return (
typeof location?.hostname === 'string' &&
location.hostname !== '' &&
location.protocol !== 'https:'
);
}
export function getClashWsUrl(): string {
const { hostname } = window.location;
return `ws://${hostname}:9090`;
}
export function getClashHttpUrl(): string {
const { hostname } = window.location;
return `http://${hostname}:9090`;
}
export function getClashUIUrl(): string {
const { hostname } = window.location;
return `http://${hostname}:9090/ui`;
}
@@ -0,0 +1,13 @@
export function getProxyUrlName(url: string) {
try {
const [_link, hash] = url.split('#');
if (!hash) {
return '';
}
return decodeURIComponent(hash);
} catch {
return '';
}
}
+12
View File
@@ -0,0 +1,12 @@
export * from './parseValueList';
export * from './injectGlobalStyles';
export * from './withTimeout';
export * from './executeShellCommand';
export * from './getProxyUrlName';
export * from './onMount';
export * from './getClashApiUrl';
export * from './preserveScrollForPage';
export * from './parseQueryString';
export * from './svgEl';
export * from './insertIf';
export * from './isCopyableProxyLink';
@@ -0,0 +1,18 @@
import { GlobalStyles } from '../styles';
const TACHYON_GLOBAL_STYLES_ID = 'tachyon-global-styles';
export function injectGlobalStyles() {
if (document.getElementById(TACHYON_GLOBAL_STYLES_ID)) {
return;
}
document.head.insertAdjacentHTML(
'beforeend',
`
<style id="${TACHYON_GLOBAL_STYLES_ID}">
${GlobalStyles}
</style>
`,
);
}
+3
View File
@@ -0,0 +1,3 @@
export function insertIf<T>(condition: boolean, elements: Array<T>) {
return condition ? elements : ([] as Array<T>);
}
@@ -0,0 +1,19 @@
const COPYABLE_PROXY_URI_RE =
/^(vless|vmess|trojan|ss|ssr|hysteria2|hy2|tuic|socks4|socks4a|socks5):\/\//i;
const HTTP_PROXY_URI_RE =
/^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i;
export function isCopyableProxyLink(link?: string) {
const value = (link || '').trim();
if (COPYABLE_PROXY_URI_RE.test(value)) {
return true;
}
const httpProxy = value.match(HTTP_PROXY_URI_RE);
if (!httpProxy) {
return false;
}
const port = Number(httpProxy[1]);
return Number.isInteger(port) && port >= 1 && port <= 65535;
}
@@ -0,0 +1,7 @@
export function normalizeCompiledVersion(version: string) {
if (version.includes('COMPILED')) {
return 'dev';
}
return version;
}
+46
View File
@@ -0,0 +1,46 @@
function getTarget(target: string | HTMLElement): HTMLElement | null {
if (typeof target === 'string') {
return document.getElementById(target);
}
return target;
}
export async function onMount(
target: string | HTMLElement,
): Promise<HTMLElement> {
return new Promise((resolve) => {
let observer: MutationObserver | null = null;
const resolveIfMountedAndVisible = () => {
const mountedTarget = getTarget(target);
if (
mountedTarget &&
mountedTarget.isConnected &&
mountedTarget.offsetParent !== null
) {
observer?.disconnect();
resolve(mountedTarget);
return true;
}
return false;
};
if (resolveIfMountedAndVisible()) {
return;
}
observer = new MutationObserver(() => {
resolveIfMountedAndVisible();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style', 'hidden'],
});
});
}
@@ -0,0 +1,22 @@
export function parseQueryString(query: string): Record<string, string> {
const clean = query.startsWith('?') ? query.slice(1) : query;
return clean
.split('&')
.filter(Boolean)
.reduce(
(acc, pair) => {
const [rawKey, rawValue = ''] = pair.split('=');
if (!rawKey) {
return acc;
}
const key = decodeURIComponent(rawKey);
const value = decodeURIComponent(rawValue);
return { ...acc, [key]: value };
},
{} as Record<string, string>,
);
}
@@ -0,0 +1,9 @@
export function parseValueList(value: string): string[] {
return value
.split(/\n/) // Split to array by newline separator
.map((line) => line.split('//')[0].split('#')[0]) // Remove comments
.join(' ') // Build clean string
.split(/[,\s]+/) // Split to array by comma and space
.map((s) => s.trim()) // Remove extra spaces
.filter(Boolean); // Leave nonempty items
}
@@ -0,0 +1,9 @@
export function preserveScrollForPage(renderFn: () => void) {
const scrollY = window.scrollY;
renderFn();
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY });
});
}
+12
View File
@@ -0,0 +1,12 @@
// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js
export function prettyBytes(n: number) {
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (n < 1000) {
return n + ' B';
}
const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1);
n = Number((n / Math.pow(1000, exponent)).toPrecision(3));
const unit = UNITS[exponent];
return n + ' ' + unit;
}
+24
View File
@@ -0,0 +1,24 @@
export function showToast(
message: string,
type: 'success' | 'error',
duration: number = 3000,
) {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.classList.add('visible'), 100);
setTimeout(() => {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 300);
}, duration);
}
+18
View File
@@ -0,0 +1,18 @@
export function svgEl<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Partial<Record<string, string | number>> = {},
children: (SVGElement | null | undefined)[] = [],
): SVGElementTagNameMap[K] {
const NS = 'http://www.w3.org/2000/svg';
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v != null) el.setAttribute(k, String(v));
}
(Array.isArray(children) ? children : [children])
.filter(Boolean)
.forEach((ch) => el.appendChild(ch as SVGElement));
return el;
}
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { canUseDirectClashApi } from '../getClashApiUrl';
describe('canUseDirectClashApi', () => {
it('allows direct Clash API access from HTTP LuCI', () => {
vi.stubGlobal('window', {
location: { hostname: 'router.example', protocol: 'http:' },
});
expect(canUseDirectClashApi()).toBe(true);
});
it('blocks direct Clash API access from HTTPS LuCI', () => {
vi.stubGlobal('window', {
location: { hostname: 'router.example', protocol: 'https:' },
});
expect(canUseDirectClashApi()).toBe(false);
});
it('blocks direct Clash API access outside a browser location', () => {
vi.stubGlobal('window', undefined);
expect(canUseDirectClashApi()).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { isCopyableProxyLink } from '../isCopyableProxyLink';
const copyableLinks = [
'vless://uuid@example.com:443',
'vmess://encoded',
'trojan://password@example.com:443',
'ss://encoded@example.com:443',
'ssr://encoded',
'hysteria2://password@example.com:443',
'hy2://password@example.com:443',
'tuic://uuid:password@example.com:443',
'socks4://example.com:1080',
'socks4a://example.com:1080',
'socks5://user:pass@example.com:1080',
'http://example.com:80',
'https://user:pass@example.com:443',
];
const nonCopyableLinks = [
'',
'direct',
'block',
'urltest',
'https://example.com/subscription',
'https://example.com',
'http://example.com:99999',
'wireguard://example.com',
];
describe('isCopyableProxyLink', () => {
describe.each(copyableLinks)('copyable proxy URI %s', (link) => {
it('returns true', () => {
expect(isCopyableProxyLink(link)).toBe(true);
});
});
describe.each(nonCopyableLinks)('non-copyable value %s', (link) => {
it('returns false', () => {
expect(isCopyableProxyLink(link)).toBe(false);
});
});
});
@@ -0,0 +1,119 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@babel/parser';
import { describe, expect, it } from 'vitest';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const serverViewPath = path.resolve(
testDir,
'../../../../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js',
);
function loadBuildHysteria2Link(values) {
const source = fs.readFileSync(serverViewPath, 'utf8');
const requiredFunctions = [
'encodeQuery',
'normalizeHost',
'isIpv4',
'isIpv6',
'formatHostForUri',
'getPublicHost',
'normalizeSha256',
'buildHysteria2Link',
];
const ast = parse(source, {
allowReturnOutsideFunction: true,
sourceType: 'script',
});
const functionSources = new Map(
ast.program.body
.filter((node) => node.type === 'FunctionDeclaration')
.map((node) => [node.id.name, source.slice(node.start, node.end)]),
);
const functions = requiredFunctions
.map((name) => {
const functionSource = functionSources.get(name);
if (!functionSource) {
throw new Error(`Function ${name} not found`);
}
return functionSource;
})
.join('\n');
const factory = new Function(
'uci',
'UCI_PACKAGE',
'window',
`${functions}; return buildHysteria2Link;`,
);
const uci = {
get(_packageName, sectionId, optionName) {
return values[`${sectionId}.${optionName}`] || '';
},
};
return factory(uci, 'tachyon', { location: { hostname: 'router.lan' } });
}
describe('buildHysteria2Link', () => {
const certificatePin =
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
it('exports a single certificate pin alias and omits stale obfs password when obfs is disabled', () => {
const buildHysteria2Link = loadBuildHysteria2Link({
'server1.public_host': 'vpn.example.com',
'server1.listen_port': '8443',
'server1.tls_server_name': 'sni.example.com',
'server1.hysteria2_obfs_type': '',
'server1.hysteria2_obfs_password': 'stale-secret',
});
const link = buildHysteria2Link(
'server1',
{ password: 'p@ss word', name: 'Server 1' },
{ tlsCertificateSha256: certificatePin },
);
const params = new URL(link).searchParams;
expect(params.get('pcs')).toBe(certificatePin);
expect(params.has('pinSHA256')).toBe(false);
expect(params.has('obfs')).toBe(false);
expect(params.has('obfs-password')).toBe(false);
});
it('exports salamander obfuscation together with its password', () => {
const buildHysteria2Link = loadBuildHysteria2Link({
'server1.public_host': 'vpn.example.com',
'server1.listen_port': '8443',
'server1.hysteria2_obfs_type': 'salamander',
'server1.hysteria2_obfs_password': 'secret',
});
const link = buildHysteria2Link(
'server1',
{ password: 'password', name: 'Server 1' },
{ tlsCertificateSha256: certificatePin },
);
const params = new URL(link).searchParams;
expect(params.get('pcs')).toBe(certificatePin);
expect(params.has('pinSHA256')).toBe(false);
expect(params.get('obfs')).toBe('salamander');
expect(params.get('obfs-password')).toBe('secret');
});
it('brackets IPv6 public host in exported URI', () => {
const buildHysteria2Link = loadBuildHysteria2Link({
'server1.public_host': '2001:db8::1',
'server1.listen_port': '8443',
});
const link = buildHysteria2Link(
'server1',
{ password: 'password', name: 'Server 1' },
{ tlsCertificateSha256: certificatePin },
);
expect(link).toContain('@[2001:db8::1]:8443');
});
});
+23
View File
@@ -0,0 +1,23 @@
import { logger } from '../tachyon/services/logger.service';
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operationName: string,
timeoutMessage = _('Operation timed out'),
): Promise<T> {
let timeoutId;
const start = performance.now();
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeoutId);
const elapsed = performance.now() - start;
logger.info('[SHELL]', `[${operationName}] took ${elapsed.toFixed(2)} ms`);
}
}
+22
View File
@@ -0,0 +1,22 @@
export * from './renderLoaderCircleIcon24';
export * from './renderCircleAlertIcon24';
export * from './renderCircleCheckIcon24';
export * from './renderCircleSlashIcon24';
export * from './renderCircleXIcon24';
export * from './renderCheckIcon24';
export * from './renderXIcon24';
export * from './renderTriangleAlertIcon24';
export * from './renderPauseIcon24';
export * from './renderPlayIcon24';
export * from './renderRotateCcwIcon24';
export * from './renderCircleStopIcon24';
export * from './renderCirclePlayIcon24';
export * from './renderCircleCheckBigIcon24';
export * from './renderSquareChartGanttIcon24';
export * from './renderCogIcon24';
export * from './renderSearchIcon24';
export * from './renderBookOpenTextIcon24';
export * from './renderCopyIcon24';
export * from './renderLinkIcon24';
export * from './renderDownloadIcon24';
export * from './renderInfoIcon24';
@@ -0,0 +1,28 @@
import { svgEl } from '../helpers';
export function renderBookOpenTextIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-book-open-text-icon lucide-book-open-text',
},
[
svgEl('path', { d: 'M12 7v14' }),
svgEl('path', { d: 'M16 12h2' }),
svgEl('path', { d: 'M16 8h2' }),
svgEl('path', {
d: 'M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
}),
svgEl('path', { d: 'M6 12h2' }),
svgEl('path', { d: 'M6 8h2' }),
],
);
}
@@ -0,0 +1,23 @@
import { svgEl } from '../helpers';
export function renderCheckIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-check-icon lucide-check',
},
[
svgEl('path', {
d: 'M20 6 9 17l-5-5',
}),
],
);
}
@@ -0,0 +1,39 @@
import { svgEl } from '../helpers';
export function renderCircleAlertIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-alert-icon lucide-circle-alert',
},
[
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
svgEl('line', {
x1: '12',
y1: '8',
x2: '12',
y2: '12',
}),
svgEl('line', {
x1: '12',
y1: '16',
x2: '12.01',
y2: '16',
}),
],
);
}
@@ -0,0 +1,26 @@
import { svgEl } from '../helpers';
export function renderCircleCheckBigIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-check-big-icon lucide-circle-check-big',
},
[
svgEl('path', {
d: 'M21.801 10A10 10 0 1 1 17 3.335',
}),
svgEl('path', {
d: 'm9 11 3 3L22 4',
}),
],
);
}
@@ -0,0 +1,30 @@
import { svgEl } from '../helpers';
export function renderCircleCheckIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-check-icon lucide-circle-check',
},
[
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
svgEl('path', {
d: 'M9 12l2 2 4-4',
}),
],
);
}
@@ -0,0 +1,28 @@
import { svgEl } from '../helpers';
export function renderCirclePlayIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-play-icon lucide-circle-play',
},
[
svgEl('path', {
d: 'M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z',
}),
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
],
);
}
@@ -0,0 +1,33 @@
import { svgEl } from '../helpers';
export function renderCircleSlashIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-slash-icon lucide-circle-slash',
},
[
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
svgEl('line', {
x1: '9',
y1: '15',
x2: '15',
y2: '9',
}),
],
);
}
@@ -0,0 +1,32 @@
import { svgEl } from '../helpers';
export function renderCircleStopIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-stop-icon lucide-circle-stop',
},
[
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
svgEl('rect', {
x: '9',
y: '9',
width: '6',
height: '6',
rx: '1',
}),
],
);
}
@@ -0,0 +1,33 @@
import { svgEl } from '../helpers';
export function renderCircleXIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-circle-x-icon lucide-circle-x',
},
[
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
svgEl('path', {
d: 'M15 9L9 15',
}),
svgEl('path', {
d: 'M9 9L15 15',
}),
],
);
}
@@ -0,0 +1,34 @@
import { svgEl } from '../helpers';
export function renderCogIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-cog-icon lucide-cog',
},
[
svgEl('path', { d: 'M11 10.27 7 3.34' }),
svgEl('path', { d: 'm11 13.73-4 6.93' }),
svgEl('path', { d: 'M12 22v-2' }),
svgEl('path', { d: 'M12 2v2' }),
svgEl('path', { d: 'M14 12h8' }),
svgEl('path', { d: 'm17 20.66-1-1.73' }),
svgEl('path', { d: 'm17 3.34-1 1.73' }),
svgEl('path', { d: 'M2 12h2' }),
svgEl('path', { d: 'm20.66 17-1.73-1' }),
svgEl('path', { d: 'm20.66 7-1.73 1' }),
svgEl('path', { d: 'm3.34 17 1.73-1' }),
svgEl('path', { d: 'm3.34 7 1.73 1' }),
svgEl('circle', { cx: '12', cy: '12', r: '2' }),
svgEl('circle', { cx: '12', cy: '12', r: '8' }),
],
);
}
@@ -0,0 +1,32 @@
import { svgEl } from '../helpers/svgEl';
export function renderCopyIcon24() {
return svgEl(
'svg',
{
xmlns: 'http://www.w3.org/2000/svg',
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-copy-icon lucide-copy',
},
[
svgEl('rect', {
width: '14',
height: '14',
x: '8',
y: '8',
rx: '2',
ry: '2',
}),
svgEl('path', {
d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2',
}),
],
);
}
@@ -0,0 +1,29 @@
import { svgEl } from '../helpers';
export function renderDownloadIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-download-icon lucide-download',
},
[
svgEl('path', {
d: 'M12 15V3',
}),
svgEl('path', {
d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4',
}),
svgEl('path', {
d: 'm7 10 5 5 5-5',
}),
],
);
}
@@ -0,0 +1,32 @@
import { svgEl } from '../helpers/svgEl';
export function renderInfoIcon24() {
return svgEl(
'svg',
{
xmlns: 'http://www.w3.org/2000/svg',
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-info-icon lucide-info',
},
[
svgEl('circle', {
cx: '12',
cy: '12',
r: '10',
}),
svgEl('path', {
d: 'M12 16v-4',
}),
svgEl('path', {
d: 'M12 8h.01',
}),
],
);
}
@@ -0,0 +1,27 @@
import { svgEl } from '../helpers/svgEl';
export function renderLinkIcon24() {
return svgEl(
'svg',
{
xmlns: 'http://www.w3.org/2000/svg',
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-link-icon lucide-link',
},
[
svgEl('path', {
d: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
}),
svgEl('path', {
d: 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
}),
],
);
}
@@ -0,0 +1,32 @@
import { svgEl } from '../helpers';
export function renderLoaderCircleIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-loader-circle rotate',
},
[
svgEl('path', {
d: 'M21 12a9 9 0 1 1-6.219-8.56',
}),
svgEl('animateTransform', {
attributeName: 'transform',
attributeType: 'XML',
type: 'rotate',
from: '0 12 12',
to: '360 12 12',
dur: '1s',
repeatCount: 'indefinite',
}),
],
);
}
@@ -0,0 +1,34 @@
import { svgEl } from '../helpers';
export function renderPauseIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-pause-icon lucide-pause',
},
[
svgEl('rect', {
x: '14',
y: '3',
width: '5',
height: '18',
rx: '1',
}),
svgEl('rect', {
x: '5',
y: '3',
width: '5',
height: '18',
rx: '1',
}),
],
);
}
@@ -0,0 +1,23 @@
import { svgEl } from '../helpers';
export function renderPlayIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-play-icon lucide-play',
},
[
svgEl('path', {
d: 'M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z',
}),
],
);
}
@@ -0,0 +1,26 @@
import { svgEl } from '../helpers';
export function renderRotateCcwIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-rotate-ccw-icon lucide-rotate-ccw',
},
[
svgEl('path', {
d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8',
}),
svgEl('path', {
d: 'M3 3v5h5',
}),
],
);
}
@@ -0,0 +1,22 @@
import { svgEl } from '../helpers';
export function renderSearchIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-search-icon lucide-search',
},
[
svgEl('path', { d: 'm21 21-4.34-4.34' }),
svgEl('circle', { cx: '11', cy: '11', r: '8' }),
],
);
}
@@ -0,0 +1,30 @@
import { svgEl } from '../helpers';
export function renderSquareChartGanttIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-square-chart-gantt-icon lucide-square-chart-gantt',
},
[
svgEl('rect', {
width: '18',
height: '18',
x: '3',
y: '3',
rx: '2',
}),
svgEl('path', { d: 'M9 8h7' }),
svgEl('path', { d: 'M8 12h6' }),
svgEl('path', { d: 'M11 16h5' }),
],
);
}
@@ -0,0 +1,25 @@
import { svgEl } from '../helpers';
export function renderTriangleAlertIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-triangle-alert-icon lucide-triangle-alert',
},
[
svgEl('path', {
d: 'm21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3',
}),
svgEl('path', { d: 'M12 9v4' }),
svgEl('path', { d: 'M12 17h.01' }),
],
);
}
+19
View File
@@ -0,0 +1,19 @@
import { svgEl } from '../helpers';
export function renderXIcon24() {
const NS = 'http://www.w3.org/2000/svg';
return svgEl(
'svg',
{
xmlns: NS,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
class: 'lucide lucide-x-icon lucide-x',
},
[svgEl('path', { d: 'M18 6 6 18' }), svgEl('path', { d: 'm6 6 12 12' })],
);
}
+59
View File
@@ -0,0 +1,59 @@
type HtmlTag = keyof HTMLElementTagNameMap;
type HtmlElement<T extends HtmlTag> = HTMLElementTagNameMap[T];
type HtmlAttributes<T extends HtmlTag = 'div'> = Partial<
Omit<HtmlElement<T>, 'style' | 'children' | 'click'> & {
style?: string | Partial<CSSStyleDeclaration>;
class?: string;
'aria-busy'?: string;
'aria-disabled'?: string;
'aria-label'?: string;
'data-latency-section'?: string;
click?: (event: MouseEvent) => void;
onclick?: (event: MouseEvent) => void;
}
>;
declare global {
const fs: {
read(path: string): Promise<string>;
write(path: string, data: string): Promise<void>;
remove(path: string): Promise<void>;
exec(
command: string,
args?: string[],
env?: Record<string, string>,
): Promise<{
stdout: string;
stderr: string;
code?: number;
}>;
};
const E: <T extends HtmlTag>(
type: T,
attr?: HtmlAttributes<T> | null,
children?: (Node | string)[] | Node | string,
) => HTMLElementTagNameMap[T];
const uci: {
load: (packages: string | string[]) => Promise<string>;
unload?: (packages: string | string[]) => void;
sections: (conf: string, type?: string, cb?: () => void) => Promise<T>;
};
const _ = (_key: string) => string;
const ui = {
showModal: (_title: string, _content: HTMLElement) => undefined,
hideModal: () => undefined,
addNotification: (
_title: string,
_children: HtmlElement | HtmlElement[],
..._classNames: string[]
) => HTMLElement,
};
}
export {};
+42
View File
@@ -0,0 +1,42 @@
'use strict';
'require baseclass';
'require fs';
'require uci';
'require ui';
if (typeof structuredClone !== 'function')
globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
export { validateIP } from './validators/validateIp';
export { validateDomain } from './validators/validateDomain';
export { validateDNS } from './validators/validateDns';
export { validateUrl } from './validators/validateUrl';
export { validatePath } from './validators/validatePath';
export { validateSubnet } from './validators/validateSubnet';
export { bulkValidate } from './validators/bulkValidate';
export { validateOutboundJson } from './validators/validateOutboundJson';
export { validateProxyUrl } from './validators/validateProxyUrl';
export { parseValueList } from './helpers/parseValueList';
export { getProxyUrlName } from './helpers/getProxyUrlName';
export { injectGlobalStyles } from './helpers/injectGlobalStyles';
export { showToast } from './helpers/showToast';
export { getClashUIUrl } from './helpers/getClashApiUrl';
export { TachyonShellMethods } from './tachyon/methods/shell';
export { coreService } from './tachyon/services/core.service';
export { store } from './tachyon/services/store.service';
export { applyUiStateToStore } from './tachyon/services/uiState.service';
export { DashboardTab } from './tachyon/tabs/dashboard';
export { DiagnosticTab } from './tachyon/tabs/diagnostic';
export { MonitoringTab } from './tachyon/tabs/monitoring';
export { UpdatesTab } from './tachyon/tabs/updates';
export {
ALLOWED_WITH_RUSSIA_INSIDE,
BOOTSTRAP_DNS_SERVER_OPTIONS,
DEFAULT_LATENCY_TEST_URL,
DNS_SERVER_OPTIONS,
DOMAIN_LIST_OPTIONS,
LATENCY_TEST_URL_OPTIONS,
TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT,
TACHYON_UCI_PACKAGE,
REGIONAL_OPTIONS,
} from './constants';
@@ -0,0 +1,74 @@
import { insertIf } from '../../helpers';
import { renderLoaderCircleIcon24 } from '../../icons';
interface IRenderButtonProps {
classNames?: string[];
disabled?: boolean;
loading?: boolean;
icon?: () => SVGSVGElement;
onClick: () => void;
text: string;
}
export function renderButton({
classNames = [],
disabled,
loading,
onClick,
text,
icon,
}: IRenderButtonProps) {
const hasIcon = !!loading || !!icon;
function getWrappedIcon() {
const iconWrap = E('span', {
class: 'fkp-partial-button__icon',
});
if (loading) {
iconWrap.appendChild(renderLoaderCircleIcon24());
return iconWrap;
}
if (icon) {
iconWrap.appendChild(icon());
return iconWrap;
}
return iconWrap;
}
function getClass() {
return [
'btn',
'fkp-partial-button',
...insertIf(Boolean(disabled), ['fkp-partial-button--disabled']),
...insertIf(Boolean(loading), ['fkp-partial-button--loading']),
...insertIf(Boolean(hasIcon), ['fkp-partial-button--with-icon']),
...classNames,
]
.filter(Boolean)
.join(' ');
}
function getDisabled() {
if (loading || disabled) {
return true;
}
return undefined;
}
return E(
'button',
{
type: 'button',
class: getClass(),
disabled: getDisabled(),
click: onClick,
},
[...insertIf(hasIcon, [getWrappedIcon()]), E('span', {}, text)],
);
}
@@ -0,0 +1,36 @@
// language=CSS
export const styles = `
.fkp-partial-button {
text-align: center;
}
.fkp-partial-button--with-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.fkp-partial-button--loading {
}
.fkp-partial-button--disabled {
}
.fkp-partial-button__icon {
flex: 0 0 auto;
}
.fkp-partial-button__icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.fkp-partial-button__icon svg {
width: 16px;
height: 16px;
display: block;
flex: 0 0 auto;
}
`;
+10
View File
@@ -0,0 +1,10 @@
import { styles as ButtonStyles } from './button/styles';
import { styles as ModalStyles } from './modal/styles';
export * from './button/renderButton';
export * from './modal/renderModal';
export const PartialStyles = `
${ButtonStyles}
${ModalStyles}
`;
@@ -0,0 +1,353 @@
import { renderButton } from '../button/renderButton';
import { copyToClipboard } from '../../helpers/copyToClipboard';
import { downloadAsTxt } from '../../helpers/downloadAsTxt';
interface ModalTextContext {
maskValues: boolean;
}
interface RenderModalOptions {
getText?: (context: ModalTextContext) => string | Promise<string>;
maskText?: (text: string) => string;
refreshMs?: number;
initialAutoRefresh?: boolean;
showAutoRefreshToggle?: boolean;
showMaskValuesToggle?: boolean;
initialMaskValues?: boolean;
startAtEnd?: boolean;
autoRefreshLabel?: string;
maskValuesLabel?: string;
}
export function renderModal(
text: string,
name: string,
options?: RenderModalOptions,
) {
let rawText = text ?? '';
let currentText = '';
let refreshInFlight = false;
let pendingRefresh = false;
let pendingForcedRefresh = false;
let refreshSessionId = 0;
let timer: ReturnType<typeof setInterval> | undefined;
let observer: MutationObserver | undefined;
let autoRefreshEnabled =
options?.initialAutoRefresh ?? Boolean(options?.getText);
let maskValuesEnabled = options?.initialMaskValues ?? true;
let shouldScrollToBottomOnMount = Boolean(options?.startAtEnd);
let autoRefreshInput: HTMLInputElement | undefined;
let maskValuesInput: HTMLInputElement | undefined;
const getDisplayText = (value: string) => {
if (maskValuesEnabled && options?.maskText) {
return options.maskText(value);
}
return value;
};
const codeEl = E('code', {}, '') as HTMLElement;
const contentEl = E(
'pre',
{ class: 'fkp-partial-modal__content' },
codeEl,
) as HTMLElement;
const stopRefreshTimer = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
};
const destroyLiveRefresh = () => {
refreshSessionId += 1;
pendingRefresh = false;
pendingForcedRefresh = false;
stopRefreshTimer();
observer?.disconnect();
observer = undefined;
};
const scrollToBottom = () => {
contentEl.scrollTop = contentEl.scrollHeight;
};
const scheduleInitialScrollToBottom = () => {
if (!shouldScrollToBottomOnMount || !body.isConnected) {
return;
}
shouldScrollToBottomOnMount = false;
requestAnimationFrame(() => {
scrollToBottom();
});
};
const updateText = (nextText: string) => {
const normalizedText = nextText ?? '';
const shouldStickToBottom =
shouldScrollToBottomOnMount ||
contentEl.scrollTop + contentEl.clientHeight >=
contentEl.scrollHeight - 16;
if (normalizedText === currentText) {
if (shouldStickToBottom) {
requestAnimationFrame(() => {
scrollToBottom();
});
}
return;
}
currentText = normalizedText;
codeEl.textContent = currentText;
if (shouldStickToBottom) {
requestAnimationFrame(() => {
scrollToBottom();
});
}
};
const updateDisplayedTextFromRaw = () => {
updateText(getDisplayText(rawText));
};
const refreshText = async (force = false) => {
if (
!options?.getText ||
(!force && !autoRefreshEnabled) ||
refreshInFlight
) {
return;
}
if (!body.isConnected) {
return;
}
refreshInFlight = true;
const sessionId = refreshSessionId;
try {
const nextText = await options.getText({
maskValues: options.maskText ? false : maskValuesEnabled,
});
if (!body.isConnected || (!force && !autoRefreshEnabled)) {
return;
}
if (sessionId !== refreshSessionId) {
return;
}
const normalizedText = nextText ?? '';
rawText = normalizedText;
updateText(getDisplayText(normalizedText));
} catch (error) {
console.warn('[renderModal] failed to refresh modal content', error);
} finally {
refreshInFlight = false;
if (pendingRefresh) {
const shouldForceRefresh = pendingForcedRefresh;
pendingRefresh = false;
pendingForcedRefresh = false;
if ((shouldForceRefresh || autoRefreshEnabled) && body.isConnected) {
void refreshText(shouldForceRefresh);
}
}
}
};
const requestRefresh = () => {
if (!options?.getText || !autoRefreshEnabled) {
return;
}
if (refreshInFlight) {
pendingRefresh = true;
return;
}
void refreshText();
};
const requestForcedRefresh = () => {
if (!options?.getText) {
return;
}
if (refreshInFlight) {
pendingRefresh = true;
pendingForcedRefresh = true;
return;
}
void refreshText(true);
};
const startRefreshTimer = () => {
if (
!options?.getText ||
!autoRefreshEnabled ||
timer ||
typeof document === 'undefined'
) {
return;
}
timer = setInterval(() => {
requestRefresh();
}, options.refreshMs ?? 3000);
};
const setAutoRefreshEnabled = (nextValue: boolean) => {
autoRefreshEnabled = nextValue;
refreshSessionId += 1;
pendingRefresh = false;
pendingForcedRefresh = false;
if (autoRefreshInput) {
autoRefreshInput.checked = nextValue;
}
if (nextValue) {
startRefreshTimer();
requestRefresh();
return;
}
stopRefreshTimer();
};
const setMaskValuesEnabled = (nextValue: boolean) => {
maskValuesEnabled = nextValue;
refreshSessionId += 1;
pendingRefresh = false;
pendingForcedRefresh = false;
if (maskValuesInput) {
maskValuesInput.checked = nextValue;
}
if (options?.maskText) {
updateDisplayedTextFromRaw();
return;
}
requestForcedRefresh();
};
const footerChildren: HTMLElement[] = [
renderButton({
classNames: ['cbi-button-apply'],
text: _('Download'),
onClick: () => downloadAsTxt(currentText, name),
}),
renderButton({
classNames: ['cbi-button-apply'],
text: _('Copy'),
onClick: () => copyToClipboard(`\`\`\`${name}\n${currentText}\n\`\`\``),
}),
renderButton({
classNames: ['cbi-button-remove'],
text: _('Close'),
onClick: () => {
destroyLiveRefresh();
ui.hideModal();
},
}),
];
if (options?.getText && options?.showAutoRefreshToggle) {
autoRefreshInput = document.createElement('input');
autoRefreshInput.type = 'checkbox';
autoRefreshInput.className = 'cbi-input-checkbox';
autoRefreshInput.checked = autoRefreshEnabled;
autoRefreshInput.addEventListener('change', () => {
setAutoRefreshEnabled(autoRefreshInput!.checked);
});
footerChildren.unshift(
E('label', { class: 'fkp-partial-modal__checkbox' }, [
autoRefreshInput,
E(
'span',
{ class: 'fkp-partial-modal__checkbox-text' },
options.autoRefreshLabel ?? _('Auto refresh'),
),
]) as HTMLElement,
);
}
if (
(options?.getText || options?.maskText) &&
options?.showMaskValuesToggle
) {
maskValuesInput = document.createElement('input');
maskValuesInput.type = 'checkbox';
maskValuesInput.className = 'cbi-input-checkbox';
maskValuesInput.checked = maskValuesEnabled;
maskValuesInput.addEventListener('change', () => {
setMaskValuesEnabled(maskValuesInput!.checked);
});
footerChildren.unshift(
E('label', { class: 'fkp-partial-modal__checkbox' }, [
maskValuesInput,
E(
'span',
{ class: 'fkp-partial-modal__checkbox-text' },
options.maskValuesLabel ?? _('Hide values'),
),
]) as HTMLElement,
);
}
const body = E('div', { class: 'fkp-partial-modal__body' }, [
E('div', {}, [
contentEl,
E('div', { class: 'fkp-partial-modal__footer' }, footerChildren),
]),
]) as HTMLElement;
if (
(options?.getText || options?.startAtEnd) &&
typeof document !== 'undefined'
) {
observer = new MutationObserver(() => {
if (!body.isConnected) {
destroyLiveRefresh();
return;
}
scheduleInitialScrollToBottom();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
scheduleInitialScrollToBottom();
}
if (options?.getText && typeof document !== 'undefined') {
startRefreshTimer();
requestRefresh();
}
updateDisplayedTextFromRaw();
return body;
}
@@ -0,0 +1,36 @@
// language=CSS
export const styles = `
.fkp-partial-modal__body {}
.fkp-partial-modal__content {
max-height: 70vh;
overflow: scroll;
border-radius: 4px;
}
.fkp-partial-modal__footer {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.fkp-partial-modal__footer button {
margin-left: 0;
}
.fkp-partial-modal__checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: auto;
cursor: pointer;
user-select: none;
}
.fkp-partial-modal__checkbox-text {
line-height: 1.2;
}
`;
+166
View File
@@ -0,0 +1,166 @@
// language=CSS
import { DashboardTab } from './tachyon/tabs/dashboard';
import { DiagnosticTab } from './tachyon/tabs/diagnostic';
import { MonitoringTab } from './tachyon/tabs/monitoring';
import { UpdatesTab } from './tachyon/tabs/updates';
import { PartialStyles } from './partials';
import { TACHYON_UCI_PACKAGE as TACHYON_CBI_PREFIX } from './constants';
export const GlobalStyles = `
${DashboardTab.styles}
${DiagnosticTab.styles}
${MonitoringTab.styles}
${UpdatesTab.styles}
${PartialStyles}
/* Hide extra H3 for settings tab */
#cbi-${TACHYON_CBI_PREFIX}-settings > h3 {
display: none;
}
/* Hide extra H3 for rules tab */
#cbi-${TACHYON_CBI_PREFIX}-section > h3:nth-child(1) {
display: none;
}
/* Vertical align for remove rule action button */
#cbi-${TACHYON_CBI_PREFIX}-section > .cbi-section-remove {
margin-bottom: -32px;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-actions > div {
display: inline-flex;
align-items: center;
gap: 4px;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-actions {
text-align: right;
}
/* Rule reorder visuals */
#cbi-${TACHYON_CBI_PREFIX}-section {
position: relative;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row {
position: relative;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.placeholder {
opacity: 1;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.placeholder em {
font-style: italic;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-above::after,
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-below::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
height: 2px;
border-radius: 2px;
background: var(--primary-color-high, #1976d2);
pointer-events: none;
z-index: 2;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-above::after {
top: -1px;
}
#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-below::after {
bottom: -1px;
}
/* Centered class helper */
.centered {
display: flex;
align-items: center;
justify-content: center;
}
/* Rotate class helper */
.rotate {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Skeleton styles*/
.skeleton {
background-color: var(--background-color-low, #e0e0e0);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
top: 0;
left: -150%;
width: 150%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: skeleton-shimmer 1.6s infinite;
}
@keyframes skeleton-shimmer {
100% {
left: 150%;
}
}
/* Toast */
.toast-container {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 9999;
font-family: system-ui, sans-serif;
}
.toast {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
padding: 10px 16px;
border-radius: 6px;
color: #fff;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
min-width: 220px;
max-width: 340px;
text-align: center;
}
.toast-success {
background-color: #28a745;
}
.toast-error {
background-color: #dc3545;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
`;
+53
View File
@@ -0,0 +1,53 @@
import { withTimeout } from '../helpers';
export async function createBaseApiRequest<T>(
fetchFn: () => Promise<Response>,
options?: {
timeoutMs?: number;
operationName?: string;
timeoutMessage?: string;
},
): Promise<IBaseApiResponse<T>> {
const wrappedFn = () =>
options?.timeoutMs && options?.operationName
? withTimeout(
fetchFn(),
options.timeoutMs,
options.operationName,
options.timeoutMessage,
)
: fetchFn();
try {
const response = await wrappedFn();
if (!response.ok) {
return {
success: false as const,
message: `${_('HTTP error')} ${response.status}: ${response.statusText}`,
};
}
const data: T = await response.json();
return {
success: true as const,
data,
};
} catch (e) {
return {
success: false as const,
message: e instanceof Error ? e.message : _('Unknown error'),
};
}
}
export type IBaseApiResponse<T> =
| {
success: true;
data: T;
}
| {
success: false;
message: string;
};
@@ -0,0 +1,70 @@
import { TachyonShellMethods } from '../methods';
import { logger } from '../services/logger.service';
import { store } from '../services/store.service';
import { refreshRuntimeUiState } from '../services/runtimeUiState.service';
import { Tachyon } from '../types';
let latestServicesInfoRequestId = 0;
function getSettledMethodResponse<T>(
scope: string,
result: PromiseSettledResult<Tachyon.MethodResponse<T>>,
): Tachyon.MethodResponse<T> {
if (result.status === 'fulfilled') {
return result.value;
}
logger.error('[SERVICES_INFO]', `${scope} failed`, result.reason);
return {
success: false,
error: result.reason instanceof Error ? result.reason.message : '',
};
}
export async function fetchServicesInfo() {
const requestId = ++latestServicesInfoRequestId;
const uiState = await refreshRuntimeUiState({ force: true });
if (requestId !== latestServicesInfoRequestId) {
return;
}
if (uiState) {
return uiState;
}
const [tachyonResult, singboxResult] = await Promise.allSettled([
TachyonShellMethods.getStatus(),
TachyonShellMethods.getSingBoxStatus(),
]);
if (requestId !== latestServicesInfoRequestId) {
return;
}
const tachyon = getSettledMethodResponse('getStatus', tachyonResult);
const singbox = getSettledMethodResponse('getSingBoxStatus', singboxResult);
const previousData = store.get().servicesInfoWidget.data;
store.set({
servicesInfoWidget: {
loading: false,
failed: !tachyon.success || !singbox.success,
data: {
singbox: singbox.success ? singbox.data.running : previousData.singbox,
tachyonRunning: tachyon.success
? tachyon.data.running
: previousData.tachyonRunning,
tachyonEnabled: tachyon.success
? tachyon.data.enabled
: previousData.tachyonEnabled,
tachyonStatus: tachyon.success
? tachyon.data.status
: previousData.tachyonStatus,
},
},
});
return undefined;
}
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
}));
vi.mock('../../../helpers', () => ({
executeShellCommand: mocks.executeShellCommand,
}));
import { store } from '../../services/store.service';
import { fetchServicesInfo } from '../fetchServicesInfo';
describe('fetchServicesInfo', () => {
beforeEach(() => {
store.reset();
mocks.executeShellCommand.mockReset();
});
it('returns the fast UI state after applying it to the shared store', async () => {
const uiState = {
service: {
tachyon: {
running: 1,
enabled: 1,
status: 'restarting',
dns_configured: 1,
},
sing_box: {
running: 1,
enabled: 0,
status: 'running but disabled',
},
},
capabilities: {
sing_box_extended: 1,
sing_box_tiny: 0,
sing_box_tailscale: 1,
zapret_installed: 1,
zapret2_installed: 0,
byedpi_installed: 0,
server_inbounds_enabled_count: 0,
},
actions: {
service: [
{
success: true,
running: true,
kind: 'service',
action: 'restart',
job_id: 'service-1',
},
],
latency: [],
component: [],
subscription: [],
},
};
mocks.executeShellCommand.mockResolvedValue({
stdout: JSON.stringify(uiState),
stderr: '',
code: 0,
});
await expect(fetchServicesInfo()).resolves.toEqual(uiState);
const state = store.get();
expect(state.servicesInfoWidget.data.tachyonStatus).toBe('restarting');
expect(state.diagnosticsActions.restart.loading).toBe(true);
});
it('keeps the previous service state when the fallback status fetch fails', async () => {
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: {
singbox: 1,
tachyonRunning: 1,
tachyonEnabled: 1,
tachyonStatus: 'running & enabled',
},
},
});
mocks.executeShellCommand
.mockResolvedValueOnce({
stdout: '',
stderr: 'get_ui_state failed',
code: 1,
})
.mockResolvedValueOnce({
stdout: '',
stderr: 'get_status failed',
code: 1,
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
running: 0,
enabled: 0,
status: 'stopped but disabled',
}),
stderr: '',
code: 0,
});
await fetchServicesInfo();
const state = store.get();
expect(state.servicesInfoWidget.failed).toBe(true);
expect(state.servicesInfoWidget.data).toEqual({
singbox: 0,
tachyonRunning: 1,
tachyonEnabled: 1,
tachyonStatus: 'running & enabled',
});
});
});
@@ -0,0 +1,31 @@
import type { StoreType } from '../services/store.service';
import type { Tachyon } from '../types';
export type UpdatesActionKey = keyof StoreType['updatesActions'];
const componentActionKeyMap: Record<string, UpdatesActionKey> = {
'tachyon:check_update': 'tachyonCheck',
'tachyon:install': 'tachyonInstall',
'sing_box:check_update': 'singBoxCheck',
'sing_box:install': 'singBoxInstall',
'sing_box:install_extended': 'singBoxInstallExtended',
'sing_box:install_extended_compressed': 'singBoxInstallExtendedCompressed',
'sing_box:install_tiny': 'singBoxInstallTiny',
'sing_box:install_stable': 'singBoxInstallStable',
'zapret:check_update': 'zapretCheck',
'zapret:install': 'zapretInstall',
'zapret:remove': 'zapretRemove',
'zapret2:check_update': 'zapret2Check',
'zapret2:install': 'zapret2Install',
'zapret2:remove': 'zapret2Remove',
'byedpi:check_update': 'byedpiCheck',
'byedpi:install': 'byedpiInstall',
'byedpi:remove': 'byedpiRemove',
};
export function getComponentActionKey(
component: Tachyon.ComponentName,
action: Tachyon.ComponentAction,
): UpdatesActionKey | undefined {
return componentActionKeyMap[`${component}:${action}`];
}
@@ -0,0 +1,11 @@
export function isActiveLuciTab(tabId: string) {
if (typeof document === 'undefined') {
return false;
}
return Boolean(
document.querySelector(
`.cbi-tab[data-tab="${tabId}"]:not(.cbi-tab-disabled)`,
),
);
}
@@ -0,0 +1,17 @@
const TRANSIENT_RPC_ERROR_PATTERNS = [
'no related rpc reply',
'request aborted',
'operation was aborted',
];
export function isTransientRpcError(message?: string | null) {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return TRANSIENT_RPC_ERROR_PATTERNS.some((pattern) =>
normalized.includes(pattern),
);
}
@@ -0,0 +1,5 @@
export function shouldShowLoadingForRestoredAction(state: {
running?: boolean;
}) {
return state.running === true;
}
@@ -0,0 +1,25 @@
export type ServiceAvailability =
| 'loading'
| 'running'
| 'stopped'
| 'unavailable';
export function getServiceAvailability({
loading,
failed,
running,
}: {
loading: boolean;
failed: boolean;
running: boolean | number;
}): ServiceAvailability {
if (loading) {
return 'loading';
}
if (failed) {
return 'unavailable';
}
return running ? 'running' : 'stopped';
}
@@ -0,0 +1,72 @@
type SingBoxVariantFields = {
sing_box_version?: string;
sing_box_extended?: number;
sing_box_tiny?: number;
sing_box_compressed?: number;
sing_box_tailscale?: number;
};
export function isExtendedSingBoxVersion(version?: string) {
return String(version || '').includes('extended');
}
function isVersionPlaceholder(version?: string) {
const normalized = String(version || '')
.trim()
.toLowerCase();
if (
!normalized ||
normalized === 'loading' ||
normalized === 'unknown' ||
normalized === 'not installed'
) {
return true;
}
return (
(typeof _ === 'function' && normalized === _('unknown').toLowerCase()) ||
(typeof _ === 'function' && normalized === _('Not installed').toLowerCase())
);
}
export function formatSingBoxVersion(value: SingBoxVariantFields) {
const version = String(value.sing_box_version || '');
if (!version || version === 'not installed') {
return _('Not installed');
}
if (isVersionPlaceholder(version)) {
return version;
}
const normalizedValue = normalizeSingBoxVariantFields(value);
let variant = '';
if (
normalizedValue.sing_box_extended &&
normalizedValue.sing_box_compressed
) {
variant = _('compressed');
} else if (normalizedValue.sing_box_tiny) {
variant = _('tiny');
}
return variant ? `${version} (${variant})` : version;
}
export function normalizeSingBoxVariantFields<T extends SingBoxVariantFields>(
value: T,
): T {
const versionExtended = isExtendedSingBoxVersion(value.sing_box_version);
const singBoxExtended = Boolean(value.sing_box_extended) || versionExtended;
return {
...value,
sing_box_extended: singBoxExtended ? 1 : 0,
sing_box_tiny: singBoxExtended ? 0 : value.sing_box_tiny ? 1 : 0,
sing_box_compressed: singBoxExtended && value.sing_box_compressed ? 1 : 0,
sing_box_tailscale: singBoxExtended || value.sing_box_tailscale ? 1 : 0,
} as T;
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { isTransientRpcError } from '../isTransientRpcError';
describe('isTransientRpcError', () => {
it('detects LuCI RPC reply races caused by interrupted page requests', () => {
expect(isTransientRpcError('No related RPC reply')).toBe(true);
expect(isTransientRpcError('Request aborted while waiting for RPC')).toBe(
true,
);
});
it('does not treat backend action failures as transient transport errors', () => {
expect(
isTransientRpcError('Another component action is already running'),
).toBe(false);
expect(isTransientRpcError('Component action job was not found')).toBe(
false,
);
});
});
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { shouldShowLoadingForRestoredAction } from '../restoredActionLoading';
describe('restored action loading', () => {
it('shows loading only for actions that are still running', () => {
expect(shouldShowLoadingForRestoredAction({ running: true })).toBe(true);
expect(shouldShowLoadingForRestoredAction({ running: false })).toBe(false);
expect(shouldShowLoadingForRestoredAction({})).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { getServiceAvailability } from '../serviceAvailability';
describe('getServiceAvailability', () => {
it('does not treat an unresolved service status as stopped', () => {
expect(
getServiceAvailability({ loading: true, failed: false, running: false }),
).toBe('loading');
});
it('distinguishes a stopped service from an unavailable status request', () => {
expect(
getServiceAvailability({ loading: false, failed: false, running: 0 }),
).toBe('stopped');
expect(
getServiceAvailability({ loading: false, failed: true, running: 0 }),
).toBe('unavailable');
});
it('accepts the numeric running flag returned by the backend', () => {
expect(
getServiceAvailability({ loading: false, failed: false, running: 1 }),
).toBe('running');
});
});
@@ -0,0 +1,9 @@
import { getConfigSections } from './getConfigSections';
export async function getClashApiSecret() {
const sections = await getConfigSections();
const settings = sections.find((section) => section['.type'] === 'settings');
return settings?.yacd_secret_key || '';
}
@@ -0,0 +1,8 @@
import { Tachyon } from '../../types';
import { TACHYON_UCI_PACKAGE } from '../../../constants';
export async function getConfigSections(): Promise<Tachyon.ConfigSection[]> {
return uci
.load(TACHYON_UCI_PACKAGE)
.then(() => uci.sections(TACHYON_UCI_PACKAGE));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
import { getConfigSections } from './getConfigSections';
import { getDashboardSections } from './getDashboardSections';
import { getClashApiSecret } from './getClashApiSecret';
export const CustomTachyonMethods = {
getConfigSections,
getDashboardSections,
getClashApiSecret,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
import { FAKEIP_CHECK_DOMAIN } from '../../../constants';
import { createBaseApiRequest, IBaseApiResponse } from '../../api';
interface IGetFakeIpCheckResponse {
fakeip: boolean;
IP: string;
}
export async function getFakeIpCheck(): Promise<
IBaseApiResponse<IGetFakeIpCheckResponse>
> {
return createBaseApiRequest<IGetFakeIpCheckResponse>(
() =>
fetch(`https://${FAKEIP_CHECK_DOMAIN}/check`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
{
operationName: 'getFakeIpCheck',
timeoutMs: 5000,
},
);
}
@@ -0,0 +1,23 @@
import { IP_CHECK_DOMAIN } from '../../../constants';
import { createBaseApiRequest, IBaseApiResponse } from '../../api';
interface IGetIpCheckResponse {
fakeip: boolean;
IP: string;
}
export async function getIpCheck(): Promise<
IBaseApiResponse<IGetIpCheckResponse>
> {
return createBaseApiRequest<IGetIpCheckResponse>(
() =>
fetch(`https://${IP_CHECK_DOMAIN}/check`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
{
operationName: 'getIpCheck',
timeoutMs: 5000,
},
);
}
@@ -0,0 +1,7 @@
import { getFakeIpCheck } from './getFakeIpCheck';
import { getIpCheck } from './getIpCheck';
export const RemoteFakeIPMethods = {
getFakeIpCheck,
getIpCheck,
};
@@ -0,0 +1,3 @@
export * from './custom';
export * from './fakeip';
export * from './shell';
@@ -0,0 +1,57 @@
import { executeShellCommand } from '../../../helpers';
import { Tachyon } from '../../types';
interface CallBaseMethodOptions {
allowNonZeroWithStdout?: boolean;
timeout?: number;
}
export async function callBaseMethod<T>(
method: Tachyon.AvailableMethods,
args: string[] = [],
command: string = '/usr/bin/tachyon',
options: CallBaseMethodOptions = {},
): Promise<Tachyon.MethodResponse<T>> {
try {
const response = await executeShellCommand({
command,
args: [method as string, ...args],
timeout: options.timeout ?? 15000,
});
const exitCode = response.code ?? 0;
if (
exitCode !== 0 &&
!(options.allowNonZeroWithStdout && response.stdout)
) {
return {
success: false,
error: response.stderr || response.stdout || '',
};
}
if (response.stdout) {
try {
return {
success: true,
data: JSON.parse(response.stdout) as T,
};
} catch (_e) {
return {
success: true,
data: response.stdout as T,
};
}
}
return {
success: false,
error: response.stderr || '',
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : '',
};
}
}
@@ -0,0 +1,765 @@
import { callBaseMethod } from './callBaseMethod';
import { ClashAPI, Tachyon } from '../../types';
import { executeShellCommand } from '../../../helpers';
import { isTransientRpcError } from '../../helpers/isTransientRpcError';
const SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS = 15000;
const SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS = 1500;
const UI_ACTION_RPC_TIMEOUT_MS = 15000;
const UI_ACTION_TRANSIENT_RPC_GRACE_MS = 30000;
const SERVICE_ACTION_TIMEOUT_MS = 2 * 60 * 1000;
const SERVICE_ACTION_POLL_INTERVAL_MS = 1000;
const LATENCY_TEST_TIMEOUT_MS = 30 * 1000;
const LATENCY_TEST_POLL_INTERVAL_MS = 1000;
const COMPONENT_ACTION_RPC_TIMEOUT_MS = 15000;
const COMPONENT_ACTION_POLL_INTERVAL_MS = 1500;
const COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS = 15000;
const COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS = 30000;
const COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS = 30000;
const COMPONENT_ACTION_STATE_DIR = '/var/run/tachyon/component-actions';
const GET_UI_STATE_RPC_TIMEOUT_MS = 3000;
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function translate(message: string) {
return typeof _ === 'function' ? _(message) : message;
}
function parseJsonObjectOutput<T>(output: string): T | null {
if (!output) {
return null;
}
try {
return JSON.parse(output) as T;
} catch (_error) {
const jsonMatch = output.match(/(\{[\s\S]*\})\s*$/);
if (!jsonMatch) {
return null;
}
try {
return JSON.parse(jsonMatch[1]) as T;
} catch (_jsonError) {
return null;
}
}
}
function parseComponentActionOutput(output: string) {
return parseJsonObjectOutput<Tachyon.ComponentActionResult>(output);
}
function parseComponentActionResult(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
return parseComponentActionOutput(response.stdout);
}
function parseComponentActionStartResult(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
const parsedResponse = parseComponentActionResult(response);
if (!parsedResponse) {
return null;
}
return parsedResponse as unknown as Tachyon.ComponentActionStartResult;
}
function parseSubscriptionUpdateStartResult(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
return parseJsonObjectOutput<Tachyon.SubscriptionUpdateStartResult>(
response.stdout,
);
}
function parseSubscriptionUpdateJobState(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
return parseJsonObjectOutput<Tachyon.SubscriptionUpdateJobState>(
response.stdout,
);
}
function parseUiActionStartResult(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
return parseJsonObjectOutput<Tachyon.UiActionStartResult>(response.stdout);
}
function parseServiceActionState(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
return parseJsonObjectOutput<Tachyon.ServiceActionState>(response.stdout);
}
function parseLatencyActionState(
response: Awaited<ReturnType<typeof executeShellCommand>>,
) {
return parseJsonObjectOutput<Tachyon.LatencyActionState>(response.stdout);
}
function isComponentActionJobId(jobId: string) {
return /^[A-Za-z0-9._-]+$/.test(jobId) && jobId !== '.' && jobId !== '..';
}
async function readComponentActionState(jobId: string) {
if (!isComponentActionJobId(jobId)) {
return null;
}
try {
return parseComponentActionOutput(
await fs.read(`${COMPONENT_ACTION_STATE_DIR}/${jobId}.json`),
);
} catch (_error) {
return null;
}
}
async function readTachyonVersion() {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: ['show_version'],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
if ((response.code ?? 0) !== 0 || !response.stdout) {
return '';
}
return response.stdout.trim();
}
async function isComponentActionStillRunning(
jobId: string,
component: Tachyon.ComponentName,
action: Tachyon.ComponentAction,
) {
const response = await callBaseMethod<Tachyon.UiState>(
Tachyon.AvailableMethods.GET_UI_STATE,
[],
'/usr/bin/tachyon',
{ timeout: GET_UI_STATE_RPC_TIMEOUT_MS },
);
return (
response.success &&
response.data.actions.component.some(
(state) =>
state.job_id === jobId &&
state.component === component &&
state.action === action &&
state.running === true,
)
);
}
function componentActionFailure(
response: Awaited<ReturnType<typeof executeShellCommand>>,
parsedResponse?: Pick<Tachyon.ComponentActionResult, 'message'> | null,
) {
return {
success: false,
error: parsedResponse?.message || response.stderr || _('Failed to execute'),
} as Tachyon.MethodFailureResponse;
}
function uiActionFailure(
response: Awaited<ReturnType<typeof executeShellCommand>>,
parsedResponse?: { message?: string } | null,
fallback: string = _('Failed to execute'),
) {
return {
success: false,
error: parsedResponse?.message || response.stderr || fallback,
} as Tachyon.MethodFailureResponse;
}
function createTransientRpcGraceTracker(graceMs: number) {
let failureStartedAt = 0;
return {
reset() {
failureStartedAt = 0;
},
shouldContinue(error?: string) {
if (!isTransientRpcError(error)) {
failureStartedAt = 0;
return false;
}
if (!failureStartedAt) {
failureStartedAt = Date.now();
}
return Date.now() - failureStartedAt < graceMs;
},
};
}
export const TachyonShellMethods = {
checkDNSAvailable: async () =>
callBaseMethod<Tachyon.DnsCheckResult>(
Tachyon.AvailableMethods.CHECK_DNS_AVAILABLE,
),
checkFakeIP: async () =>
callBaseMethod<Tachyon.FakeIPCheckResult>(
Tachyon.AvailableMethods.CHECK_FAKEIP,
),
checkNftRules: async () =>
callBaseMethod<Tachyon.NftRulesCheckResult>(
Tachyon.AvailableMethods.CHECK_NFT_RULES,
),
checkZapretRuntime: async () =>
callBaseMethod<Tachyon.ZapretCheckResult>(
Tachyon.AvailableMethods.CHECK_ZAPRET_RUNTIME,
),
checkZapret2Runtime: async () =>
callBaseMethod<Tachyon.Zapret2CheckResult>(
Tachyon.AvailableMethods.CHECK_ZAPRET2_RUNTIME,
),
checkByedpiRuntime: async () =>
callBaseMethod<Tachyon.ByedpiCheckResult>(
Tachyon.AvailableMethods.CHECK_BYEDPI_RUNTIME,
),
checkInboundsConfig: async () =>
callBaseMethod<Tachyon.InboundsConfigCheckResult>(
Tachyon.AvailableMethods.CHECK_INBOUNDS_CONFIG,
),
getStatus: async () =>
callBaseMethod<Tachyon.GetStatus>(Tachyon.AvailableMethods.GET_STATUS),
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],
),
checkSingBox: async () =>
callBaseMethod<Tachyon.SingBoxCheckResult>(
Tachyon.AvailableMethods.CHECK_SING_BOX,
),
checkInbounds: async () =>
callBaseMethod<Tachyon.InboundsCheckResult>(
Tachyon.AvailableMethods.CHECK_INBOUNDS,
),
getSingBoxStatus: async () =>
callBaseMethod<Tachyon.GetSingBoxStatus>(
Tachyon.AvailableMethods.GET_SING_BOX_STATUS,
),
getZapretStatus: async () =>
callBaseMethod<Tachyon.GetZapretStatus>(
Tachyon.AvailableMethods.GET_ZAPRET_STATUS,
),
getZapret2Status: async () =>
callBaseMethod<Tachyon.GetZapret2Status>(
Tachyon.AvailableMethods.GET_ZAPRET2_STATUS,
),
getByedpiStatus: async () =>
callBaseMethod<Tachyon.GetByedpiStatus>(
Tachyon.AvailableMethods.GET_BYEDPI_STATUS,
),
getClashApiProxies: async () =>
callBaseMethod<ClashAPI.Proxies>(Tachyon.AvailableMethods.CLASH_API, [
Tachyon.AvailableClashAPIMethods.GET_PROXIES,
]),
getClashApiConnections: async () =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.CLASH_API, [
Tachyon.AvailableClashAPIMethods.GET_CONNECTIONS,
]),
getClashApiProxyLatency: async (tag: string, timeout = '5000') =>
callBaseMethod<Tachyon.GetClashApiProxyLatency>(
Tachyon.AvailableMethods.CLASH_API,
[Tachyon.AvailableClashAPIMethods.GET_PROXY_LATENCY, tag, timeout],
),
getClashApiProxyLatencies: async (tags: string[]) =>
callBaseMethod<Tachyon.GetClashApiProxyLatencies>(
Tachyon.AvailableMethods.CLASH_API,
[
Tachyon.AvailableClashAPIMethods.GET_PROXY_LATENCIES,
JSON.stringify(tags),
'5000',
],
),
getClashApiGroupLatency: async (tag: string) =>
callBaseMethod<Tachyon.GetClashApiGroupLatency>(
Tachyon.AvailableMethods.CLASH_API,
[Tachyon.AvailableClashAPIMethods.GET_GROUP_LATENCY, tag, '10000'],
),
setClashApiGroupProxy: async (group: string, proxy: string) =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.CLASH_API, [
Tachyon.AvailableClashAPIMethods.SET_GROUP_PROXY,
group,
proxy,
]),
closeClashApiConnection: async (connectionId: string) =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.CLASH_API, [
Tachyon.AvailableClashAPIMethods.CLOSE_CONNECTION,
connectionId,
]),
closeAllClashApiConnections: async () =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.CLASH_API, [
Tachyon.AvailableClashAPIMethods.CLOSE_ALL_CONNECTIONS,
]),
enable: async () =>
callBaseMethod<unknown>(
Tachyon.AvailableMethods.ENABLE,
[],
'/etc/init.d/tachyon',
),
disable: async () =>
callBaseMethod<unknown>(
Tachyon.AvailableMethods.DISABLE,
[],
'/etc/init.d/tachyon',
),
globalCheck: async (masked = true) =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.GLOBAL_CHECK, [
masked ? 'masked' : 'raw',
]),
showSingBoxConfig: async (masked = true) =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.SHOW_SING_BOX_CONFIG, [
masked ? 'masked' : 'raw',
]),
checkLogs: async () =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.CHECK_LOGS),
checkSingBoxLogs: async () =>
callBaseMethod<unknown>(Tachyon.AvailableMethods.CHECK_SING_BOX_LOGS),
getSystemInfo: async () =>
callBaseMethod<Tachyon.GetSystemInfo>(
Tachyon.AvailableMethods.GET_SYSTEM_INFO,
),
getServerCapabilities: async () =>
callBaseMethod<Tachyon.GetServerCapabilities>(
Tachyon.AvailableMethods.GET_SERVER_CAPABILITIES,
),
getUiCapabilities: async () =>
callBaseMethod<Tachyon.GetUiCapabilities>(
Tachyon.AvailableMethods.GET_UI_CAPABILITIES,
),
getUiState: async () =>
callBaseMethod<Tachyon.UiState>(
Tachyon.AvailableMethods.GET_UI_STATE,
[],
'/usr/bin/tachyon',
{ timeout: GET_UI_STATE_RPC_TIMEOUT_MS },
),
serviceActionStart: async (action: Tachyon.ServiceAction) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.SERVICE_ACTION_ASYNC, action],
timeout: UI_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseUiActionStartResult(response);
if (
(response.code ?? 0) !== 0 ||
!parsedResponse?.success ||
!parsedResponse.job_id
) {
return uiActionFailure(
response,
parsedResponse,
_('Service action failed'),
);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.UiActionStartResult>;
},
serviceActionStatus: async (jobId: string) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.SERVICE_ACTION_STATUS, jobId],
timeout: UI_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseServiceActionState(response);
if ((response.code ?? 0) !== 0 || !parsedResponse) {
return uiActionFailure(
response,
parsedResponse,
_('Service action failed'),
);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.ServiceActionState>;
},
waitServiceActionJob: async (jobId: string, startedAt = Date.now()) => {
while (Date.now() - startedAt < SERVICE_ACTION_TIMEOUT_MS) {
await sleep(SERVICE_ACTION_POLL_INTERVAL_MS);
const response = await TachyonShellMethods.serviceActionStatus(jobId);
if (!response.success) {
return response;
}
if (response.data.running) {
continue;
}
return response;
}
return {
success: false,
error: _('Operation timed out'),
} as Tachyon.MethodFailureResponse;
},
latencyTestStart: async (
latencyType: Tachyon.LatencyActionState['latency_type'],
section: string,
tag: string,
timeout?: string,
) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [
Tachyon.AvailableMethods.LATENCY_TEST_ASYNC,
latencyType,
section,
tag,
...(timeout ? [timeout] : []),
],
timeout: UI_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseUiActionStartResult(response);
if (
(response.code ?? 0) !== 0 ||
!parsedResponse?.success ||
!parsedResponse.job_id
) {
return uiActionFailure(
response,
parsedResponse,
_('Latency test failed'),
);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.UiActionStartResult>;
},
latencyTestStatus: async (jobId: string) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.LATENCY_TEST_STATUS, jobId],
timeout: UI_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseLatencyActionState(response);
if ((response.code ?? 0) !== 0 || !parsedResponse) {
return uiActionFailure(
response,
parsedResponse,
_('Latency test failed'),
);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.LatencyActionState>;
},
waitLatencyTestJob: async (jobId: string, startedAt = Date.now()) => {
const transientRpc = createTransientRpcGraceTracker(
UI_ACTION_TRANSIENT_RPC_GRACE_MS,
);
while (Date.now() - startedAt < LATENCY_TEST_TIMEOUT_MS) {
await sleep(LATENCY_TEST_POLL_INTERVAL_MS);
const response = await TachyonShellMethods.latencyTestStatus(jobId);
if (!response.success) {
if (transientRpc.shouldContinue(response.error)) {
continue;
}
return response;
}
transientRpc.reset();
if (response.data.running) {
continue;
}
return response;
}
return {
success: false,
error: _('Operation timed out'),
} as Tachyon.MethodFailureResponse;
},
uiActionAck: async (
kind: 'service' | 'latency' | 'component' | 'subscription',
jobId: string,
) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.UI_ACTION_ACK, kind, jobId],
timeout: UI_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseUiActionStartResult(response);
if ((response.code ?? 0) !== 0 || !parsedResponse?.success) {
return uiActionFailure(response, parsedResponse);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.UiActionStartResult>;
},
componentActionStart: async (
component: Tachyon.ComponentName,
action: Tachyon.ComponentAction,
) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseComponentActionStartResult(response);
if (
(response.code ?? 0) !== 0 ||
!parsedResponse?.success ||
!parsedResponse.job_id
) {
return componentActionFailure(response, parsedResponse);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.ComponentActionStartResult>;
},
componentActionStatus: async (jobId: string) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.COMPONENT_ACTION_STATUS, jobId],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseComponentActionResult(response);
if ((response.code ?? 0) !== 0 || !parsedResponse) {
return componentActionFailure(response, parsedResponse);
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.ComponentActionResult>;
},
componentUpdateCheckCache: async () =>
callBaseMethod<Tachyon.ComponentUpdateCheckCache>(
Tachyon.AvailableMethods.COMPONENT_UPDATE_CHECK_CACHE,
),
waitComponentActionJob: async (
jobId: string,
component: Tachyon.ComponentName,
action: Tachyon.ComponentAction,
expectedLatestVersion?: string,
) => {
let selfUpdateVersionMatchedAt = 0;
let lastStatusRefreshAt = 0;
const transientRpc = createTransientRpcGraceTracker(
COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS,
);
while (true) {
await sleep(COMPONENT_ACTION_POLL_INTERVAL_MS);
const stateResponse = await readComponentActionState(jobId);
if (stateResponse) {
if (!stateResponse.running) {
transientRpc.reset();
return {
success: true,
data: stateResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.ComponentActionResult>;
}
if (
Date.now() - lastStatusRefreshAt <
COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS
) {
continue;
}
}
lastStatusRefreshAt = Date.now();
const statusResponse = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.COMPONENT_ACTION_STATUS, jobId],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseComponentActionResult(statusResponse);
if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) {
if (stateResponse?.running) {
transientRpc.reset();
continue;
}
if (await isComponentActionStillRunning(jobId, component, action)) {
transientRpc.reset();
continue;
}
const failure = componentActionFailure(statusResponse, parsedResponse);
if (transientRpc.shouldContinue(failure.error)) {
continue;
}
if (component === 'tachyon' && action === 'install') {
const installedVersion = expectedLatestVersion
? await readTachyonVersion()
: '';
if (
expectedLatestVersion &&
installedVersion === expectedLatestVersion
) {
if (!selfUpdateVersionMatchedAt) {
selfUpdateVersionMatchedAt = Date.now();
}
if (
Date.now() - selfUpdateVersionMatchedAt >=
COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS
) {
return {
success: true,
data: {
success: true,
component,
action,
message: translate('Tachyon has been installed'),
current_version: installedVersion,
latest_version: expectedLatestVersion,
changed: true,
status: 'latest',
},
} as Tachyon.MethodSuccessResponse<Tachyon.ComponentActionResult>;
}
}
continue;
}
return failure;
}
transientRpc.reset();
if (parsedResponse.running) {
continue;
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.ComponentActionResult>;
}
},
subscriptionUpdateStart: async (section?: string, sourceIndex?: number) => {
const startArgs = [
Tachyon.AvailableMethods.SUBSCRIPTION_UPDATE_ASYNC,
...(section ? [section] : []),
...(section && sourceIndex !== undefined ? [String(sourceIndex)] : []),
];
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: startArgs,
timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS,
});
const parsedResponse = parseSubscriptionUpdateStartResult(response);
if (
(response.code ?? 0) !== 0 ||
!parsedResponse?.success ||
!parsedResponse.job_id
) {
return {
success: false,
error:
parsedResponse?.message ||
response.stderr ||
_('Subscription update failed'),
} as Tachyon.MethodFailureResponse;
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.SubscriptionUpdateStartResult>;
},
subscriptionUpdateStatus: async (jobId: string) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.SUBSCRIPTION_UPDATE_STATUS, jobId],
timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS,
});
const parsedResponse = parseSubscriptionUpdateJobState(response);
if ((response.code ?? 0) !== 0 || !parsedResponse) {
return {
success: false,
error: response.stderr || _('Subscription update failed'),
} as Tachyon.MethodFailureResponse;
}
return {
success: true,
data: parsedResponse,
} as Tachyon.MethodSuccessResponse<Tachyon.SubscriptionUpdateJobState>;
},
waitSubscriptionUpdateJob: async (jobId: string) => {
const transientRpc = createTransientRpcGraceTracker(
UI_ACTION_TRANSIENT_RPC_GRACE_MS,
);
while (true) {
await sleep(SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS);
const response = await TachyonShellMethods.subscriptionUpdateStatus(jobId);
if (!response.success) {
if (transientRpc.shouldContinue(response.error)) {
continue;
}
return response;
}
transientRpc.reset();
if (response.data.running) {
continue;
}
return response;
}
},
};
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
}));
vi.mock('../../../../helpers', () => ({
executeShellCommand: mocks.executeShellCommand,
}));
import { Tachyon } from '../../../types';
import { callBaseMethod } from '../callBaseMethod';
describe('callBaseMethod', () => {
beforeEach(() => {
mocks.executeShellCommand.mockReset();
});
it('fails on non-zero exit code even when stdout is present by default', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: '{"ok":false}',
stderr: 'command failed',
code: 1,
});
const response = await callBaseMethod(
Tachyon.AvailableMethods.CHECK_DNS_AVAILABLE,
);
expect(response).toEqual({
success: false,
error: 'command failed',
});
});
it('can return stdout for diagnostic commands with non-zero exit code', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: 'Global check run\n[FAIL] Sing-box process running',
stderr: '',
code: 1,
});
const response = await callBaseMethod(
Tachyon.AvailableMethods.GLOBAL_CHECK,
[],
'/usr/bin/tachyon',
{ allowNonZeroWithStdout: true },
);
expect(response).toEqual({
success: true,
data: 'Global check run\n[FAIL] Sing-box process running',
});
});
it('passes a custom timeout to long-running commands', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: '{"ok":true}',
stderr: '',
code: 0,
});
await callBaseMethod(
Tachyon.AvailableMethods.GLOBAL_CHECK,
[],
'/usr/bin/tachyon',
{ timeout: 60000 },
);
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
command: '/usr/bin/tachyon',
args: ['global_check'],
timeout: 60000,
});
});
});
@@ -0,0 +1,347 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
fsRead: vi.fn(),
}));
vi.mock('../../../../helpers', () => ({
executeShellCommand: mocks.executeShellCommand,
}));
import { TachyonShellMethods } from '../index';
describe('TachyonShellMethods.componentAction', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.executeShellCommand.mockReset();
mocks.fsRead.mockReset();
vi.stubGlobal('fs', {
read: mocks.fsRead,
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('does not fail Tachyon self-update when status polling disappears after package replacement', async () => {
mocks.fsRead.mockRejectedValue(new Error('Access denied'));
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'component_action_status') {
return Promise.resolve({
stdout: '',
stderr: 'Unknown command',
code: 1,
});
}
if (args[0] === 'show_version') {
return Promise.resolve({
stdout: '0.7.17.11\n',
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitComponentActionJob(
'job-1',
'tachyon',
'install',
'0.7.17.11',
);
await vi.advanceTimersByTimeAsync(33000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
component: 'tachyon',
action: 'install',
message: 'Tachyon has been installed',
current_version: '0.7.17.11',
latest_version: '0.7.17.11',
changed: true,
status: 'latest',
},
});
});
it('returns the backend component action start error message', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: JSON.stringify({
success: false,
message: 'Another component action is already running',
}),
stderr: '',
code: 1,
});
await expect(
TachyonShellMethods.componentActionStart('zapret', 'install'),
).resolves.toEqual({
success: false,
error: 'Another component action is already running',
});
});
it('keeps following a component job after the former browser-side wait timeout while the backend reports it running', async () => {
let stateReads = 0;
mocks.fsRead.mockImplementation(() =>
Promise.resolve(
JSON.stringify(
stateReads++ < 401
? {
success: true,
running: true,
component: 'sing_box',
action: 'check_update',
message: 'Component action is running',
job_id: 'job-1',
}
: {
success: true,
running: false,
component: 'sing_box',
action: 'check_update',
message: 'sing-box is up to date',
current_version: '1.13.12-extended-2.3.2',
latest_version: '1.13.12-extended-2.3.2',
changed: false,
status: 'latest',
},
),
),
);
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'component_action_status') {
return Promise.resolve({
stdout: JSON.stringify({
success: true,
running: true,
component: 'sing_box',
action: 'check_update',
message: 'Component action is running',
job_id: 'job-1',
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitComponentActionJob(
'job-1',
'sing_box',
'check_update',
);
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 3000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
component: 'sing_box',
action: 'check_update',
message: 'sing-box is up to date',
current_version: '1.13.12-extended-2.3.2',
latest_version: '1.13.12-extended-2.3.2',
changed: false,
status: 'latest',
},
});
});
it('keeps waiting when status polling briefly fails but UI state still reports the job running', async () => {
mocks.fsRead
.mockRejectedValueOnce(new Error('State is temporarily unavailable'))
.mockResolvedValueOnce(
JSON.stringify({
success: true,
running: false,
component: 'sing_box',
action: 'install_extended',
message: 'sing-box-extended has been installed',
current_version: '1.13.12-extended-2.3.2',
latest_version: '1.13.12-extended-2.3.2',
changed: true,
status: 'latest',
}),
);
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'component_action_status') {
return Promise.resolve({
stdout: '',
stderr: 'Component action job was not found',
code: 1,
});
}
if (args[0] === 'get_ui_state') {
return Promise.resolve({
stdout: JSON.stringify({
service: {
tachyon: {
running: 0,
enabled: 1,
status: 'stopped but enabled',
dns_configured: 0,
},
sing_box: {
running: 0,
enabled: 1,
status: 'stopped but enabled',
},
},
capabilities: {
sing_box_extended: 0,
sing_box_tiny: 1,
sing_box_compressed: 0,
sing_box_tailscale: 0,
zapret_installed: 1,
zapret2_installed: 1,
byedpi_installed: 0,
server_inbounds_enabled_count: 0,
},
actions: {
service: [],
latency: [],
component: [
{
success: true,
running: true,
component: 'sing_box',
action: 'install_extended',
message: 'Component action is running',
job_id: 'job-1',
},
],
subscription: [],
},
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitComponentActionJob(
'job-1',
'sing_box',
'install_extended',
);
await vi.advanceTimersByTimeAsync(3000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
component: 'sing_box',
action: 'install_extended',
message: 'sing-box-extended has been installed',
current_version: '1.13.12-extended-2.3.2',
latest_version: '1.13.12-extended-2.3.2',
changed: true,
status: 'latest',
},
});
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
command: '/usr/bin/tachyon',
args: ['get_ui_state'],
timeout: 3000,
});
});
it('keeps waiting through a transient RPC reply loss until the action state settles', async () => {
mocks.fsRead
.mockRejectedValueOnce(new Error('State is temporarily unavailable'))
.mockResolvedValueOnce(
JSON.stringify({
success: true,
running: false,
component: 'zapret',
action: 'check_update',
message: 'zapret is up to date',
current_version: '70.2',
latest_version: '70.2',
changed: false,
status: 'latest',
}),
);
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'component_action_status') {
return Promise.resolve({
stdout: '',
stderr: 'No related RPC reply',
code: 1,
});
}
if (args[0] === 'get_ui_state') {
return Promise.resolve({
stdout: '',
stderr: 'No related RPC reply',
code: 1,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitComponentActionJob(
'job-1',
'zapret',
'check_update',
);
await vi.advanceTimersByTimeAsync(3000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
component: 'zapret',
action: 'check_update',
message: 'zapret is up to date',
current_version: '70.2',
latest_version: '70.2',
changed: false,
status: 'latest',
},
});
});
});
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
}));
vi.mock('../../../../helpers', () => ({
executeShellCommand: mocks.executeShellCommand,
}));
import { TachyonShellMethods } from '../index';
describe('TachyonShellMethods.latencyAction', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.executeShellCommand.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps waiting through a transient RPC reply loss', async () => {
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'latency_test_status') {
if (mocks.executeShellCommand.mock.calls.length === 1) {
return Promise.resolve({
stdout: '',
stderr: 'No related RPC reply',
code: 1,
});
}
return Promise.resolve({
stdout: JSON.stringify({
success: true,
running: false,
message: 'Latency test completed',
section: 'main',
tag: 'proxy-1',
exit_code: 0,
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitLatencyTestJob('job-1');
await vi.advanceTimersByTimeAsync(2000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
message: 'Latency test completed',
section: 'main',
tag: 'proxy-1',
exit_code: 0,
},
});
});
it('passes an optional timeout to latency jobs', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: JSON.stringify({ success: true, job_id: 'latency-1' }),
stderr: '',
code: 0,
});
await TachyonShellMethods.latencyTestStart(
'proxy',
'AWG',
'AWG-out',
'10000',
);
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
command: '/usr/bin/tachyon',
args: ['latency_test_async', 'proxy', 'AWG', 'AWG-out', '10000'],
timeout: 15000,
});
});
});
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
}));
vi.mock('../../../../helpers', () => ({
executeShellCommand: mocks.executeShellCommand,
}));
import { TachyonShellMethods } from '../index';
describe('TachyonShellMethods.serviceAction', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.executeShellCommand.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps failed finished service state available to low-level waiters', async () => {
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'service_action_status') {
return Promise.resolve({
stdout: JSON.stringify({
success: false,
running: false,
kind: 'service',
action: 'restart',
message: 'Service restart failed',
exit_code: 1,
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitServiceActionJob('job-1');
await vi.advanceTimersByTimeAsync(1000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: false,
running: false,
kind: 'service',
action: 'restart',
message: 'Service restart failed',
exit_code: 1,
},
});
});
it('returns the backend service action start error message', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: JSON.stringify({
success: false,
message: 'Another service action is already running',
}),
stderr: '',
code: 1,
});
await expect(
TachyonShellMethods.serviceActionStart('restart'),
).resolves.toEqual({
success: false,
error: 'Another service action is already running',
});
});
});
@@ -0,0 +1,258 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
executeShellCommand: vi.fn(),
}));
vi.mock('../../../../helpers', () => ({
executeShellCommand: mocks.executeShellCommand,
}));
import { TachyonShellMethods } from '../index';
describe('TachyonShellMethods.subscriptionUpdate', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.executeShellCommand.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('starts a targeted background subscription update and waits for its state', async () => {
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'subscription_update_async') {
return Promise.resolve({
stdout: JSON.stringify({
success: true,
job_id: 'job-1',
message: 'Subscription update started',
}),
stderr: '',
code: 0,
});
}
if (args[0] === 'subscription_update_status') {
return Promise.resolve({
stdout: JSON.stringify({
success: true,
running: false,
message: 'Subscription update completed',
exit_code: 0,
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
await expect(
TachyonShellMethods.subscriptionUpdateStart('main'),
).resolves.toEqual({
success: true,
data: {
success: true,
job_id: 'job-1',
message: 'Subscription update started',
},
});
const responsePromise =
TachyonShellMethods.waitSubscriptionUpdateJob('job-1');
await vi.advanceTimersByTimeAsync(1500);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
message: 'Subscription update completed',
exit_code: 0,
},
});
expect(mocks.executeShellCommand).toHaveBeenNthCalledWith(1, {
command: '/usr/bin/tachyon',
args: ['subscription_update_async', 'main'],
timeout: 15000,
});
expect(mocks.executeShellCommand).toHaveBeenNthCalledWith(2, {
command: '/usr/bin/tachyon',
args: ['subscription_update_status', 'job-1'],
timeout: 15000,
});
});
it('returns the backend subscription start error message', async () => {
mocks.executeShellCommand.mockResolvedValue({
stdout: JSON.stringify({
success: false,
message: 'Subscription update is already running',
}),
stderr: '',
code: 1,
});
await expect(
TachyonShellMethods.subscriptionUpdateStart('main'),
).resolves.toEqual({
success: false,
error: 'Subscription update is already running',
});
});
it('returns failed finished job state from the low-level waiter for UI restoration', async () => {
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'subscription_update_status') {
return Promise.resolve({
stdout: JSON.stringify({
success: false,
running: false,
message: 'Failed to download subscriptions',
section: 'main',
source_index: '',
exit_code: 1,
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise =
TachyonShellMethods.waitSubscriptionUpdateJob('job-1');
await vi.advanceTimersByTimeAsync(1500);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: false,
running: false,
message: 'Failed to download subscriptions',
section: 'main',
source_index: '',
exit_code: 1,
},
});
});
it('keeps following a subscription job after the former browser-side wait timeout while the backend reports it running', async () => {
let statusCalls = 0;
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'subscription_update_status') {
return Promise.resolve({
stdout: JSON.stringify(
statusCalls++ < 401
? {
success: true,
running: true,
message: 'Subscription update is running',
section: 'main',
source_index: '',
exit_code: null,
}
: {
success: true,
running: false,
message: 'Subscription update completed',
section: 'main',
source_index: '',
exit_code: 0,
},
),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise =
TachyonShellMethods.waitSubscriptionUpdateJob('job-1');
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 3000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
message: 'Subscription update completed',
section: 'main',
source_index: '',
exit_code: 0,
},
});
});
it('keeps waiting through a transient RPC reply loss', async () => {
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'subscription_update_status') {
if (mocks.executeShellCommand.mock.calls.length === 1) {
return Promise.resolve({
stdout: '',
stderr: 'No related RPC reply',
code: 1,
});
}
return Promise.resolve({
stdout: JSON.stringify({
success: true,
running: false,
message: 'Subscription update completed',
section: 'main',
source_index: '',
exit_code: 0,
}),
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise =
TachyonShellMethods.waitSubscriptionUpdateJob('job-1');
await vi.advanceTimersByTimeAsync(3000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
message: 'Subscription update completed',
section: 'main',
source_index: '',
exit_code: 0,
},
});
});
});
+30
View File
@@ -0,0 +1,30 @@
const RESERVED_RUNTIME_TAGS = new Set([
'dns-server',
'fakeip-server',
'bootstrap-dns-server',
'fakeip-dns-rule-tag',
'fakeip-ruleset-dns-rule-tag',
'service-fakeip-dns-rule-tag',
'tproxy-in',
'tproxy6-in',
'dns-in',
'service-mixed-in',
'direct-out',
'bypass-out',
]);
export function allocateRuntimeTag(base: string, postfix: string): string {
let suffix = 1;
let candidate = `${base}-${postfix}`;
while (RESERVED_RUNTIME_TAGS.has(candidate)) {
candidate = `${base}-${postfix}-${suffix}`;
suffix += 1;
}
return candidate;
}
export function getOutboundTagBySection(sectionName: string): string {
return allocateRuntimeTag(sectionName, 'out');
}
@@ -0,0 +1,118 @@
import { TabServiceInstance } from './tab.service';
import { store } from './store.service';
import { logger } from './logger.service';
import { TachyonLogWatcher } from './tachyonLogWatcher.service';
import {
getTachyonLogNotification,
LogNotificationDeduper,
TachyonLogNotification,
} from './logNotificationDeduper.service';
import { TachyonShellMethods } from '../methods';
import {
registerRuntimeStateResumeRefresh,
startRuntimeUiStatePolling,
} from './runtimeUiState.service';
type CoreServiceOptions = {
waitForLogWatcherStart?: () => Promise<unknown>;
logWatcherStartDelayMs?: number;
};
const LOG_WATCHER_INTERVAL_MS = 10000;
const LOG_WATCHER_START_DELAY_MS = 5000;
function componentDisplayName(component: string) {
const names: Record<string, string> = {
tachyon: 'Tachyon',
sing_box: 'sing-box',
zapret: 'Zapret',
zapret2: 'Zapret2',
byedpi: 'ByeDPI',
};
return names[component] || component;
}
function showLogNotification(notification: TachyonLogNotification) {
if (notification.kind === 'component-update') {
const message = _('New version %s is available for %s')
.replace('%s', notification.version)
.replace('%s', componentDisplayName(notification.component));
ui.addNotification(
_('Component update available'),
E('div', {}, message),
'warning',
'fkp-component-update-notification',
);
return;
}
ui.addNotification(
_('Tachyon Error'),
E('div', {}, notification.line),
'error',
'fkp-log-error-notification',
);
}
export function coreService(options: CoreServiceOptions = {}) {
TabServiceInstance.onChange((activeId, tabs) => {
logger.info('[TAB]', activeId);
store.set({
tabService: {
current: activeId || '',
all: tabs.map((tab) => tab.id),
},
});
});
const watcher = TachyonLogWatcher.getInstance();
const logNotificationDeduper = new LogNotificationDeduper();
watcher.init(
async () => {
const logs = await TachyonShellMethods.checkLogs();
if (logs.success) {
return logs.data as string;
}
return '';
},
{
intervalMs: LOG_WATCHER_INTERVAL_MS,
onNewLog: (line) => {
if (logNotificationDeduper.shouldNotify(line)) {
const notification = getTachyonLogNotification(line);
if (notification) {
showLogNotification(notification);
}
}
},
},
);
const startWatcher = async () => {
if (options.waitForLogWatcherStart) {
await Promise.resolve()
.then(() => options.waitForLogWatcherStart?.())
.catch(() => null);
}
watcher.start();
};
const scheduleStartWatcher = () =>
window.setTimeout(() => {
void startWatcher();
}, options.logWatcherStartDelayMs ?? LOG_WATCHER_START_DELAY_MS);
if (typeof window !== 'undefined') {
scheduleStartWatcher();
} else {
void startWatcher();
}
registerRuntimeStateResumeRefresh();
startRuntimeUiStatePolling();
}
@@ -0,0 +1,8 @@
export * from './tab.service';
export * from './core.service';
export * from './socket.service';
export * from './store.service';
export * from './logger.service';
export * from './runtimeUiState.service';
export * from './uiActionNotification.service';
export * from './localActionOverlay.service';
@@ -0,0 +1,61 @@
import type { StoreType } from './store.service';
type UpdatesActionKey = keyof StoreType['updatesActions'];
type ServiceActionKey = 'restart' | 'start' | 'stop';
const componentActions = new Set<UpdatesActionKey>();
const subscriptionSections = new Set<string>();
const latencySections = new Set<string>();
const serviceActions = new Set<ServiceActionKey>();
function setMembership<T>(set: Set<T>, value: T, active: boolean) {
if (active) {
set.add(value);
} else {
set.delete(value);
}
}
export function setLocalComponentAction(
action: UpdatesActionKey,
active: boolean,
) {
setMembership(componentActions, action, active);
}
export function setLocalSubscriptionAction(section: string, active: boolean) {
if (section) {
setMembership(subscriptionSections, section, active);
}
}
export function setLocalLatencyAction(section: string, active: boolean) {
if (section) {
setMembership(latencySections, section, active);
}
}
export function setLocalServiceAction(
action: keyof StoreType['diagnosticsActions'],
active: boolean,
) {
if (action === 'restart' || action === 'start' || action === 'stop') {
setMembership(serviceActions, action, active);
}
}
export function getLocalActionOverlay() {
return {
componentActions: new Set(componentActions),
subscriptionSections: new Set(subscriptionSections),
latencySections: new Set(latencySections),
serviceActions: new Set(serviceActions),
};
}
export function clearLocalActionOverlay() {
componentActions.clear();
subscriptionSections.clear();
latencySections.clear();
serviceActions.clear();
}
@@ -0,0 +1,114 @@
const LOG_NOTIFICATION_STORAGE_KEY = 'tachyon:shown-log-error-notifications:v1';
const MAX_STORED_LOG_NOTIFICATIONS = 500;
export type TachyonLogNotification =
| { kind: 'error'; line: string }
| {
kind: 'component-update';
line: string;
component: string;
version: string;
};
function getSessionStorage(): Storage | null {
if (typeof window === 'undefined') {
return null;
}
try {
return window.sessionStorage;
} catch {
return null;
}
}
function readStoredKeys(storage: Storage | null): string[] {
if (!storage) {
return [];
}
try {
const parsed = JSON.parse(
storage.getItem(LOG_NOTIFICATION_STORAGE_KEY) || '[]',
);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: [];
} catch {
return [];
}
}
function writeStoredKeys(storage: Storage | null, keys: string[]) {
if (!storage) {
return;
}
try {
storage.setItem(
LOG_NOTIFICATION_STORAGE_KEY,
JSON.stringify(keys.slice(-MAX_STORED_LOG_NOTIFICATIONS)),
);
} catch {
// Notifications are still deduped in-memory when sessionStorage is blocked.
}
}
export function isErrorLogLine(line: string) {
const lower = line.toLowerCase();
return lower.includes('[error]') || lower.includes('[fatal]');
}
export function getTachyonLogNotification(
line: string,
): TachyonLogNotification | null {
if (isErrorLogLine(line)) {
return { kind: 'error', line };
}
const update = line.match(
/\[component-update\]\s+(tachyon|sing_box|zapret|zapret2|byedpi)\s+(\S+)/i,
);
if (!update) {
return null;
}
return {
kind: 'component-update',
line,
component: update[1].toLowerCase(),
version: update[2],
};
}
export function getLogNotificationKey(line: string) {
return line.trim();
}
export class LogNotificationDeduper {
private readonly storage: Storage | null;
private readonly seenKeys: Set<string>;
constructor(storage: Storage | null = getSessionStorage()) {
this.storage = storage;
this.seenKeys = new Set(readStoredKeys(storage));
}
shouldNotify(line: string) {
if (!getTachyonLogNotification(line)) {
return false;
}
const key = getLogNotificationKey(line);
if (!key || this.seenKeys.has(key)) {
return false;
}
this.seenKeys.add(key);
writeStoredKeys(this.storage, Array.from(this.seenKeys));
return true;
}
}
@@ -0,0 +1,66 @@
import { downloadAsTxt } from '../../helpers/downloadAsTxt';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
class Logger {
private logs: string[] = [];
private readonly levels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
private format(level: LogLevel, ...args: unknown[]): string {
return `[${level.toUpperCase()}] ${args.join(' ')}`;
}
private push(level: LogLevel, ...args: unknown[]): void {
if (!this.levels.includes(level)) level = 'info';
const message = this.format(level, ...args);
this.logs.push(message);
switch (level) {
case 'error':
console.error(message);
break;
case 'warn':
console.warn(message);
break;
case 'info':
console.info(message);
break;
default:
console.log(message);
}
}
debug(...args: unknown[]): void {
this.push('debug', ...args);
}
info(...args: unknown[]): void {
this.push('info', ...args);
}
warn(...args: unknown[]): void {
this.push('warn', ...args);
}
error(...args: unknown[]): void {
this.push('error', ...args);
}
clear(): void {
this.logs = [];
}
getLogs(): string {
return this.logs.join('\n');
}
download(filename = 'logs.txt'): void {
if (typeof document === 'undefined') {
console.warn('Logger.download() доступен только в браузере');
return;
}
downloadAsTxt(this.getLogs(), filename);
}
}
export const logger = new Logger();
@@ -0,0 +1,166 @@
import { TachyonShellMethods } from '../methods';
import { Tachyon } from '../types';
import { logger } from './logger.service';
import { applyUiStateToStore } from './uiState.service';
const RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS = 500;
const RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS = 1000;
const RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS = 500;
type RuntimeUiStateListener = (uiState: Tachyon.UiState) => void;
let runtimeUiStateRefreshPromise: Promise<Tachyon.UiState | undefined> | null =
null;
let lastRuntimeUiStateRefreshAt = 0;
let lastRuntimeUiState: Tachyon.UiState | undefined;
let runtimeStateResumeRefreshRegistered = false;
let runtimeStatePollTimer: ReturnType<typeof setTimeout> | null = null;
let runtimeStatePollingStarted = false;
let runtimeStateHasRunningAction = false;
const runtimeUiStateListeners = new Set<RuntimeUiStateListener>();
function isDocumentVisible() {
return (
typeof document === 'undefined' ||
!document.visibilityState ||
document.visibilityState === 'visible'
);
}
function hasRunningAction(uiState: Tachyon.UiState) {
return Object.values(uiState.actions).some((actions) =>
actions.some((action) => action.running),
);
}
function getNextPollDelay() {
return runtimeStateHasRunningAction
? RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS
: RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS;
}
function scheduleRuntimeUiStatePoll(delay = getNextPollDelay()) {
if (
!runtimeStatePollingStarted ||
runtimeStatePollTimer ||
typeof window === 'undefined'
) {
return;
}
runtimeStatePollTimer = window.setTimeout(() => {
runtimeStatePollTimer = null;
void refreshRuntimeUiState()
.catch(() => undefined)
.finally(() => {
scheduleRuntimeUiStatePoll();
});
}, delay);
}
function notifyRuntimeUiStateListeners(uiState: Tachyon.UiState) {
for (const listener of runtimeUiStateListeners) {
try {
listener(uiState);
} catch (error) {
logger.error('[RUNTIME_UI_STATE]', 'listener failed', error);
}
}
}
export async function refreshRuntimeUiState({
force = false,
}: { force?: boolean } = {}): Promise<Tachyon.UiState | undefined> {
if (!isDocumentVisible()) {
return undefined;
}
if (runtimeUiStateRefreshPromise) {
return runtimeUiStateRefreshPromise;
}
const now = Date.now();
if (
!force &&
now - lastRuntimeUiStateRefreshAt < RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS
) {
return undefined;
}
lastRuntimeUiStateRefreshAt = now;
const promise = TachyonShellMethods.getUiState()
.then((response) => {
if (!response.success) {
return undefined;
}
applyUiStateToStore(response.data);
lastRuntimeUiState = response.data;
runtimeStateHasRunningAction = hasRunningAction(response.data);
notifyRuntimeUiStateListeners(response.data);
return response.data;
})
.catch((error) => {
logger.error('[RUNTIME_UI_STATE]', 'refresh failed', error);
return undefined;
})
.finally(() => {
if (runtimeUiStateRefreshPromise === promise) {
runtimeUiStateRefreshPromise = null;
}
});
runtimeUiStateRefreshPromise = promise;
return runtimeUiStateRefreshPromise;
}
export function subscribeRuntimeUiState(listener: RuntimeUiStateListener) {
runtimeUiStateListeners.add(listener);
if (lastRuntimeUiState) {
try {
listener(lastRuntimeUiState);
} catch (error) {
logger.error('[RUNTIME_UI_STATE]', 'listener failed', error);
}
}
return () => {
runtimeUiStateListeners.delete(listener);
};
}
export function getCachedRuntimeUiState() {
return lastRuntimeUiState;
}
export function registerRuntimeStateResumeRefresh() {
if (runtimeStateResumeRefreshRegistered || typeof window === 'undefined') {
return;
}
runtimeStateResumeRefreshRegistered = true;
const refreshOnResume = () => {
if (!isDocumentVisible()) {
return;
}
void refreshRuntimeUiState({ force: true });
};
document.addEventListener('visibilitychange', refreshOnResume);
window.addEventListener('pageshow', refreshOnResume);
window.addEventListener('focus', refreshOnResume);
}
export function startRuntimeUiStatePolling() {
if (runtimeStatePollingStarted || typeof window === 'undefined') {
return;
}
runtimeStatePollingStarted = true;
void refreshRuntimeUiState({ force: true }).finally(() => {
scheduleRuntimeUiStatePoll();
});
}
@@ -0,0 +1,167 @@
import { logger } from './logger.service';
// eslint-disable-next-line
type Listener = (data: any) => void;
type ErrorListener = (error: Event | string) => void;
class SocketManager {
private static instance: SocketManager;
private sockets = new Map<string, WebSocket>();
private listeners = new Map<string, Set<Listener>>();
private connected = new Map<string, boolean>();
private errorListeners = new Map<string, Set<ErrorListener>>();
private constructor() {}
static getInstance(): SocketManager {
if (!SocketManager.instance) {
SocketManager.instance = new SocketManager();
}
return SocketManager.instance;
}
resetAll(): void {
for (const [url, ws] of this.sockets.entries()) {
try {
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
ws.close();
}
} catch (err) {
logger.error(
'[SOCKET]',
`resetAll: failed to close socket ${url}`,
err,
);
}
}
this.sockets.clear();
this.listeners.clear();
this.errorListeners.clear();
this.connected.clear();
logger.info('[SOCKET]', 'All connections and state have been reset.');
}
connect(url: string): void {
if (this.sockets.has(url)) return;
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch (err) {
logger.error(
'[SOCKET]',
`failed to construct WebSocket for ${url}:`,
err,
);
this.triggerError(url, err instanceof Event ? err : String(err));
return;
}
this.sockets.set(url, ws);
this.connected.set(url, false);
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);
logger.info('[SOCKET]', 'Connected to', url);
});
ws.addEventListener('message', (event) => {
const handlers = this.listeners.get(url);
if (handlers) {
for (const handler of handlers) {
try {
handler(event.data);
} catch (err) {
logger.error('[SOCKET]', `Handler error for ${url}:`, err);
}
}
}
});
ws.addEventListener('close', () => {
this.connected.set(url, false);
logger.warn('[SOCKET]', `Disconnected: ${url}`);
this.triggerError(url, 'Connection closed');
});
ws.addEventListener('error', (err) => {
logger.error('[SOCKET]', `Socket error for ${url}:`, err);
this.triggerError(url, err);
});
}
subscribe(url: string, listener: Listener, onError?: ErrorListener): void {
if (!this.errorListeners.has(url)) {
this.errorListeners.set(url, new Set());
}
if (onError) {
this.errorListeners.get(url)?.add(onError);
}
if (!this.sockets.has(url)) {
this.connect(url);
}
if (!this.listeners.has(url)) {
this.listeners.set(url, new Set());
}
this.listeners.get(url)?.add(listener);
}
unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void {
this.listeners.get(url)?.delete(listener);
if (onError) {
this.errorListeners.get(url)?.delete(onError);
}
}
// eslint-disable-next-line
send(url: string, data: any): void {
const ws = this.sockets.get(url);
if (ws && this.connected.get(url)) {
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} else {
logger.warn('[SOCKET]', `Cannot send: not connected to ${url}`);
this.triggerError(url, 'Not connected');
}
}
disconnect(url: string): void {
const ws = this.sockets.get(url);
if (ws) {
ws.close();
this.sockets.delete(url);
this.listeners.delete(url);
this.errorListeners.delete(url);
this.connected.delete(url);
}
}
disconnectAll(): void {
for (const url of this.sockets.keys()) {
this.disconnect(url);
}
}
private triggerError(url: string, err: Event | string): void {
const handlers = this.errorListeners.get(url);
if (handlers) {
for (const cb of handlers) {
try {
cb(err);
} catch (e) {
logger.error('[SOCKET]', `Error handler threw for ${url}:`, e);
}
}
}
}
}
export const socket = SocketManager.getInstance();

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