Rebrand upstream: переименовать forkop в tachyon
This commit is contained in:
@@ -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/
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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' }),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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' })],
|
||||
);
|
||||
}
|
||||
Vendored
+59
@@ -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 {};
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user