Files
NeoTachyon/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts
T

1939 lines
51 KiB
TypeScript

import { onMount, preserveScrollForPage } from '../../../helpers';
import { TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT } from '../../../constants';
import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion';
import { capSetSize } from '../../helpers/capCollectionSize';
import { showToast } from '../../../helpers/showToast';
import {
renderDownloadIcon24,
renderGlobeIcon24,
renderRotateCcwIcon24,
renderSearchIcon24,
renderXIcon24,
} from '../../../icons';
import { renderButton } from '../../../partials';
import { getComponentActionKey } from '../../helpers/getComponentActionKey';
import type { UpdatesActionKey } from '../../helpers/getComponentActionKey';
import { isTransientRpcError } from '../../helpers/isTransientRpcError';
import { isActiveLuciTab } from '../../helpers/isActiveLuciTab';
import { shouldShowLoadingForRestoredAction } from '../../helpers/restoredActionLoading';
import {
formatSingBoxVersion,
normalizeSingBoxVariantFields,
renderSingBoxVariantBadge,
} from '../../helpers/singBoxVariant';
import { shouldApplyCompletedComponentActionResult } from './componentActionCompletion';
import {
shouldPreserveCompletedCheckResultOnNextMount,
shouldExposeCheckResults,
shouldRefreshComponentStateBeforeRender,
shouldResetCheckResultsOnMount,
} from './checkResultLifecycle';
import { describeSameReleaseBuild } from './sameReleaseBuild';
import { TachyonShellMethods } from '../../methods';
import {
logger,
markUiActionOwned,
setLocalComponentAction,
shouldNotifyOwnedUiAction,
store,
StoreType,
} from '../../services';
import { ensureSystemInfo } from '../../services/systemInfo.service';
import {
getCachedRuntimeUiState,
refreshRuntimeUiState,
subscribeRuntimeUiState,
} from '../../services/runtimeUiState.service';
import { Tachyon } from '../../types';
import {
getActiveProgressModalController,
setActiveProgressModalJobId,
showUpdateProgressModal,
} from './partials/renderUpdateProgressModal';
import {
loadHandledJobsFromSession,
safeReloadPage,
saveHandledJobToSession,
} from './sessionJobs';
function getComponentCardTitle(component: Tachyon.ComponentName): string {
switch (component) {
case 'tachyon':
return 'Tachyon';
case 'sing_box':
return 'Sing-box';
case 'zapret':
return 'Zapret';
case 'zapret2':
return 'Zapret2';
case 'byedpi':
return 'ByeDPI';
case 'tailscale':
return 'Tailscale';
default:
return String(component);
}
}
function getComponentCurrentVersion(
component: Tachyon.ComponentName,
): string | undefined {
const sys = store.get().diagnosticsSystemInfo;
switch (component) {
case 'tachyon':
return sys.tachyon_version;
case 'sing_box':
return sys.sing_box_version;
case 'zapret':
return sys.zapret_version;
case 'zapret2':
return sys.zapret2_version;
case 'byedpi':
return sys.byedpi_version;
case 'tailscale':
return sys.tailscale_version;
default:
return undefined;
}
}
type UpdateStatus = StoreType['updatesChecks'][Tachyon.ComponentName]['status'];
interface ComponentActionButton {
key: UpdatesActionKey;
text: string;
icon: () => SVGSVGElement;
component: Tachyon.ComponentName;
action: Tachyon.ComponentAction;
}
interface ComponentCard {
component: Tachyon.ComponentName;
column: 0 | 1;
title: string;
version: string;
latestVersion?: string;
releaseUrl?: string;
repoUrl?: string;
actions: ComponentActionButton[];
badgeNode?: Node | null;
supportsVersions?: boolean;
}
let updatesLifecycleRegistered = false;
let updatesControllerInitialized = false;
let updatesMounted = false;
let updatesMountId = 0;
let pageUnloading = false;
let preserveCheckResultsOnNextMount = false;
let componentUpdateCheckCacheResolved = false;
let componentUpdateCheckCacheSnapshot: Tachyon.ComponentUpdateCheckCache | null =
null;
let componentUpdateCheckCachePromise: Promise<Tachyon.ComponentUpdateCheckCache> | null =
null;
let componentActionStateUnsubscribe: (() => void) | null = null;
let componentActionStateRefreshPromise: Promise<void> | null = null;
const followedComponentJobs = new Set<string>();
const handledComponentJobs = new Set<string>();
for (const savedJob of loadHandledJobsFromSession()) {
handledComponentJobs.add(savedJob);
followedComponentJobs.add(savedJob);
}
capSetSize(handledComponentJobs);
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => {
pageUnloading = true;
});
window.addEventListener('pageshow', () => {
pageUnloading = false;
});
}
function isNotInstalled(version: string | undefined) {
return !version || version === 'not installed';
}
function shouldShowInstallAfterCheck(component: Tachyon.ComponentName) {
const status = getVisibleCheckResult(component)?.status;
return (
status === 'outdated' ||
status === 'dev' ||
status === 'outdated_same_release'
);
}
function getVisibleCheckResult(component: Tachyon.ComponentName) {
if (
!shouldExposeCheckResults({
mounted: updatesMounted,
cacheResolved: componentUpdateCheckCacheResolved,
})
) {
return null;
}
return store.get().updatesChecks[component];
}
function getLatestVersion(component: Tachyon.ComponentName) {
const checkResult = getVisibleCheckResult(component);
if (!checkResult || !shouldShowInstallAfterCheck(component)) {
return undefined;
}
return checkResult.latest_version || undefined;
}
function getGitHubReleaseUrl(component: Tachyon.ComponentName) {
const checkResult = getVisibleCheckResult(component);
if (
!checkResult ||
!shouldShowInstallAfterCheck(component) ||
!checkResult.release_url
) {
return undefined;
}
return checkResult.release_url;
}
function isAnyActionLoading() {
return Object.values(store.get().updatesActions).some((item) => item.loading);
}
function isSystemInfoLoading() {
const systemInfo = store.get().diagnosticsSystemInfo;
return systemInfo.loading || !systemInfo.loaded;
}
function setActionLoading(
action: UpdatesActionKey,
loading: boolean,
local = false,
) {
if (local || !loading) {
setLocalComponentAction(action, loading && local);
}
const updatesActions = store.get().updatesActions;
store.set({
updatesActions: {
...updatesActions,
[action]: { loading },
},
});
}
function beginComponentAction(button: ComponentActionButton) {
if (isAnyActionLoading()) {
return false;
}
setActionLoading(button.key, true, true);
return true;
}
function setCheckResult(
component: Tachyon.ComponentName,
status: UpdateStatus,
latestVersion: string,
releaseUrl: string = '',
currentSha: string = '',
latestSha: string = '',
) {
const updatesChecks = store.get().updatesChecks;
store.set({
updatesChecks: {
...updatesChecks,
[component]: {
status,
latest_version: latestVersion,
release_url: releaseUrl,
...(currentSha ? { current_sha: currentSha } : {}),
...(latestSha ? { latest_sha: latestSha } : {}),
},
},
});
}
function resetCheckResult(component: Tachyon.ComponentName) {
setCheckResult(component, null, '');
}
function applyCachedCheckResults(results: Tachyon.ComponentActionResult[]) {
results.forEach((result) => {
const status = result.status || null;
if (
status === 'latest' ||
status === 'outdated' ||
status === 'dev' ||
status === 'outdated_same_release'
) {
setCheckResult(
result.component,
status,
result.latest_version || '',
result.release_url || '',
result.current_sha || '',
result.latest_sha || '',
);
}
});
}
function loadComponentUpdateCheckCache({ force = false } = {}) {
if (!force && componentUpdateCheckCacheSnapshot) {
return Promise.resolve(componentUpdateCheckCacheSnapshot);
}
if (componentUpdateCheckCachePromise) {
return componentUpdateCheckCachePromise;
}
const promise = TachyonShellMethods.componentUpdateCheckCache()
.then((response) =>
response.success
? response.data
: ({
enabled: false,
results: [],
} satisfies Tachyon.ComponentUpdateCheckCache),
)
.then((cache) => {
componentUpdateCheckCacheSnapshot = cache;
return cache;
})
.finally(() => {
if (componentUpdateCheckCachePromise === promise) {
componentUpdateCheckCachePromise = null;
}
});
componentUpdateCheckCachePromise = promise;
return promise;
}
function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
async function ackComponentActionJob(jobId: string) {
try {
const response = await TachyonShellMethods.uiActionAck('component', jobId);
if (!response.success) {
logger.debug('[UPDATES]', 'component action ack failed', response.error);
}
} catch (error) {
logger.debug('[UPDATES]', 'component action ack failed', error);
}
}
function getExpectedLatestVersionForAction(button: ComponentActionButton) {
if (
button.component !== 'tachyon' ||
(button.action !== 'install' && button.action !== 'reinstall')
) {
return undefined;
}
return (
store.get().updatesChecks[button.component].latest_version || undefined
);
}
function getCheckToastMessage(status: UpdateStatus) {
if (status === 'outdated' || status === 'outdated_same_release') {
return _('Update is available');
}
if (status === 'dev') {
return _('Installed version is newer than release');
}
return _('Latest version is installed');
}
async function refreshSystemInfoAfterMutation() {
await ensureSystemInfo({ force: true, silent: true });
}
function notifyActionProvidersAvailabilityChanged(
systemInfo: StoreType['diagnosticsSystemInfo'],
) {
if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') {
return;
}
window.dispatchEvent(
new CustomEvent(TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT, {
detail: {
zapretInstalled: Boolean(systemInfo.zapret_installed),
zapret2Installed: Boolean(systemInfo.zapret2_installed),
byedpiInstalled: Boolean(systemInfo.byedpi_installed),
},
}),
);
}
const RELOAD_POLL_INTERVAL_MS = 1000;
const RELOAD_POLL_MAX_WAIT_MS = 30000;
async function waitForTachyonResponsive() {
const deadline = Date.now() + RELOAD_POLL_MAX_WAIT_MS;
while (Date.now() < deadline) {
try {
const response = await TachyonShellMethods.getUiState();
if (response.success) {
return true;
}
} catch {
// Backend is restarting — keep polling until it answers again.
}
await new Promise((resolve) =>
setTimeout(resolve, RELOAD_POLL_INTERVAL_MS),
);
}
return false;
}
function reloadPageAfterTachyonUpdate(jobId?: string) {
if (jobId) {
saveHandledJobToSession(jobId);
}
// Reload only after the restarted backend actually answers get_ui_state:
// a fixed delay either reloaded too early (tab hung on stale component
// action state) or wasted seconds on fast routers.
void waitForTachyonResponsive().finally(() => {
safeReloadPage();
});
}
function patchSystemInfoAfterMutation(result: Tachyon.ComponentActionResult) {
const systemInfo = store.get().diagnosticsSystemInfo;
const nextSystemInfo = { ...systemInfo, loading: false, loaded: true };
const version =
result.current_version || result.latest_version || _('unknown');
if (
result.component === 'tachyon' &&
(result.action === 'install' || result.action === 'reinstall')
) {
nextSystemInfo.tachyon_version = version;
}
if (result.component === 'sing_box') {
nextSystemInfo.sing_box_version = version;
if (result.action === 'install_extended') {
nextSystemInfo.sing_box_extended = 1;
nextSystemInfo.sing_box_tiny = 0;
nextSystemInfo.sing_box_compressed = 0;
nextSystemInfo.sing_box_lx = 0;
nextSystemInfo.sing_box_tailscale = 1;
}
if (result.action === 'install_extended_compressed') {
nextSystemInfo.sing_box_extended = 1;
nextSystemInfo.sing_box_tiny = 0;
nextSystemInfo.sing_box_compressed = 1;
nextSystemInfo.sing_box_lx = 0;
nextSystemInfo.sing_box_tailscale = 1;
}
if (result.action === 'install_lx') {
nextSystemInfo.sing_box_extended = 1;
nextSystemInfo.sing_box_tiny = 0;
nextSystemInfo.sing_box_compressed = 0;
nextSystemInfo.sing_box_lx = 1;
nextSystemInfo.sing_box_tailscale = 1;
}
if (result.action === 'install_stable') {
nextSystemInfo.sing_box_extended = 0;
nextSystemInfo.sing_box_tiny = 0;
nextSystemInfo.sing_box_compressed = 0;
nextSystemInfo.sing_box_lx = 0;
nextSystemInfo.sing_box_tailscale = 1;
}
if (result.action === 'install_tiny') {
nextSystemInfo.sing_box_extended = 0;
nextSystemInfo.sing_box_tiny = 1;
nextSystemInfo.sing_box_compressed = 0;
nextSystemInfo.sing_box_lx = 0;
nextSystemInfo.sing_box_tailscale = 0;
}
}
if (result.component === 'zapret') {
nextSystemInfo.providerInfoLoaded = true;
if (result.action === 'remove') {
nextSystemInfo.zapret_installed = 0;
nextSystemInfo.zapret_version = 'not installed';
} else {
nextSystemInfo.zapret_installed = 1;
nextSystemInfo.zapret_version = version;
}
}
if (result.component === 'zapret2') {
nextSystemInfo.providerInfoLoaded = true;
if (result.action === 'remove') {
nextSystemInfo.zapret2_installed = 0;
nextSystemInfo.zapret2_version = 'not installed';
} else {
nextSystemInfo.zapret2_installed = 1;
nextSystemInfo.zapret2_version = version;
}
}
if (result.component === 'byedpi') {
nextSystemInfo.providerInfoLoaded = true;
if (result.action === 'remove') {
nextSystemInfo.byedpi_installed = 0;
nextSystemInfo.byedpi_version = 'not installed';
} else {
nextSystemInfo.byedpi_installed = 1;
nextSystemInfo.byedpi_version = version;
}
}
const normalizedSystemInfo = normalizeSingBoxVariantFields(nextSystemInfo);
store.set({
diagnosticsSystemInfo: normalizedSystemInfo,
});
if (
result.component === 'zapret' ||
result.component === 'zapret2' ||
result.component === 'byedpi'
) {
notifyActionProvidersAvailabilityChanged(normalizedSystemInfo);
}
}
async function applyCompletedComponentAction({
key,
result,
notify,
}: {
key: UpdatesActionKey;
result: Tachyon.ComponentActionResult;
notify: boolean;
}) {
const modalController = getActiveProgressModalController();
if (result.action === 'check_update') {
setActionLoading(key, false);
if (!shouldApplyCompletedComponentActionResult(result, notify)) {
modalController?.completeSuccess(_('Check completed!'));
return;
}
if (
shouldPreserveCompletedCheckResultOnNextMount({
action: result.action,
mounted: updatesMounted,
})
) {
preserveCheckResultsOnNextMount = true;
}
const status = result.status || null;
const hasUpdate =
status === 'outdated' || status === 'outdated_same_release';
if (
status === 'latest' ||
status === 'outdated' ||
status === 'dev' ||
status === 'outdated_same_release'
) {
setCheckResult(
result.component,
status,
result.latest_version || '',
result.release_url || '',
result.current_sha || '',
result.latest_sha || '',
);
modalController?.updateVersions({
currentVersion: getComponentCurrentVersion(result.component),
targetVersion: result.latest_version || '',
currentSha: result.current_sha || '',
targetSha: result.latest_sha || '',
});
}
if (notify) {
showToast(getCheckToastMessage(status), 'success');
}
if (hasUpdate) {
const installButton = getComponentInstallAction(result.component);
modalController?.completeSuccess(getCheckToastMessage(status), {
autoCloseMs: 0,
installText: installButton.text,
onInstall: () => {
void handleComponentAction(installButton);
},
});
} else {
modalController?.completeSuccess(getCheckToastMessage(status));
}
return;
}
if (
result.action === 'install' ||
result.action === 'reinstall' ||
result.action.startsWith('install_')
) {
setCheckResult(result.component, 'latest', result.latest_version || '');
} else {
resetCheckResult(result.component);
}
patchSystemInfoAfterMutation(result);
setActionLoading(key, false);
if (
result.component === 'tachyon' &&
(result.action === 'install' || result.action === 'reinstall')
) {
if (notify && result.message) {
showToast(result.message, 'success', 1200);
}
if (notify) {
if (modalController) {
modalController.completeSuccess(result.message, { reloadPage: true });
} else {
reloadPageAfterTachyonUpdate(result.job_id);
}
} else {
modalController?.completeSuccess(result.message);
}
return;
}
if (notify && result.message) {
showToast(result.message, 'success');
}
modalController?.completeSuccess(result.message);
void refreshSystemInfoAfterMutation();
}
async function completeComponentActionJob(
key: UpdatesActionKey,
jobId: string,
response: Tachyon.MethodResponse<Tachyon.ComponentActionResult>,
) {
if (pageUnloading) {
setActionLoading(key, false);
return;
}
const alreadyHandled = handledComponentJobs.has(jobId);
if (alreadyHandled) {
setActionLoading(key, false);
return;
}
const shouldNotify = shouldNotifyOwnedUiAction('component', jobId);
const modalController = getActiveProgressModalController();
if (!response.success || !response.data.success) {
const message = response.success
? response.data.message || _('Failed to execute')
: response.error || _('Failed to execute');
if (isTransientRpcError(message)) {
setActionLoading(key, false);
void refreshComponentActionState();
return;
}
handledComponentJobs.add(jobId);
capSetSize(handledComponentJobs);
saveHandledJobToSession(jobId);
setActionLoading(key, false);
if (shouldNotify) {
showToast(message, 'error');
}
modalController?.completeError(message);
await ackComponentActionJob(jobId);
return;
}
handledComponentJobs.add(jobId);
capSetSize(handledComponentJobs);
saveHandledJobToSession(jobId);
await ackComponentActionJob(jobId);
await applyCompletedComponentAction({
key,
result: response.data,
notify: shouldNotify,
});
}
async function followComponentActionState(
state: Tachyon.ComponentActionResult,
) {
const jobId = state.job_id;
const key = getComponentActionKey(state.component, state.action);
if (
!jobId ||
!key ||
followedComponentJobs.has(jobId) ||
handledComponentJobs.has(jobId)
) {
return;
}
followedComponentJobs.add(jobId);
if (shouldShowLoadingForRestoredAction(state)) {
setActionLoading(key, true);
if (!getActiveProgressModalController()) {
setActiveProgressModalJobId(jobId);
showUpdateProgressModal({
component: state.component,
action: state.action,
componentTitle: getComponentCardTitle(state.component),
currentVersion: state.current_version,
targetVersion: state.latest_version,
});
}
getActiveProgressModalController()?.startLogTracking(jobId);
}
try {
const response = state.running
? await TachyonShellMethods.waitComponentActionJob(
jobId,
state.component,
state.action,
state.latest_version || undefined,
)
: ({
success: true,
data: state,
} as Tachyon.MethodSuccessResponse<Tachyon.ComponentActionResult>);
await completeComponentActionJob(key, jobId, response);
} catch (error) {
logger.error('[UPDATES]', 'followComponentActionState failed', error);
if (!pageUnloading) {
const message = getErrorMessage(error, _('Failed to execute'));
setActionLoading(key, false);
if (!isTransientRpcError(message)) {
showToast(message, 'error');
getActiveProgressModalController()?.completeError(message);
}
}
} finally {
followedComponentJobs.delete(jobId);
}
}
async function followAlreadyRunningComponentAction(
button: ComponentActionButton,
) {
const uiState = await refreshRuntimeUiState({ force: true });
if (!uiState) {
return false;
}
let state = uiState.actions.component.find(
(item) =>
item.running &&
item.component === button.component &&
item.action === button.action &&
(!item.job_id || !handledComponentJobs.has(item.job_id)),
);
if (!state) {
state = uiState.actions.component.find(
(item) =>
item.running &&
item.component === button.component &&
(!item.job_id || !handledComponentJobs.has(item.job_id)),
);
}
if (!state) {
return false;
}
if (state.job_id) {
markUiActionOwned('component', state.job_id);
}
await followComponentActionState(state);
return true;
}
function isComponentActionAlreadyRunningError(message: string | undefined) {
return Boolean(
message && message.includes('Another component action is already running'),
);
}
function handleComponentUiState(uiState: Tachyon.UiState) {
for (const state of uiState.actions.component || []) {
const jobId = state.job_id;
if (!jobId) {
continue;
}
if (handledComponentJobs.has(jobId) || followedComponentJobs.has(jobId)) {
continue;
}
if (state.running) {
void followComponentActionState(state);
} else {
handledComponentJobs.add(jobId);
capSetSize(handledComponentJobs);
saveHandledJobToSession(jobId);
void ackComponentActionJob(jobId);
}
}
}
async function refreshComponentActionState() {
if (componentActionStateRefreshPromise) {
return componentActionStateRefreshPromise;
}
componentActionStateRefreshPromise = (async () => {
if (!updatesMounted) {
return;
}
const state = await refreshRuntimeUiState({ force: true });
if (!state) {
return;
}
handleComponentUiState(state);
})().finally(() => {
componentActionStateRefreshPromise = null;
});
return componentActionStateRefreshPromise;
}
function startComponentActionStateWatcher() {
if (componentActionStateUnsubscribe) {
return;
}
componentActionStateUnsubscribe = subscribeRuntimeUiState((uiState) => {
if (updatesMounted) {
handleComponentUiState(uiState);
}
});
}
function stopComponentActionStateWatcher() {
if (!componentActionStateUnsubscribe) {
return;
}
componentActionStateUnsubscribe();
componentActionStateUnsubscribe = null;
}
async function handleComponentAction(button: ComponentActionButton) {
if (!beginComponentAction(button)) {
return;
}
const cardTitle = getComponentCardTitle(button.component);
const currentVersion = getComponentCurrentVersion(button.component);
const targetVersion = getExpectedLatestVersionForAction(button);
const checkResult = getVisibleCheckResult(button.component);
const currentSha = checkResult?.current_sha;
const targetSha = checkResult?.latest_sha;
let modalController = getActiveProgressModalController();
if (!modalController) {
modalController = showUpdateProgressModal({
component: button.component,
action: button.action,
componentTitle: cardTitle,
currentVersion,
targetVersion,
currentSha,
targetSha,
});
}
let jobId = '';
let ownsJobFollow = false;
try {
const startResponse = await TachyonShellMethods.componentActionStart(
button.component,
button.action,
);
if (!startResponse.success) {
if (isComponentActionAlreadyRunningError(startResponse.error)) {
if (await followAlreadyRunningComponentAction(button)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 800));
if (await followAlreadyRunningComponentAction(button)) {
return;
}
setActionLoading(button.key, false);
modalController.completeError(
_('Another component action is already running'),
);
await refreshComponentActionState();
return;
}
if (isTransientRpcError(startResponse.error)) {
if (await followAlreadyRunningComponentAction(button)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 800));
if (await followAlreadyRunningComponentAction(button)) {
return;
}
setActionLoading(button.key, false);
modalController.completeError(
startResponse.error || _('Transient connection error'),
);
await refreshComponentActionState();
return;
}
throw new Error(startResponse.error);
}
jobId = startResponse.data.job_id;
if (followedComponentJobs.has(jobId) || handledComponentJobs.has(jobId)) {
return;
}
followedComponentJobs.add(jobId);
ownsJobFollow = true;
setActiveProgressModalJobId(jobId);
markUiActionOwned('component', jobId);
modalController.startLogTracking(jobId);
const response = await TachyonShellMethods.waitComponentActionJob(
jobId,
button.component,
button.action,
getExpectedLatestVersionForAction(button),
);
await completeComponentActionJob(button.key, jobId, response);
} catch (error) {
logger.error('[UPDATES]', 'handleComponentAction failed', error);
if (!pageUnloading) {
const message = getErrorMessage(error, _('Failed to execute'));
setActionLoading(button.key, false);
if (!isTransientRpcError(message)) {
showToast(message, 'error');
}
modalController.completeError(message);
void refreshComponentActionState();
}
} finally {
if (ownsJobFollow) {
followedComponentJobs.delete(jobId);
}
}
}
function getCheckAction(
component: Tachyon.ComponentName,
key: UpdatesActionKey,
): ComponentActionButton {
return {
key,
text: _('Check update'),
icon: renderSearchIcon24,
component,
action: 'check_update',
};
}
function getInstallAction(
component: Tachyon.ComponentName,
key: UpdatesActionKey,
installed: boolean,
): ComponentActionButton {
return {
key,
text: installed ? _('Update') : _('Install'),
icon: installed ? renderRotateCcwIcon24 : renderDownloadIcon24,
component,
action: 'install',
};
}
function getComponentInstallKey(
component: Tachyon.ComponentName,
): UpdatesActionKey {
switch (component) {
case 'tachyon':
return 'tachyonInstall';
case 'sing_box':
return 'singBoxInstall';
case 'zapret':
return 'zapretInstall';
case 'zapret2':
return 'zapret2Install';
case 'byedpi':
return 'byedpiInstall';
case 'tailscale':
return 'tailscaleInstall';
}
}
function getComponentInstallAction(
component: Tachyon.ComponentName,
): ComponentActionButton {
const isInstalled = !isNotInstalled(getComponentCurrentVersion(component));
const key = getComponentInstallKey(component);
return getInstallAction(component, key, isInstalled);
}
function getInstalledUpdateActions(
component: Tachyon.ComponentName,
checkKey: UpdatesActionKey,
installKey: UpdatesActionKey,
installed = true,
) {
if (!installed) {
return [];
}
const actions = [getCheckAction(component, checkKey)];
if (shouldShowInstallAfterCheck(component)) {
actions.push(getInstallAction(component, installKey, true));
}
return actions;
}
function getComponentBackupVersion(component: Tachyon.ComponentName): string {
const sys = store.get().diagnosticsSystemInfo;
switch (component) {
case 'sing_box':
return sys.sing_box_backup_version || '';
case 'zapret':
return sys.zapret_backup_version || '';
case 'zapret2':
return sys.zapret2_backup_version || '';
case 'byedpi':
return sys.byedpi_backup_version || '';
case 'tailscale':
return sys.tailscale_backup_version || '';
default:
return '';
}
}
function getRollbackAction(
component: Tachyon.ComponentName,
key: UpdatesActionKey,
backupVersion: string,
): ComponentActionButton {
return {
key,
text: backupVersion ? `${_('Rollback')} (${backupVersion})` : _('Rollback'),
icon: renderRotateCcwIcon24,
component,
action: 'rollback',
};
}
function getOptionalComponentActions({
component,
installed,
checkKey,
installKey,
removeKey,
rollbackKey,
}: {
component: 'zapret' | 'zapret2' | 'byedpi' | 'tailscale';
installed: boolean;
checkKey: UpdatesActionKey;
installKey: UpdatesActionKey;
removeKey: UpdatesActionKey;
rollbackKey: UpdatesActionKey;
}) {
if (!installed) {
return [getInstallAction(component, installKey, false)];
}
const actions = [
...getInstalledUpdateActions(component, checkKey, installKey),
{
key: removeKey,
text: _('Remove'),
icon: renderXIcon24,
component,
action: 'remove' as const,
},
];
const backupVersion = getComponentBackupVersion(component);
if (backupVersion) {
actions.push(getRollbackAction(component, rollbackKey, backupVersion));
}
return actions;
}
const COMPONENT_REPO_URLS: Record<Tachyon.ComponentName, string> = {
tachyon: 'https://github.com/Dushnilin/tachyon',
sing_box: 'https://github.com/SagerNet/sing-box',
zapret: 'https://github.com/remittor/zapret-openwrt',
zapret2: 'https://github.com/Dushnilin/zapret2-openwrt',
byedpi: 'https://github.com/DPITrickster/ByeDPI-OpenWrt',
tailscale: 'https://openwrt.org/packages/pkgdata/tailscale',
};
function getComponentCards(): ComponentCard[] {
const systemInfo = normalizeSingBoxVariantFields(
store.get().diagnosticsSystemInfo,
);
const systemInfoLoading = isSystemInfoLoading();
const zapretInstalled = Boolean(systemInfo.zapret_installed);
const zapret2Installed = Boolean(systemInfo.zapret2_installed);
const byedpiInstalled = Boolean(systemInfo.byedpi_installed);
const tailscaleInstalled = Boolean(systemInfo.tailscale_installed);
const singBoxInstalled = !isNotInstalled(systemInfo.sing_box_version);
const singBoxStable =
singBoxInstalled &&
!systemInfo.sing_box_extended &&
!systemInfo.sing_box_tiny;
const singBoxExtended =
Boolean(systemInfo.sing_box_extended) &&
!systemInfo.sing_box_compressed &&
!systemInfo.sing_box_lx;
const singBoxExtendedCompressed =
Boolean(systemInfo.sing_box_extended) &&
Boolean(systemInfo.sing_box_compressed);
const singBoxLx =
Boolean(systemInfo.sing_box_extended) && Boolean(systemInfo.sing_box_lx);
const singBoxTiny = Boolean(systemInfo.sing_box_tiny);
const tachyonActions = [
...getInstalledUpdateActions('tachyon', 'tachyonCheck', 'tachyonInstall'),
{
key: 'tachyonReinstall' as const,
text: _('Reinstall'),
icon: renderRotateCcwIcon24,
component: 'tachyon' as const,
action: 'reinstall' as const,
},
];
const singBoxActions = getInstalledUpdateActions(
'sing_box',
'singBoxCheck',
'singBoxInstall',
singBoxInstalled,
);
const singBoxBackup = getComponentBackupVersion('sing_box');
if (singBoxBackup && singBoxInstalled) {
singBoxActions.push(
getRollbackAction('sing_box', 'singBoxRollback', singBoxBackup),
);
}
if (!singBoxStable) {
singBoxActions.push({
key: 'singBoxInstallStable',
text: 'Stable',
icon: renderDownloadIcon24,
component: 'sing_box',
action: 'install_stable',
});
}
if (!singBoxTiny) {
singBoxActions.push({
key: 'singBoxInstallTiny',
text: 'Tiny',
icon: renderDownloadIcon24,
component: 'sing_box',
action: 'install_tiny',
});
}
if (!singBoxExtended) {
singBoxActions.push({
key: 'singBoxInstallExtended',
text: 'Extended',
icon: renderDownloadIcon24,
component: 'sing_box',
action: 'install_extended',
});
}
if (!singBoxExtendedCompressed) {
singBoxActions.push({
key: 'singBoxInstallExtendedCompressed',
text: 'Extended compressed',
icon: renderDownloadIcon24,
component: 'sing_box',
action: 'install_extended_compressed',
});
}
if (!singBoxLx) {
singBoxActions.push({
key: 'singBoxInstallLx',
text: 'Leadaxe (lx)',
icon: renderDownloadIcon24,
component: 'sing_box',
action: 'install_lx',
});
}
const zapretActions = getOptionalComponentActions({
component: 'zapret',
installed: zapretInstalled,
checkKey: 'zapretCheck',
installKey: 'zapretInstall',
removeKey: 'zapretRemove',
rollbackKey: 'zapretRollback',
});
const zapret2Actions = getOptionalComponentActions({
component: 'zapret2',
installed: zapret2Installed,
checkKey: 'zapret2Check',
installKey: 'zapret2Install',
removeKey: 'zapret2Remove',
rollbackKey: 'zapret2Rollback',
});
const byedpiActions = getOptionalComponentActions({
component: 'byedpi',
installed: byedpiInstalled,
checkKey: 'byedpiCheck',
installKey: 'byedpiInstall',
removeKey: 'byedpiRemove',
rollbackKey: 'byedpiRollback',
});
const tailscaleActions = getOptionalComponentActions({
component: 'tailscale',
installed: tailscaleInstalled,
checkKey: 'tailscaleCheck',
installKey: 'tailscaleInstall',
removeKey: 'tailscaleRemove',
rollbackKey: 'tailscaleRollback',
});
return [
{
component: 'tachyon',
column: 0,
title: 'Tachyon',
version: systemInfoLoading
? _('Loading...')
: normalizeCompiledVersion(
systemInfo.tachyon_version,
systemInfo.tachyon_commit_sha,
),
latestVersion: getLatestVersion('tachyon'),
releaseUrl: getGitHubReleaseUrl('tachyon'),
repoUrl: COMPONENT_REPO_URLS.tachyon,
actions: tachyonActions,
supportsVersions: true,
},
{
component: 'sing_box',
column: 0,
title: 'Sing-box',
version: systemInfoLoading
? _('Loading...')
: formatSingBoxVersion(systemInfo),
badgeNode: systemInfoLoading
? null
: renderSingBoxVariantBadge(systemInfo),
latestVersion: getLatestVersion('sing_box'),
releaseUrl: getGitHubReleaseUrl('sing_box'),
repoUrl:
systemInfo.sing_box_repo_url ||
(singBoxLx
? 'https://github.com/Leadaxe/sing-box-lx'
: singBoxExtended || singBoxExtendedCompressed
? 'https://github.com/shtorm-7/sing-box-extended'
: COMPONENT_REPO_URLS.sing_box),
actions: singBoxActions,
supportsVersions: true,
},
{
component: 'zapret',
column: 1,
title: 'Zapret',
version: systemInfoLoading
? _('Loading...')
: zapretInstalled
? systemInfo.zapret_version
: _('Not installed'),
latestVersion: getLatestVersion('zapret'),
releaseUrl: getGitHubReleaseUrl('zapret'),
repoUrl: COMPONENT_REPO_URLS.zapret,
actions: zapretActions,
supportsVersions: true,
},
{
component: 'zapret2',
column: 1,
title: 'Zapret2',
version: systemInfoLoading
? _('Loading...')
: zapret2Installed
? systemInfo.zapret2_version
: _('Not installed'),
latestVersion: getLatestVersion('zapret2'),
releaseUrl: getGitHubReleaseUrl('zapret2'),
repoUrl: COMPONENT_REPO_URLS.zapret2,
actions: zapret2Actions,
supportsVersions: true,
},
{
component: 'byedpi',
column: 1,
title: 'ByeDPI',
version: systemInfoLoading
? _('Loading...')
: byedpiInstalled
? systemInfo.byedpi_version
: _('Not installed'),
latestVersion: getLatestVersion('byedpi'),
releaseUrl: getGitHubReleaseUrl('byedpi'),
repoUrl: COMPONENT_REPO_URLS.byedpi,
actions: byedpiActions,
},
{
component: 'tailscale',
column: 1,
title: 'Tailscale',
version: systemInfoLoading
? _('Loading...')
: tailscaleInstalled
? systemInfo.tailscale_version
: _('Not installed'),
latestVersion: getLatestVersion('tailscale'),
releaseUrl: getGitHubReleaseUrl('tailscale'),
repoUrl: COMPONENT_REPO_URLS.tailscale,
actions: tailscaleActions,
},
];
}
async function showVersionPicker(component: Tachyon.ComponentName) {
const container = document.getElementById('tachyon_updates-components');
if (!container) return;
let dropdown = container.querySelector(
'.tachyon-version-picker',
) as HTMLElement | null;
if (dropdown) {
dropdown.remove();
return;
}
dropdown = E('div', { class: 'tachyon-version-picker' }, [
E(
'div',
{ class: 'tachyon-version-picker__loading' },
_('Loading versions...'),
),
]);
const componentCards = container.querySelectorAll(
`.tachyon_updates-page__component`,
);
const card = Array.from(componentCards).find((el) => {
const title = el.querySelector('.tachyon_updates-page__component__title');
return title?.textContent === getComponentCardTitle(component);
});
if (card) {
const actionsEl = card.querySelector(
'.tachyon_updates-page__component__actions',
);
actionsEl?.appendChild(dropdown);
}
try {
const response = await TachyonShellMethods.componentListReleases(
component,
3,
);
if (!response.success) {
dropdown.innerHTML = '';
dropdown.appendChild(
E(
'div',
{ class: 'tachyon-version-picker__error' },
response.error || _('No versions found'),
),
);
return;
}
if (!response.data || response.data.length === 0) {
dropdown.innerHTML = '';
dropdown.appendChild(
E(
'div',
{ class: 'tachyon-version-picker__error' },
_('No versions found'),
),
);
return;
}
dropdown.innerHTML = '';
const list = E('div', { class: 'tachyon-version-picker__list' });
for (const release of response.data) {
const children: Node[] = [
E('span', { class: 'tachyon-version-picker__tag' }, release.tag),
E('span', { class: 'tachyon-version-picker__date' }, release.published),
];
if (release.prerelease) {
children.push(
E(
'span',
{ class: 'tachyon-version-picker__prerelease' },
_('pre-release'),
),
);
}
children.push(
renderButton({
text: _('Install'),
loading: false,
disabled: false,
onClick: () => void installVersion(component, release.tag, dropdown!),
}),
);
const item = E(
'div',
{ class: 'tachyon-version-picker__item' },
children,
);
list.appendChild(item);
}
dropdown.appendChild(list);
dropdown.appendChild(
E(
'button',
{
class: 'cbi-button tachyon-version-picker__close',
click: () => dropdown?.remove(),
},
_('Close'),
),
);
} catch (_error) {
dropdown.innerHTML = '';
dropdown.appendChild(
E(
'div',
{ class: 'tachyon-version-picker__error' },
_('Failed to load versions'),
),
);
}
}
async function installVersion(
component: Tachyon.ComponentName,
tag: string,
dropdown: HTMLElement,
) {
const loadingEl = dropdown.querySelector(
'.tachyon-version-picker__loading',
) as HTMLElement | null;
if (loadingEl) {
loadingEl.textContent = _('Installing ') + tag + '...';
}
try {
const response = await TachyonShellMethods.componentInstallVersion(
component,
tag,
);
if (response?.success) {
showToast(_('Installed ') + tag, 'success');
dropdown.remove();
window.location.reload();
} else {
showToast(_('Installation failed: ') + (response?.error || ''), 'error');
}
} catch (_error) {
showToast(_('Installation failed'), 'error');
}
}
function renderComponentCard(card: ComponentCard) {
const updatesActions = store.get().updatesActions;
const anyActionLoading = isAnyActionLoading();
const systemInfoLoading = isSystemInfoLoading();
const checkResult = getVisibleCheckResult(card.component);
const headerChildren: Node[] = [
E('b', { class: 'tachyon_updates-page__component__title' }, card.title),
];
if (card.badgeNode) {
headerChildren.push(card.badgeNode);
}
headerChildren.push(
E(
'span',
{ class: 'tachyon_updates-page__component__header-version' },
card.version,
),
);
if (card.repoUrl) {
headerChildren.push(
E(
'a',
{
class: 'tachyon_updates-page__component__repo-link',
href: card.repoUrl,
target: '_blank',
rel: 'noopener noreferrer',
title: card.repoUrl,
},
renderGlobeIcon24(),
),
);
}
const header = E(
'div',
{ class: 'tachyon_updates-page__component__header' },
headerChildren,
);
const detailsChildren: Node[] = [];
if (checkResult && checkResult.status) {
let labelText = '';
const latestValueNodes: Node[] = [];
if (checkResult.status === 'outdated') {
labelText = _('Update is available:');
const versionToShow =
checkResult.latest_version || card.latestVersion || card.version;
if (checkResult.release_url) {
latestValueNodes.push(
E(
'a',
{
class: 'tachyon_updates-page__component__release-version-link',
href: checkResult.release_url,
target: '_blank',
rel: 'noopener noreferrer',
},
versionToShow || _('Open'),
),
);
} else if (versionToShow) {
latestValueNodes.push(document.createTextNode(versionToShow));
}
} else if (checkResult.status === 'outdated_same_release') {
labelText = _('Update is available for current release');
// Show short commit SHAs so user understands it's the same version, different build
const build = describeSameReleaseBuild({
currentSha: checkResult.current_sha,
latestSha: checkResult.latest_sha,
});
latestValueNodes.push(
E(
'span',
{
class: 'tachyon_updates-page__component__sha-info',
title: _('Installed build → Available build'),
},
build.kind === 'sha' ? build.text : _('Rebuilt release'),
),
);
} else if (checkResult.status === 'latest') {
labelText = _('Latest version is installed');
} else if (checkResult.status === 'dev') {
labelText = `${_('Installed version is newer than release')}. ${_('Latest version:')}`;
const versionToShow = checkResult.latest_version || card.latestVersion;
if (checkResult.release_url) {
latestValueNodes.push(
E(
'a',
{
class: 'tachyon_updates-page__component__release-version-link',
href: checkResult.release_url,
target: '_blank',
rel: 'noopener noreferrer',
},
versionToShow || _('Open'),
),
);
} else if (versionToShow) {
latestValueNodes.push(document.createTextNode(versionToShow));
}
}
if (labelText) {
const rowChildren: Node[] = [
E(
'span',
{ class: 'tachyon_updates-page__component__info-label' },
labelText,
),
];
if (latestValueNodes.length > 0) {
rowChildren.push(
E(
'span',
{
class:
'tachyon_updates-page__component__info-value tachyon_updates-page__component__info-value--latest',
},
latestValueNodes,
),
);
}
detailsChildren.push(
E(
'div',
{ class: 'tachyon_updates-page__component__info-row' },
rowChildren,
),
);
}
}
const detailsContainer =
detailsChildren.length > 0
? E(
'div',
{ class: 'tachyon_updates-page__component__details' },
detailsChildren,
)
: null;
const primaryActions: ComponentActionButton[] = [];
const dangerActions: ComponentActionButton[] = [];
const variantActions: ComponentActionButton[] = [];
card.actions.forEach((action) => {
if (action.action === 'remove') {
dangerActions.push(action);
} else if (action.action.startsWith('install_')) {
variantActions.push(action);
} else {
primaryActions.push(action);
}
});
const actionElements: Node[] = [];
const primaryButtons = primaryActions.map((action) => {
const loading = updatesActions[action.key].loading;
const isUpdateOrInstall =
action.action === 'install' || action.action === 'reinstall';
return renderButton({
classNames: isUpdateOrInstall ? ['cbi-button-save'] : [],
text: action.text,
icon: action.icon,
loading,
disabled: systemInfoLoading || (anyActionLoading && !loading),
onClick: () => void handleComponentAction(action),
});
});
const dangerButtons = dangerActions.map((action) => {
const loading = updatesActions[action.key].loading;
return renderButton({
classNames: ['cbi-button-remove'],
text: action.text,
icon: action.icon,
loading,
disabled: systemInfoLoading || (anyActionLoading && !loading),
onClick: () => void handleComponentAction(action),
});
});
if (primaryButtons.length > 0 || dangerButtons.length > 0) {
actionElements.push(
E('div', { class: 'tachyon_updates-page__component__actions-main' }, [
...primaryButtons,
...dangerButtons,
]),
);
}
if (variantActions.length > 0) {
const variantButtons = variantActions.map((action) => {
const loading = updatesActions[action.key].loading;
return renderButton({
text: action.text,
icon: action.icon,
loading,
disabled: systemInfoLoading || (anyActionLoading && !loading),
onClick: () => void handleComponentAction(action),
});
});
actionElements.push(
E('div', { class: 'tachyon_updates-page__component__variants' }, [
E(
'div',
{ class: 'tachyon_updates-page__component__variants-title' },
_('Install another build:'),
),
E(
'div',
{ class: 'tachyon_updates-page__component__variants-buttons' },
variantButtons,
),
]),
);
}
if (card.supportsVersions) {
const versionsButton = renderButton({
text: _('Versions'),
loading: false,
disabled: systemInfoLoading || anyActionLoading,
onClick: () => void showVersionPicker(card.component),
});
actionElements.push(
E('div', { class: 'tachyon_updates-page__component__versions' }, [
versionsButton,
]),
);
}
const actionsContainer = E(
'div',
{
class: [
'tachyon_updates-page__component__actions',
detailsContainer
? 'tachyon_updates-page__component__actions--with-details'
: '',
]
.filter(Boolean)
.join(' '),
},
actionElements,
);
const cardChildren: Node[] = [header];
if (detailsContainer) {
cardChildren.push(detailsContainer);
}
cardChildren.push(actionsContainer);
return E('div', { class: 'tachyon_updates-page__component' }, cardChildren);
}
function renderUpdatesComponents() {
const container = document.getElementById('tachyon_updates-components');
if (!container) {
return;
}
const columns = [[], []] as Node[][];
getComponentCards().forEach((card) => {
columns[card.column].push(renderComponentCard(card));
});
return preserveScrollForPage(() => {
container.replaceChildren(
E(
'div',
{ class: 'tachyon_updates-page__components-column' },
columns[0],
),
E(
'div',
{ class: 'tachyon_updates-page__components-column' },
columns[1],
),
);
});
}
function onStoreUpdate(
_next: StoreType,
_prev: StoreType,
diff: Partial<StoreType>,
) {
if (
diff.diagnosticsSystemInfo ||
diff.updatesActions ||
diff.updatesChecks ||
diff.diagnosticsActions ||
diff.servicesInfoWidget
) {
renderUpdatesComponents();
}
}
function applyComponentUpdateCheckCache(
componentUpdateCheckCache: Tachyon.ComponentUpdateCheckCache,
) {
componentUpdateCheckCacheResolved = true;
if (componentUpdateCheckCache.enabled) {
store.reset(['updatesChecks']);
applyCachedCheckResults(componentUpdateCheckCache.results);
}
if (
shouldResetCheckResultsOnMount({
anyActionLoading: isAnyActionLoading(),
preserveCheckResultsOnNextMount,
persistentCacheEnabled: componentUpdateCheckCache.enabled,
})
) {
store.reset(['updatesChecks']);
}
}
async function onPageMount() {
onPageUnmount();
updatesMounted = true;
updatesMountId += 1;
const mountId = updatesMountId;
const cachedRuntimeState = getCachedRuntimeUiState();
const hasRuntimeSnapshot = Boolean(cachedRuntimeState);
const needsFreshStateBeforeRender =
shouldRefreshComponentStateBeforeRender(cachedRuntimeState);
const runtimeStateRefreshPromise =
!hasRuntimeSnapshot || needsFreshStateBeforeRender
? refreshRuntimeUiState({ force: true })
: null;
const prefetchedComponentUpdateCheckCache = componentUpdateCheckCacheSnapshot;
if (prefetchedComponentUpdateCheckCache) {
applyComponentUpdateCheckCache(prefetchedComponentUpdateCheckCache);
}
renderUpdatesComponents();
const componentUpdateCheckCache = await loadComponentUpdateCheckCache({
force: Boolean(prefetchedComponentUpdateCheckCache),
});
if (!updatesMounted || mountId !== updatesMountId) {
return;
}
applyComponentUpdateCheckCache(componentUpdateCheckCache);
preserveCheckResultsOnNextMount = false;
renderUpdatesComponents();
if (runtimeStateRefreshPromise) {
await runtimeStateRefreshPromise;
if (!updatesMounted || mountId !== updatesMountId) {
return;
}
}
store.subscribe(onStoreUpdate);
startComponentActionStateWatcher();
renderUpdatesComponents();
void ensureSystemInfo();
if (hasRuntimeSnapshot) {
void refreshRuntimeUiState({ force: true });
}
}
function onPageUnmount() {
updatesMounted = false;
updatesMountId += 1;
stopComponentActionStateWatcher();
store.unsubscribe(onStoreUpdate);
}
function registerLifecycleListeners() {
if (updatesLifecycleRegistered) {
return;
}
updatesLifecycleRegistered = true;
store.subscribe((next, prev, diff) => {
if (
diff.tabService &&
next.tabService.current !== prev.tabService.current
) {
const isUpdatesVisible = next.tabService.current === 'updates';
if (isUpdatesVisible) {
return onPageMount();
}
if (updatesMounted) {
return onPageUnmount();
}
}
});
}
export async function initController(): Promise<void> {
if (updatesControllerInitialized) {
return;
}
updatesControllerInitialized = true;
void loadComponentUpdateCheckCache();
onMount('updates-status').then(() => {
logger.debug('[UPDATES]', 'initController', 'onMount');
registerLifecycleListeners();
if (
store.get().tabService.current === 'updates' ||
isActiveLuciTab('updates')
) {
onPageMount();
}
});
}