From 328546e7cd5303aad25bd285607f3e30aebd7672 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:21:57 +0300 Subject: [PATCH 01/98] web: poll power LEDs quickly only while the power popover is open The toolbar polled /api/vm/gpio every 200 ms for as long as the desktop was open, although outside the popover it only renders the power-on glow and the HDD LED. Poll every 300 ms while the popover is open and every 3 s otherwise; opening the popover refreshes immediately. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/desktop/menu/power/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/pages/desktop/menu/power/index.tsx b/web/src/pages/desktop/menu/power/index.tsx index 745f2e38..ffb94e4e 100644 --- a/web/src/pages/desktop/menu/power/index.tsx +++ b/web/src/pages/desktop/menu/power/index.tsx @@ -20,6 +20,7 @@ export const Power = ({ vertical = false }: { vertical?: boolean }) => { const [isHddActive, setIsHddActive] = useState(false); const [isLoading, setIsLoading] = useState(false); const [showConfirm, setShowConfirm] = useState(localstorage.getPowerConfirm); + const [isOpen, setIsOpen] = useState(false); useEffect(() => { let disposed = false; @@ -41,14 +42,16 @@ export const Power = ({ vertical = false }: { vertical?: boolean }) => { } } + // The toolbar only shows the power glow and HDD LED; poll quickly only + // while the popover is open and the user is acting on power controls. void refreshLeds(); - const stopPolling = pollWhileVisible(refreshLeds, 200); + const stopPolling = pollWhileVisible(refreshLeds, isOpen ? 300 : 3000); return () => { disposed = true; stopPolling(); }; - }, []); + }, [isOpen]); function updateShowConfirm(value: boolean) { setShowConfirm(value); @@ -100,7 +103,7 @@ export const Power = ({ vertical = false }: { vertical?: boolean }) => { return (
- +
Date: Wed, 7 Oct 2026 03:22:41 +0300 Subject: [PATCH 02/98] web: stop rewriting the stream type when an admin opens the desktop The Screen toolbar item wrote /kvmapp/kvm/type (via POST /api/vm/screen type) on mount and on every local mode or codec change whenever the viewer was an administrator. That file is device-wide, so merely opening the page in a browser with a different local player choice silently changed the setting for everyone. Nothing depends on that implicit write: the Go server only writes the file (service/vm/screen.go) and never reads it back; the Direct, WebRTC and MJPEG streams pick their encoder from the stream that is opened and from the shared encoder state (POST /api/stream/state). Its only reader is kvm_system, which shows the type on the OLED and uses it to bucket the quality value. The video settings dialog already sends `type` in the batched POST /api/vm/video request when an administrator applies a different transport or codec (settingsRequest in lib/video-model.ts), which is the explicit path that should own this setting. Remove the effect and the now unused getScreenType helper. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/desktop/menu/screen/constants.ts | 10 ---------- web/src/pages/desktop/menu/screen/index.tsx | 9 --------- 2 files changed, 19 deletions(-) diff --git a/web/src/pages/desktop/menu/screen/constants.ts b/web/src/pages/desktop/menu/screen/constants.ts index 30d1760e..54adb8d1 100644 --- a/web/src/pages/desktop/menu/screen/constants.ts +++ b/web/src/pages/desktop/menu/screen/constants.ts @@ -26,13 +26,3 @@ export function getQualityMap(videoMode: string) { } return null; } - -export function getScreenType(videoMode: string, codec: string) { - if (videoMode === 'mjpeg') { - return 0; - } - if (videoMode === 'direct' || videoMode === 'h264') { - return codec === 'h265' ? 2 : 1; - } - return null; -} diff --git a/web/src/pages/desktop/menu/screen/index.tsx b/web/src/pages/desktop/menu/screen/index.tsx index a35a0733..b6c7d9ab 100644 --- a/web/src/pages/desktop/menu/screen/index.tsx +++ b/web/src/pages/desktop/menu/screen/index.tsx @@ -1,11 +1,8 @@ -import { useEffect } from 'react'; -import { useAuth } from '@/contexts/auth'; import { Button } from 'antd'; import { useAtomValue, useSetAtom } from 'jotai'; import { ClapperboardIcon, MonitorIcon, SettingsIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { updateScreen } from '@/api/vm'; import { getEncoderCodec } from '@/lib/encoder'; import { videoModeAtom, videoSessionCountAtom } from '@/jotai/screen'; import { menuCloseSignalAtom, settingsRequestAtom } from '@/jotai/settings'; @@ -13,13 +10,11 @@ import { useResponsiveDevice } from '@/hooks/useResponsiveDevice'; import { MenuItem } from '@/components/menu-item'; import { useDismissMobileMenu } from '@/components/mobile-menu-context'; -import { getScreenType } from './constants'; import { QuickVideo } from './quick-video'; import { Scale } from './scale'; export const Screen = () => { const { t } = useTranslation(); - const { account } = useAuth(); const mode = useAtomValue(videoModeAtom); const sessions = useAtomValue(videoSessionCountAtom); const codec = getEncoderCodec(); @@ -31,10 +26,6 @@ export const Screen = () => { const closeMenu = useSetAtom(menuCloseSignalAtom); const dismissMobileMenu = useDismissMobileMenu(); const { isMobilePortrait } = useResponsiveDevice(); - useEffect(() => { - const type = getScreenType(mode, codec); - if (type !== null && account.role === 'admin') void updateScreen('type', type); - }, [mode, codec, account.role]); const content = (
From d6845843f48288e88b80ece96d60d57b3e9b9fb3 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:22:49 +0300 Subject: [PATCH 03/98] i18n: move RustDesk labels into the locale files The RustDesk add-on kept its own en/ru label map and picked it with startsWith("ru"), so every other language got English and the strings were invisible to translators. Move them to settings.rustdesk.* in en.ts and ru.ts, use i18next interpolation for the version, and drop the two labels that were never rendered (installed, codec). Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/en.ts | 68 +++++++++ web/src/i18n/locales/ru.ts | 68 +++++++++ .../menu/settings/extensions/rustdesk.tsx | 88 ++++++----- .../menu/settings/software/rustdesk-labels.ts | 142 ------------------ .../settings/software/rustdesk-versions.tsx | 9 +- .../menu/settings/software/rustdesk.tsx | 30 ++-- 6 files changed, 196 insertions(+), 209 deletions(-) delete mode 100644 web/src/pages/desktop/menu/settings/software/rustdesk-labels.ts diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 68fefd5f..438a102e 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -1492,6 +1492,74 @@ const en = { stopFailed: 'Stop failed' } }, + rustdesk: { + absent: 'Not installed', + running: 'Running', + stopped: 'Stopped', + registered: 'Registered', + registering: 'Waiting for ID server', + install: 'Install', + remove: 'Remove', + upgrade: 'Update to {{version}}', + addonVersion: 'Add-on version', + rustdeskVersion: 'RustDesk protocol base', + unknownVersion: 'Unknown', + save: 'Save settings', + enabled: 'Enable remote access', + server: 'Server', + public: 'Official public servers', + custom: 'Custom server', + idServer: 'ID / rendezvous server', + relay: 'Relay server (optional)', + key: 'Server public key (optional)', + password: 'Access password', + passwordMode: 'Password', + temporary: 'Temporary (automatic)', + permanent: 'Permanent', + temporaryPassword: 'Temporary password', + newPassword: 'New password', + temporaryHint: + 'Share this ID and password. The password changes when RustDesk starts or when you request a new one.', + rotatingTemporaryHint: + 'Share this ID and the current password. Each new successful sign-in generates a new password without closing active sessions. RustDesk also replaces it on startup or when you request a new one.', + startForPassword: 'Enable RustDesk and save settings to generate a password.', + waitingForPassword: 'Generating a temporary password…', + regenerateConfirm: 'Generate a new password? Current RustDesk connections will close.', + advanced: 'Advanced settings', + audioEnabled: + 'USB audio is enabled. Select NanoKVM as the audio output on the connected computer. Sound can be muted in the RustDesk client.', + audioDisabled: + 'To stream computer sound, enable USB audio in the device USB settings and select NanoKVM as the audio output on the computer.', + webrtc: 'Allow WebRTC connections', + transportHint: + 'TCP and relay are used by default. WebRTC may reduce frame rate on NanoKVM. To connect by TCP using an ID, turn WebRTC off in the RustDesk client too.', + open: 'Open management', + settings: 'RustDesk', + keepPassword: 'Leave empty to keep the current password', + inputDefaults: + 'Enabling remote access automatically enables USB keyboard and absolute/relative mouse.', + transmitAudio: 'Transmit sound', + audioHint: + 'Enables USB audio automatically. Select NanoKVM as the sound output on the connected computer.', + audioMuted: 'Sound transmission through RustDesk is disabled.', + clients: 'Maximum viewers', + unavailable: + 'RustDesk is not available in the configured APK repositories. Add the repository containing nanokvm-rustdesk, or install its APK through Packages.', + explain: + 'Connect a RustDesk client to this ID to view HDMI and control USB keyboard and mouse.', + video: + 'Video automatically uses the codec selected in NanoKVM video settings. H.265 requires client support. Reconnect after changing the device codec. Browser takeover releases RustDesk input.', + deletion: 'Remove the package? Server settings, password and device ID are preserved.', + failed: 'The request failed', + saved: 'Settings saved', + passwordUpdated: 'Password updated', + description: 'Remote access to the computer connected to NanoKVM, using RustDesk.', + done: 'Package operation completed', + id: 'RustDesk ID', + source: 'Source and license', + passwordRequired: 'Enter a password of 8 to 64 bytes', + sameServer: 'Configure the same custom ID server and public key in the RustDesk client.' + }, account: { title: 'Users', webAccount: 'Web Account Name', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 861bb05c..3dd34fd5 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -1517,6 +1517,74 @@ const ru = { stopFailed: 'Не удалось остановить службу' } }, + rustdesk: { + absent: 'Не установлен', + running: 'Работает', + stopped: 'Остановлен', + registered: 'Зарегистрирован', + registering: 'Ожидание ID-сервера', + install: 'Установить', + remove: 'Удалить', + upgrade: 'Обновить до {{version}}', + addonVersion: 'Версия аддона', + rustdeskVersion: 'RustDesk — база протокола', + unknownVersion: 'Неизвестна', + save: 'Сохранить настройки', + enabled: 'Включить удалённый доступ', + server: 'Сервер', + public: 'Штатные публичные серверы', + custom: 'Собственный сервер', + idServer: 'ID / rendezvous сервер', + relay: 'Relay сервер (необязательно)', + key: 'Публичный ключ сервера (необязательно)', + password: 'Пароль доступа', + passwordMode: 'Пароль', + temporary: 'Временный (автоматически)', + permanent: 'Постоянный', + temporaryPassword: 'Временный пароль', + newPassword: 'Новый пароль', + temporaryHint: + 'Передайте этот ID и пароль. Пароль меняется при запуске RustDesk или по кнопке «Новый пароль».', + rotatingTemporaryHint: + 'Передайте этот ID и текущий пароль. После нового успешного входа создаётся новый пароль; открытые сеансы продолжают работать. Пароль также меняется при запуске RustDesk или по кнопке «Новый пароль».', + startForPassword: 'Включите RustDesk и сохраните настройки, чтобы получить пароль.', + waitingForPassword: 'Генерируется временный пароль…', + regenerateConfirm: 'Создать новый пароль? Текущие подключения RustDesk будут завершены.', + advanced: 'Дополнительные настройки', + audioEnabled: + 'USB audio включён. Выберите NanoKVM как устройство вывода звука на подключённом компьютере. Звук можно отключить в клиенте RustDesk.', + audioDisabled: + 'Для передачи звука включите USB audio в настройках USB устройства и выберите NanoKVM как устройство вывода звука на компьютере.', + webrtc: 'Разрешить подключения WebRTC', + transportHint: + 'По умолчанию используются TCP и relay. WebRTC может снижать частоту кадров на NanoKVM. Для подключения по ID через TCP выключите WebRTC и в клиенте RustDesk.', + open: 'Открыть управление', + settings: 'RustDesk', + keepPassword: 'Оставьте пустым, чтобы сохранить пароль', + inputDefaults: + 'Включение удалённого доступа автоматически включает USB-клавиатуру и абсолютную/относительную мышь.', + transmitAudio: 'Передавать звук', + audioHint: + 'Автоматически включает USB audio. Выберите NanoKVM как устройство вывода звука на подключённом компьютере.', + audioMuted: 'Передача звука через RustDesk отключена.', + clients: 'Максимум зрителей', + unavailable: + 'В настроенных APK-репозиториях нет RustDesk. Добавьте репозиторий с nanokvm-rustdesk или установите его APK через Packages.', + explain: + 'Подключитесь к этому ID из RustDesk, чтобы видеть HDMI и управлять USB-клавиатурой и мышью.', + video: + 'Видеокодек автоматически берётся из настроек видео NanoKVM. Для H.265 нужна поддержка клиента. После смены кодека устройства подключитесь заново. Перехват управления браузером освобождает ввод RustDesk.', + deletion: 'Удалить пакет? Настройки сервера, пароль и ID устройства сохранятся.', + failed: 'Запрос не выполнен', + saved: 'Настройки сохранены', + passwordUpdated: 'Пароль обновлён', + description: 'Удалённый доступ через RustDesk к компьютеру, подключённому к NanoKVM.', + done: 'Операция с пакетом завершена', + id: 'RustDesk ID', + source: 'Исходники и лицензия', + passwordRequired: 'Введите пароль от 8 до 64 байт', + sameServer: 'Укажите тот же собственный ID-сервер и публичный ключ в клиенте RustDesk.' + }, account: { title: 'Пользователи', webAccount: 'Имя веб-аккаунта', diff --git a/web/src/pages/desktop/menu/settings/extensions/rustdesk.tsx b/web/src/pages/desktop/menu/settings/extensions/rustdesk.tsx index 06477f8b..f1c8833c 100644 --- a/web/src/pages/desktop/menu/settings/extensions/rustdesk.tsx +++ b/web/src/pages/desktop/menu/settings/extensions/rustdesk.tsx @@ -29,12 +29,10 @@ import { pollWhileVisible } from '@/lib/visible-poll'; import { rustDeskStatusAtom } from '@/jotai/rustdesk'; import { RustDeskIcon } from '@/components/icons/rustdesk'; -import { rustDeskLabels as labels } from '../software/rustdesk-labels'; import { RustDeskVersions } from '../software/rustdesk-versions'; export const RustDeskControls = () => { - const { i18n } = useTranslation(); - const l = labels[(i18n.resolvedLanguage || i18n.language).startsWith('ru') ? 'ru' : 'en']; + const { t } = useTranslation('translation', { keyPrefix: 'settings.rustdesk' }); const [status, setStatus] = useAtom(rustDeskStatusAtom); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); @@ -69,11 +67,11 @@ export const RustDeskControls = () => { form.setFieldsValue({ audio_enabled: true, ...next.config, password: '' }); setError(''); } catch { - if (started === generation.current && !controller.signal.aborted) setError(l.failed); + if (started === generation.current && !controller.signal.aborted) setError(t('failed')); } finally { if (started === generation.current) pending.current = false; } - }, [form, l.failed, setStatus]); + }, [form, setStatus, t]); useEffect(() => { mounted.current = true; @@ -117,10 +115,10 @@ export const RustDeskControls = () => { return; } if (action !== 'regenerate-password') dirty.current = false; - message.success(action === 'save' ? l.saved : l.passwordUpdated); + message.success(action === 'save' ? t('saved') : t('passwordUpdated')); setError(''); } catch { - if (mounted.current && started === generation.current) setError(l.failed); + if (mounted.current && started === generation.current) setError(t('failed')); } finally { working.current = false; if (mounted.current) { @@ -134,7 +132,7 @@ export const RustDeskControls = () => {

- {l.settings} + {t('settings')}

{error && } @@ -142,19 +140,19 @@ export const RustDeskControls = () => {
{status?.installed && ( - {status.running ? l.running : l.stopped} + {status.running ? t('running') : t('stopped')} )} {status?.running && ( - {status.runtime?.registered ? l.registered : l.registering} + {status.runtime?.registered ? t('registered') : t('registering')} )}
{status?.id && (
- {l.id}: + {t('id')}: {status.id}
)} @@ -162,26 +160,26 @@ export const RustDeskControls = () => {
{status.temporary_password ? (
- {l.temporaryPassword}: + {t('temporaryPassword')}: {status.temporary_password} operation('regenerate-password')} > - +
) : ( -

{status.running ? l.waitingForPassword : l.startForPassword}

+

{status.running ? t('waitingForPassword') : t('startForPassword')}

)}

- {rotatesOnLogin ? l.rotatingTemporaryHint : l.temporaryHint} + {rotatesOnLogin ? t('rotatingTemporaryHint') : t('temporaryHint')}

)} -

{l.explain}

+

{t('explain')}

{status?.installed && ( { > {status.supports_audio_settings && ( @@ -223,28 +221,28 @@ export const RustDeskControls = () => { type="info" title={ status.supports_audio_settings && audioEnabled === false - ? l.audioMuted + ? t('audioMuted') : status.usb_audio_enabled - ? l.audioEnabled + ? t('audioEnabled') : status.supports_audio_settings - ? l.audioHint - : l.audioDisabled + ? t('audioHint') + : t('audioDisabled') } className="mb-4" /> )} - + {!official && ( <> - + - + - + @@ -308,17 +306,17 @@ export const RustDeskControls = () => { {status.supports_transport_settings && ( )} - + @@ -330,14 +328,14 @@ export const RustDeskControls = () => { ]} /> )} {status?.installed && status.source_url && ( - {l.source} · AGPL-3.0 + {t('source')} · AGPL-3.0 )}
diff --git a/web/src/pages/desktop/menu/settings/software/rustdesk-labels.ts b/web/src/pages/desktop/menu/settings/software/rustdesk-labels.ts deleted file mode 100644 index 9d1f72bb..00000000 --- a/web/src/pages/desktop/menu/settings/software/rustdesk-labels.ts +++ /dev/null @@ -1,142 +0,0 @@ -export const rustDeskLabels = { - en: { - installed: 'Installed', - absent: 'Not installed', - running: 'Running', - stopped: 'Stopped', - registered: 'Registered', - registering: 'Waiting for ID server', - install: 'Install', - remove: 'Remove', - upgrade: 'Update to {version}', - addonVersion: 'Add-on version', - rustdeskVersion: 'RustDesk protocol base', - unknownVersion: 'Unknown', - save: 'Save settings', - enabled: 'Enable remote access', - server: 'Server', - public: 'Official public servers', - custom: 'Custom server', - idServer: 'ID / rendezvous server', - relay: 'Relay server (optional)', - key: 'Server public key (optional)', - password: 'Access password', - passwordMode: 'Password', - temporary: 'Temporary (automatic)', - permanent: 'Permanent', - temporaryPassword: 'Temporary password', - newPassword: 'New password', - temporaryHint: - 'Share this ID and password. The password changes when RustDesk starts or when you request a new one.', - rotatingTemporaryHint: - 'Share this ID and the current password. Each new successful sign-in generates a new password without closing active sessions. RustDesk also replaces it on startup or when you request a new one.', - startForPassword: 'Enable RustDesk and save settings to generate a password.', - waitingForPassword: 'Generating a temporary password…', - regenerateConfirm: 'Generate a new password? Current RustDesk connections will close.', - advanced: 'Advanced settings', - audioEnabled: - 'USB audio is enabled. Select NanoKVM as the audio output on the connected computer. Sound can be muted in the RustDesk client.', - audioDisabled: - 'To stream computer sound, enable USB audio in the device USB settings and select NanoKVM as the audio output on the computer.', - webrtc: 'Allow WebRTC connections', - transportHint: - 'TCP and relay are used by default. WebRTC may reduce frame rate on NanoKVM. To connect by TCP using an ID, turn WebRTC off in the RustDesk client too.', - open: 'Open management', - settings: 'RustDesk', - keepPassword: 'Leave empty to keep the current password', - inputDefaults: - 'Enabling remote access automatically enables USB keyboard and absolute/relative mouse.', - transmitAudio: 'Transmit sound', - audioHint: - 'Enables USB audio automatically. Select NanoKVM as the sound output on the connected computer.', - audioMuted: 'Sound transmission through RustDesk is disabled.', - codec: 'Video codec', - clients: 'Maximum viewers', - unavailable: - 'RustDesk is not available in the configured APK repositories. Add the repository containing nanokvm-rustdesk, or install its APK through Packages.', - explain: - 'Connect a RustDesk client to this ID to view HDMI and control USB keyboard and mouse.', - video: - 'Video automatically uses the codec selected in NanoKVM video settings. H.265 requires client support. Reconnect after changing the device codec. Browser takeover releases RustDesk input.', - deletion: 'Remove the package? Server settings, password and device ID are preserved.', - failed: 'The request failed', - saved: 'Settings saved', - passwordUpdated: 'Password updated', - description: 'Remote access to the computer connected to NanoKVM, using RustDesk.', - done: 'Package operation completed', - id: 'RustDesk ID', - source: 'Source and license', - passwordRequired: 'Enter a password of 8 to 64 bytes', - sameServer: 'Configure the same custom ID server and public key in the RustDesk client.' - }, - ru: { - installed: 'Установлен', - absent: 'Не установлен', - running: 'Работает', - stopped: 'Остановлен', - registered: 'Зарегистрирован', - registering: 'Ожидание ID-сервера', - install: 'Установить', - remove: 'Удалить', - upgrade: 'Обновить до {version}', - addonVersion: 'Версия аддона', - rustdeskVersion: 'RustDesk — база протокола', - unknownVersion: 'Неизвестна', - save: 'Сохранить настройки', - enabled: 'Включить удалённый доступ', - server: 'Сервер', - public: 'Штатные публичные серверы', - custom: 'Собственный сервер', - idServer: 'ID / rendezvous сервер', - relay: 'Relay сервер (необязательно)', - key: 'Публичный ключ сервера (необязательно)', - password: 'Пароль доступа', - passwordMode: 'Пароль', - temporary: 'Временный (автоматически)', - permanent: 'Постоянный', - temporaryPassword: 'Временный пароль', - newPassword: 'Новый пароль', - temporaryHint: - 'Передайте этот ID и пароль. Пароль меняется при запуске RustDesk или по кнопке «Новый пароль».', - rotatingTemporaryHint: - 'Передайте этот ID и текущий пароль. После нового успешного входа создаётся новый пароль; открытые сеансы продолжают работать. Пароль также меняется при запуске RustDesk или по кнопке «Новый пароль».', - startForPassword: 'Включите RustDesk и сохраните настройки, чтобы получить пароль.', - waitingForPassword: 'Генерируется временный пароль…', - regenerateConfirm: 'Создать новый пароль? Текущие подключения RustDesk будут завершены.', - advanced: 'Дополнительные настройки', - audioEnabled: - 'USB audio включён. Выберите NanoKVM как устройство вывода звука на подключённом компьютере. Звук можно отключить в клиенте RustDesk.', - audioDisabled: - 'Для передачи звука включите USB audio в настройках USB устройства и выберите NanoKVM как устройство вывода звука на компьютере.', - webrtc: 'Разрешить подключения WebRTC', - transportHint: - 'По умолчанию используются TCP и relay. WebRTC может снижать частоту кадров на NanoKVM. Для подключения по ID через TCP выключите WebRTC и в клиенте RustDesk.', - open: 'Открыть управление', - settings: 'RustDesk', - keepPassword: 'Оставьте пустым, чтобы сохранить пароль', - inputDefaults: - 'Включение удалённого доступа автоматически включает USB-клавиатуру и абсолютную/относительную мышь.', - transmitAudio: 'Передавать звук', - audioHint: - 'Автоматически включает USB audio. Выберите NanoKVM как устройство вывода звука на подключённом компьютере.', - audioMuted: 'Передача звука через RustDesk отключена.', - codec: 'Видеокодек', - clients: 'Максимум зрителей', - unavailable: - 'В настроенных APK-репозиториях нет RustDesk. Добавьте репозиторий с nanokvm-rustdesk или установите его APK через Packages.', - explain: - 'Подключитесь к этому ID из RustDesk, чтобы видеть HDMI и управлять USB-клавиатурой и мышью.', - video: - 'Видеокодек автоматически берётся из настроек видео NanoKVM. Для H.265 нужна поддержка клиента. После смены кодека устройства подключитесь заново. Перехват управления браузером освобождает ввод RustDesk.', - deletion: 'Удалить пакет? Настройки сервера, пароль и ID устройства сохранятся.', - failed: 'Запрос не выполнен', - saved: 'Настройки сохранены', - passwordUpdated: 'Пароль обновлён', - description: 'Удалённый доступ через RustDesk к компьютеру, подключённому к NanoKVM.', - done: 'Операция с пакетом завершена', - id: 'RustDesk ID', - source: 'Исходники и лицензия', - passwordRequired: 'Введите пароль от 8 до 64 байт', - sameServer: 'Укажите тот же собственный ID-сервер и публичный ключ в клиенте RustDesk.' - } -}; diff --git a/web/src/pages/desktop/menu/settings/software/rustdesk-versions.tsx b/web/src/pages/desktop/menu/settings/software/rustdesk-versions.tsx index c2d6bf74..8c5ac51d 100644 --- a/web/src/pages/desktop/menu/settings/software/rustdesk-versions.tsx +++ b/web/src/pages/desktop/menu/settings/software/rustdesk-versions.tsx @@ -2,18 +2,15 @@ import { useTranslation } from 'react-i18next'; import type { RustDeskStatus } from '@/api/rustdesk'; -import { rustDeskLabels } from './rustdesk-labels'; - export const RustDeskVersions = ({ status }: { status: RustDeskStatus }) => { - const { i18n } = useTranslation(); - const l = rustDeskLabels[(i18n.resolvedLanguage || i18n.language).startsWith('ru') ? 'ru' : 'en']; + const { t } = useTranslation('translation', { keyPrefix: 'settings.rustdesk' }); return (
- {l.addonVersion}: {status.version || l.unknownVersion} + {t('addonVersion')}: {status.version || t('unknownVersion')} - {l.rustdeskVersion}: {status.rustdesk_version || l.unknownVersion} + {t('rustdeskVersion')}: {status.rustdesk_version || t('unknownVersion')}
); diff --git a/web/src/pages/desktop/menu/settings/software/rustdesk.tsx b/web/src/pages/desktop/menu/settings/software/rustdesk.tsx index b9fbc4ed..55f336bd 100644 --- a/web/src/pages/desktop/menu/settings/software/rustdesk.tsx +++ b/web/src/pages/desktop/menu/settings/software/rustdesk.tsx @@ -10,12 +10,10 @@ import { rustDeskStatusAtom } from '@/jotai/rustdesk'; import { RustDeskIcon } from '@/components/icons/rustdesk'; import { AddonCard } from './addon-card'; -import { rustDeskLabels } from './rustdesk-labels'; import { RustDeskVersions } from './rustdesk-versions'; export const RustDeskAddon = ({ onOpen }: { onOpen: () => void }) => { - const { i18n } = useTranslation(); - const l = rustDeskLabels[(i18n.resolvedLanguage || i18n.language).startsWith('ru') ? 'ru' : 'en']; + const { t } = useTranslation('translation', { keyPrefix: 'settings.rustdesk' }); const [status, setStatus] = useAtom(rustDeskStatusAtom); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); @@ -40,11 +38,11 @@ export const RustDeskAddon = ({ onOpen }: { onOpen: () => void }) => { setStatus(response.data as RustDeskStatus); setError(''); } catch { - if (started === generation.current && !controller.signal.aborted) setError(l.failed); + if (started === generation.current && !controller.signal.aborted) setError(t('failed')); } finally { if (started === generation.current) pending.current = false; } - }, [setStatus, l.failed]); + }, [setStatus, t]); useEffect(() => { mounted.current = true; void refresh(); @@ -76,10 +74,10 @@ export const RustDeskAddon = ({ onOpen }: { onOpen: () => void }) => { setError(response.msg); return; } - message.success(l.done); + message.success(t('done')); setError(''); } catch { - if (mounted.current && started === generation.current) setError(l.failed); + if (mounted.current && started === generation.current) setError(t('failed')); } finally { working.current = false; if (mounted.current) { @@ -96,25 +94,25 @@ export const RustDeskAddon = ({ onOpen }: { onOpen: () => void }) => { <> {error && } {status && - (status.installed ? : {l.absent})} -

{l.description}

+ (status.installed ? : {t('absent')})} +

{t('description')}

{!status?.installed && status && !status.available && ( - + )}
{status?.installed ? ( <> {status.update_version && ( )} - operation('remove')}> + operation('remove')}> @@ -126,13 +124,13 @@ export const RustDeskAddon = ({ onOpen }: { onOpen: () => void }) => { disabled={!status?.available} onClick={() => void operation('install')} > - {l.install} + {t('install')} )}
{status?.installed && status.source_url && ( - {l.source} · AGPL-3.0 + {t('source')} · AGPL-3.0 )} From a63477c04b85b0d4fa660de486f5682e5bdda974 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:23:23 +0300 Subject: [PATCH 04/98] web: semantic colour tokens, themed static messages, settings building blocks - One set of semantic colours (fg, fg-muted, line, surface, success, warning, danger, info) in lib/theme-tokens.ts for antd and in @theme for Tailwind utilities; a test keeps them equal. fg-muted is neutral-400, the lowest contrast meant for information. - Static message, Modal.confirm and notification calls render outside the ConfigProvider and used the default light theme; holderRender gives them the app theme. - components/ui/settings.tsx: SettingsPage, SettingsSection, SettingRow, Panel, StatusBadge and IconButton for the settings pages, which each hand-build these today. Co-Authored-By: Claude Opus 5.5 --- web/src/assets/styles/index.css | 15 +++ web/src/components/app-theme.tsx | 46 ++++++--- web/src/components/ui/settings.tsx | 155 +++++++++++++++++++++++++++++ web/src/lib/theme-tokens.ts | 16 +++ web/tests/theme-tokens.test.mjs | 17 ++++ 5 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 web/src/components/ui/settings.tsx create mode 100644 web/src/lib/theme-tokens.ts create mode 100644 web/tests/theme-tokens.test.mjs diff --git a/web/src/assets/styles/index.css b/web/src/assets/styles/index.css index b5cf74be..156796a8 100644 --- a/web/src/assets/styles/index.css +++ b/web/src/assets/styles/index.css @@ -2,6 +2,21 @@ @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities); +/* Semantic colours (lib/theme-tokens.ts holds the same values for antd; a test + * keeps them equal): text-fg, text-fg-muted, border-line, bg-surface, + * bg-surface-raised, text-success / warning / danger / info. */ +@theme { + --color-fg: #e5e5e5; + --color-fg-muted: #a3a3a3; + --color-line: rgb(64 64 64 / 0.7); + --color-surface: rgb(38 38 38 / 0.4); + --color-surface-raised: rgb(38 38 38 / 0.7); + --color-success: #22c55e; + --color-warning: #fbbf24; + --color-danger: #f87171; + --color-info: #38bdf8; +} + /* Keep the established no-preflight policy for Ant Design and remote video. */ body { font-family: var(--font-sans); diff --git a/web/src/components/app-theme.tsx b/web/src/components/app-theme.tsx index d6f673cf..4ac20f30 100644 --- a/web/src/components/app-theme.tsx +++ b/web/src/components/app-theme.tsx @@ -1,28 +1,44 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, ReactNode, useEffect, useMemo } from 'react'; import { ConfigProvider, theme } from 'antd'; import { useAtomValue } from 'jotai'; +import { themeTokens } from '@/lib/theme-tokens'; import { brandingAtom, brandingButtonColor, buttonTextColor } from '@/jotai/branding'; export const AppTheme = ({ children }: PropsWithChildren) => { const branding = useAtomValue(brandingAtom); const colorPrimary = brandingButtonColor(branding); - const themeConfig = { - algorithm: theme.darkAlgorithm, - token: { - colorPrimary, - colorSuccess: '#22c55e' - }, - components: { - Button: { - primaryColor: buttonTextColor(colorPrimary) + const themeConfig = useMemo( + () => ({ + algorithm: theme.darkAlgorithm, + token: { + colorPrimary, + colorSuccess: themeTokens.success, + colorWarning: themeTokens.warning, + colorError: themeTokens.danger, + colorInfo: themeTokens.info, + colorTextDescription: themeTokens.fgMuted }, - Collapse: { - headerPadding: 0, - contentPadding: 0 + components: { + Button: { + primaryColor: buttonTextColor(colorPrimary) + }, + Collapse: { + headerPadding: 0, + contentPadding: 0 + } } - } - }; + }), + [colorPrimary] + ); + + // Static message, Modal.confirm and notification calls render outside this + // provider; give them the same dark theme and colours. + useEffect(() => { + ConfigProvider.config({ + holderRender: (node: ReactNode) => {node} + }); + }, [themeConfig]); return {children}; }; diff --git a/web/src/components/ui/settings.tsx b/web/src/components/ui/settings.tsx new file mode 100644 index 00000000..6ef29ac8 --- /dev/null +++ b/web/src/components/ui/settings.tsx @@ -0,0 +1,155 @@ +import { ReactNode } from 'react'; +import { Button, Divider, Tooltip } from 'antd'; +import clsx from 'clsx'; +import { CircleHelpIcon } from 'lucide-react'; + +// Building blocks for the settings pages. One page = SettingsPage; groups of +// related settings = SettingsSection; one setting = SettingRow (label and +// description on the left, the control on the right); bordered groups and +// lists = Panel. Colours come from the semantic tokens (lib/theme-tokens.ts). + +type SettingsPageProps = { + title: ReactNode; + description?: ReactNode; + // Page-level actions (for example a refresh button) next to the title. + actions?: ReactNode; + children: ReactNode; +}; + +export const SettingsPage = ({ title, description, actions, children }: SettingsPageProps) => ( +
+
+

{title}

+ {actions &&
{actions}
} +
+ {description &&

{description}

} + +
{children}
+
+); + +type SettingsSectionProps = { + title?: ReactNode; + description?: ReactNode; + children: ReactNode; +}; + +export const SettingsSection = ({ title, description, children }: SettingsSectionProps) => ( +
+ {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + {children} +
+); + +type SettingRowProps = { + label: ReactNode; + description?: ReactNode; + // Extra explanation behind a help icon, for details most people do not need. + help?: ReactNode; + // id of the control. The description gets the id -description; + // pass it to the control as aria-describedby. + htmlFor?: string; + // Put the control under the label (wide controls: long selects, inputs). + stacked?: boolean; + children: ReactNode; +}; + +export const SettingRow = ({ + label, + description, + help, + htmlFor, + stacked, + children +}: SettingRowProps) => { + const descriptionId = htmlFor ? `${htmlFor}-description` : undefined; + return ( +
+
+
+ {htmlFor ? : {label}} + {help && ( + + + + )} +
+ {description && ( +
+ {description} +
+ )} +
+
{children}
+
+ ); +}; + +type PanelProps = { + className?: string; + children: ReactNode; +}; + +export const Panel = ({ className, children }: PanelProps) => ( +
{children}
+); + +type Tone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'; + +const toneClass: Record = { + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + info: 'text-info', + neutral: 'text-fg-muted' +}; + +// A state shown as a coloured dot plus text, never colour alone. +export const StatusBadge = ({ tone, children }: { tone: Tone; children: ReactNode }) => ( + + + {children} + +); + +type IconButtonProps = { + // Accessible name and tooltip. + label: string; + icon: ReactNode; + onClick?: () => void; + danger?: boolean; + loading?: boolean; + disabled?: boolean; +}; + +export const IconButton = ({ + label, + icon, + onClick, + danger, + loading, + disabled +}: IconButtonProps) => ( + +
)) ?? '—'}
, - admin ? 'network' : undefined + admin ? 'network-general' : undefined )}
{admin && @@ -627,7 +627,7 @@ export const Dashboard = ({ navigate }: { navigate: (tab: string) => void }) => {line(t('dashboard.timeService'), extra.time?.daemon)} {line(t('dashboard.ntpServers'), extra.time?.config.servers.join(', '))} , - admin ? 'date-time' : undefined + admin ? 'system-date-time' : undefined )}
diff --git a/web/src/pages/desktop/menu/settings/index.tsx b/web/src/pages/desktop/menu/settings/index.tsx index 6e8452f9..5cbde3a9 100644 --- a/web/src/pages/desktop/menu/settings/index.tsx +++ b/web/src/pages/desktop/menu/settings/index.tsx @@ -373,7 +373,9 @@ export const Settings = ({ setSystemExpanded((expanded) => !expanded); return; } - const target = tab; + // An unknown or no longer available tab falls back to the dashboard + // instead of rendering an empty page. + const target = tabs.some((item) => item.id === tab) ? tab : 'dashboard'; if (target.startsWith('vpn-')) setVpnExpanded(true); if (target.startsWith('network-')) setNetworkExpanded(true); if (target.startsWith('software-')) setSoftwareExpanded(true); @@ -646,7 +648,7 @@ export const Settings = ({ fallback={} > }> - {tabs.find((tab) => tab.id === currentTab)?.component} + {(tabs.find((tab) => tab.id === currentTab) ?? tabs[0]).component}
From 7aaaeab2dba725a02e496a08270a47cfacea6e68 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:25:03 +0300 Subject: [PATCH 06/98] web: import only the AES module from crypto-js encrypt() uses a single passphrase-based AES call, but importing the crypto-js root bundled every hash and cipher. crypto-js/aes pulls in only AES and its MD5/EvpKDF/Base64/OpenSSL-format dependencies, so the output format is unchanged; a test checks that the full library still decrypts it and that both imports are byte-identical for a fixed salt. encrypt chunk: 69.5 kB -> 17.7 kB (gzip 24.1 -> 6.0 kB). Co-Authored-By: Claude Opus 5.5 --- web/src/lib/encrypt.ts | 6 ++++-- web/tests/encrypt.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 web/tests/encrypt.test.mjs diff --git a/web/src/lib/encrypt.ts b/web/src/lib/encrypt.ts index 912473ef..3343828a 100644 --- a/web/src/lib/encrypt.ts +++ b/web/src/lib/encrypt.ts @@ -1,9 +1,11 @@ -import CryptoJS from 'crypto-js'; +// Only the AES cipher (with its MD5/EvpKDF/Base64/OpenSSL-format dependencies) +// is needed; importing the crypto-js root would bundle every algorithm. +import AES from 'crypto-js/aes'; // This key is only used to prevent the data from being transmitted in plaintext. const SECRET_KEY = 'nanokvm-sipeed-2024'; export function encrypt(data: string) { - const dataEncrypt = CryptoJS.AES.encrypt(data, SECRET_KEY).toString(); + const dataEncrypt = AES.encrypt(data, SECRET_KEY).toString(); return encodeURIComponent(dataEncrypt); } diff --git a/web/tests/encrypt.test.mjs b/web/tests/encrypt.test.mjs new file mode 100644 index 00000000..62556d57 --- /dev/null +++ b/web/tests/encrypt.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +import { encrypt } from '../src/lib/encrypt.ts'; + +const require = createRequire(import.meta.url); +// The full crypto-js build, as the previous implementation imported it. +const CryptoJS = require('crypto-js'); +const AES = require('crypto-js/aes'); +const SECRET_KEY = 'nanokvm-sipeed-2024'; + +test('encrypt output is URI-encoded OpenSSL-salted AES that the full library decrypts', () => { + for (const input of ['admin', 'p@ss wörd/+=?&', '', 'пароль 🔑']) { + const encoded = encrypt(input); + const ciphertext = decodeURIComponent(encoded); + assert.equal(encoded, encodeURIComponent(ciphertext)); + // Base64 of the "Salted__" header, as CryptoJS.AES.encrypt(...).toString() emits. + assert.match(ciphertext, /^U2FsdGVkX1[A-Za-z0-9+/]+=*$/); + const plain = CryptoJS.AES.decrypt(ciphertext, SECRET_KEY).toString(CryptoJS.enc.Utf8); + assert.equal(plain, input); + } +}); + +test('the modular AES import matches the full crypto-js build byte for byte', () => { + const salt = CryptoJS.enc.Hex.parse('0011223344556677'); + const full = CryptoJS.AES.encrypt('secret', SECRET_KEY, { salt }).toString(); + const modular = AES.encrypt('secret', SECRET_KEY, { salt }).toString(); + assert.equal(modular, full); +}); From 48b8f06deae7e45bfdd510d4ac5bdd068ebd9180 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:25:38 +0300 Subject: [PATCH 07/98] server, web: persist MJPEG frame detect on the device Frame detect is a device-wide encoder setting, but it had no source of truth: the server only pushed it into the native encoder (which starts with it off after every restart), any signed-in user could change it, and each browser remembered its own copy in localStorage (nano-kvm-frame-detect). The settings dialog could therefore show it as on while the device had it off, and the MJPEG player paused frame detect based on that stale local copy. Server: - Save the choice in /etc/kvm/frame_detect (like the other /etc/kvm settings) and apply it when KvmVision is initialized. - Return it as `frameDetect` from GET /api/vm/screen. - Move POST /api/stream/mjpeg/detect into the admin group; the temporary pause (/detect/stop) stays available to viewers, does nothing when frame detect is off and restores the saved choice afterwards instead of always re-enabling it. Web: - Read frameDetect from GET /api/vm/screen; applying the setting no longer writes localStorage, and the key's getter and setter are gone. - The MJPEG player always asks for the short pause and lets the server decide whether frame detect is on. Add a router test for the admin-only update and its persistence. Co-Authored-By: Claude Opus 5.5 --- server/common/frame_detect.go | 38 +++++++++ server/common/kvm_vision.go | 1 + server/router/stream.go | 7 +- server/router/stream_frame_detect_test.go | 77 +++++++++++++++++++ server/service/stream/mjpeg/frame-detect.go | 21 +++-- server/service/vm/screen.go | 1 + web/src/lib/localstorage.ts | 10 --- .../desktop/menu/settings/video/apply.ts | 9 +-- .../menu/settings/video/use-video-settings.ts | 4 +- web/src/pages/desktop/screen/mjpeg.tsx | 9 +-- web/tests/video-permissions.test.mjs | 1 - 11 files changed, 144 insertions(+), 34 deletions(-) create mode 100644 server/common/frame_detect.go create mode 100644 server/router/stream_frame_detect_test.go diff --git a/server/common/frame_detect.go b/server/common/frame_detect.go new file mode 100644 index 00000000..3b0bc7df --- /dev/null +++ b/server/common/frame_detect.go @@ -0,0 +1,38 @@ +package common + +import ( + "os" + "strings" +) + +// FrameDetectFile keeps the MJPEG frame-detect choice across restarts. The +// native encoder starts with frame detection off, so the saved choice is +// applied when KvmVision is initialized. +var FrameDetectFile = "/etc/kvm/frame_detect" + +// FrameDetectInterval is the number of unchanged frames after which the +// encoder stops sending MJPEG frames while frame detection is enabled. +const FrameDetectInterval uint8 = 60 + +// FrameDetectEnabled reports the saved choice; it is off when unset. +func FrameDetectEnabled() bool { + data, err := os.ReadFile(FrameDetectFile) + return err == nil && strings.TrimSpace(string(data)) == "1" +} + +// SaveFrameDetect persists the choice. Callers apply it to the encoder. +func SaveFrameDetect(enabled bool) error { + value := "0\n" + if enabled { + value = "1\n" + } + return os.WriteFile(FrameDetectFile, []byte(value), 0o644) +} + +// FrameDetectFrames is the encoder setting for a frame-detect choice. +func FrameDetectFrames(enabled bool) uint8 { + if enabled { + return FrameDetectInterval + } + return 0 +} diff --git a/server/common/kvm_vision.go b/server/common/kvm_vision.go index 0fa89120..8370ea04 100644 --- a/server/common/kvm_vision.go +++ b/server/common/kvm_vision.go @@ -63,6 +63,7 @@ func GetKvmVision() *KvmVision { if C.set_mjpeg_chroma(C.uint8_t(boolToInt(GetScreen().MjpegChroma == 422))) != 0 { log.Error("failed to select saved MJPEG chroma") } + C.set_frame_detact(C.uint8_t(FrameDetectFrames(FrameDetectEnabled()))) log.Debugf("kvm vision initialized") }) diff --git a/server/router/stream.go b/server/router/stream.go index fafab00a..f84fc7c1 100644 --- a/server/router/stream.go +++ b/server/router/stream.go @@ -1,6 +1,7 @@ package router import ( + "NanoKVM-Server/authn" "NanoKVM-Server/middleware" "NanoKVM-Server/service/stream" "NanoKVM-Server/service/stream/audio" @@ -13,9 +14,13 @@ import ( func streamRouter(r *gin.Engine) { api := r.Group("/api").Use(middleware.CheckToken()) + admin := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(authn.RoleAdmin), + ) api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream - api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect + admin.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect (device-wide) api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect api.GET("/stream/state", stream.GetEncoderState) diff --git a/server/router/stream_frame_detect_test.go b/server/router/stream_frame_detect_test.go new file mode 100644 index 00000000..8f4fd552 --- /dev/null +++ b/server/router/stream_frame_detect_test.go @@ -0,0 +1,77 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "NanoKVM-Server/authn" + "NanoKVM-Server/common" + "NanoKVM-Server/middleware" + "github.com/gin-gonic/gin" +) + +func TestFrameDetectIsAdminOnlyAndPersisted(t *testing.T) { + gin.SetMode(gin.TestMode) + store := authn.NewStore(filepath.Join(t.TempDir(), "accounts.json")) + admin, err := store.SetPassword("admin", "owner-password") + if err != nil { + t.Fatalf("admin account: %v", err) + } + if err := store.Create("viewer", "valid-password", authn.RoleUser); err != nil { + t.Fatal(err) + } + viewer, err := store.Get("viewer") + if err != nil { + t.Fatal(err) + } + oldStore := authn.DefaultStore + authn.DefaultStore = store + t.Cleanup(func() { authn.DefaultStore = oldStore }) + oldFile := common.FrameDetectFile + common.FrameDetectFile = filepath.Join(t.TempDir(), "frame_detect") + t.Cleanup(func() { common.FrameDetectFile = oldFile }) + adminToken, err := middleware.GenerateJWT(admin.Username, admin.TokenVersion) + if err != nil { + t.Fatal(err) + } + viewerToken, err := middleware.GenerateJWT(viewer.Username, viewer.TokenVersion) + if err != nil { + t.Fatal(err) + } + r := gin.New() + streamRouter(r) + + post := func(token, body string) int { + req := httptest.NewRequest(http.MethodPost, "/api/stream/mjpeg/detect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + writer := httptest.NewRecorder() + r.ServeHTTP(writer, req) + return writer.Code + } + + if common.FrameDetectEnabled() { + t.Fatal("frame detect must default to off when nothing is saved") + } + if status := post(viewerToken, `{"enabled":true}`); status != http.StatusForbidden { + t.Fatalf("viewer update = %d, want 403", status) + } + if common.FrameDetectEnabled() { + t.Fatal("a rejected viewer request changed the saved choice") + } + if status := post(adminToken, `{"enabled":true}`); status != http.StatusOK { + t.Fatalf("admin enable = %d, want 200", status) + } + if !common.FrameDetectEnabled() { + t.Fatal("enabling frame detect was not persisted") + } + if status := post(adminToken, `{"enabled":false}`); status != http.StatusOK { + t.Fatalf("admin disable = %d, want 200", status) + } + if common.FrameDetectEnabled() { + t.Fatal("disabling frame detect was not persisted") + } +} diff --git a/server/service/stream/mjpeg/frame-detect.go b/server/service/stream/mjpeg/frame-detect.go index ac2d8df0..1af2eb93 100644 --- a/server/service/stream/mjpeg/frame-detect.go +++ b/server/service/stream/mjpeg/frame-detect.go @@ -9,8 +9,7 @@ import ( log "github.com/sirupsen/logrus" ) -const FrameDetectInterval uint8 = 60 - +// UpdateFrameDetect saves the device-wide frame-detect choice and applies it. func UpdateFrameDetect(c *gin.Context) { var req proto.UpdateFrameDetectReq var rsp proto.Response @@ -20,12 +19,13 @@ func UpdateFrameDetect(c *gin.Context) { return } - var frame uint8 = 0 - if req.Enabled { - frame = FrameDetectInterval + if err := common.SaveFrameDetect(req.Enabled); err != nil { + log.Errorf("save frame detect failed: %s", err) + rsp.ErrRsp(c, -2, "save frame detect failed") + return } - common.GetKvmVision().SetFrameDetect(frame) + common.GetKvmVision().SetFrameDetect(common.FrameDetectFrames(req.Enabled)) rsp.OkRsp(c) log.Debugf("update frame detect: %t", req.Enabled) @@ -40,6 +40,12 @@ func StopFrameDetect(c *gin.Context) { return } + // Nothing to pause, and restoring would enable it against the saved choice. + if !common.FrameDetectEnabled() { + rsp.OkRsp(c) + return + } + duration := 10 * time.Second if req.Duration > 0 { duration = time.Duration(req.Duration) * time.Second @@ -49,7 +55,8 @@ func StopFrameDetect(c *gin.Context) { vision.SetFrameDetect(0) time.Sleep(duration) - vision.SetFrameDetect(FrameDetectInterval) + // Restore the saved choice, which an administrator may have changed meanwhile. + vision.SetFrameDetect(common.FrameDetectFrames(common.FrameDetectEnabled())) rsp.OkRsp(c) } diff --git a/server/service/vm/screen.go b/server/service/vm/screen.go index b512b529..bd6a1a1e 100644 --- a/server/service/vm/screen.go +++ b/server/service/vm/screen.go @@ -63,6 +63,7 @@ func (s *Service) GetScreen(c *gin.Context) { "videoOutputWidth": common.ReadVideoValue("/run/nanokvm/video_width"), "videoOutputHeight": common.ReadVideoValue("/run/nanokvm/video_height"), "measuredFps": common.ReadVideoValue("/run/nanokvm/now_fps"), + "frameDetect": common.FrameDetectEnabled(), "effectiveFps": common.GetCaptureScreen().FPS}) } diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index 3db1a73e..90afa1ce 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -7,7 +7,6 @@ const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution'; const FPS_KEY = 'nano-kvm-fps'; const QUALITY_KEY = 'nano-kvm-quality'; const GOP_KEY = 'nano-kvm-gop'; -const FRAME_DETECT_KEY = 'nano-kvm-frame-detect'; const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style'; const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode'; const INPUT_ADAPTER_KEY = 'nano-kvm-input-adapter'; @@ -123,15 +122,6 @@ export function setGop(gop: number) { localStorage.setItem(GOP_KEY, String(gop)); } -export function getFrameDetect(): boolean { - const enabled = localStorage.getItem(FRAME_DETECT_KEY); - return enabled === 'true'; -} - -export function setFrameDetect(enabled: boolean) { - localStorage.setItem(FRAME_DETECT_KEY, String(enabled)); -} - export function getMouseStyle() { return localStorage.getItem(MOUSE_STYLE_KEY); } diff --git a/web/src/pages/desktop/menu/settings/video/apply.ts b/web/src/pages/desktop/menu/settings/video/apply.ts index 89d87a5c..ad239ed3 100644 --- a/web/src/pages/desktop/menu/settings/video/apply.ts +++ b/web/src/pages/desktop/menu/settings/video/apply.ts @@ -18,13 +18,7 @@ export type ApplyDeps = { setEncoderCodec: typeof setEncoderCodec; storage: Pick< typeof storage, - | 'setVideoMode' - | 'setDirectPlayback' - | 'setFrameDetect' - | 'setResolution' - | 'setFps' - | 'setGop' - | 'setQuality' + 'setVideoMode' | 'setDirectPlayback' | 'setResolution' | 'setFps' | 'setGop' | 'setQuality' >; reload: () => void; clearPlaybackOverrides: () => void; @@ -89,7 +83,6 @@ export async function applyVideoDraft( if (draft.frameDetect !== saved.frameDetect) { const rsp = await deps.updateFrameDetect(draft.frameDetect); if (rsp.code !== 0) throw new Error(rsp.msg || 'video-settings-failed'); - deps.storage.setFrameDetect(draft.frameDetect); } const width = caps.stream.limits.find((l) => l.height === draft.height)?.width ?? 0; if (draft.height !== saved.height) deps.storage.setResolution({ width, height: draft.height }); diff --git a/web/src/pages/desktop/menu/settings/video/use-video-settings.ts b/web/src/pages/desktop/menu/settings/video/use-video-settings.ts index 9f482410..183cfc9f 100644 --- a/web/src/pages/desktop/menu/settings/video/use-video-settings.ts +++ b/web/src/pages/desktop/menu/settings/video/use-video-settings.ts @@ -45,6 +45,8 @@ export type ScreenStatus = { videoOutputHeight: number; effectiveFps: number; measuredFps: number; + // Device-wide MJPEG frame detection, saved by the server. + frameDetect: boolean; }; const transportOf = (mode: string): Transport => @@ -64,7 +66,7 @@ export function savedDraft(status: ScreenStatus, mode: string): VideoDraft { gopMode: status.gopMode, mjpegChroma: status.mjpegChroma === 422 ? 422 : 420, directPlayback: storage.getDirectPlayback(), - frameDetect: storage.getFrameDetect() + frameDetect: status.frameDetect === true }; } diff --git a/web/src/pages/desktop/screen/mjpeg.tsx b/web/src/pages/desktop/screen/mjpeg.tsx index 6facf0f4..f3c3c1c9 100644 --- a/web/src/pages/desktop/screen/mjpeg.tsx +++ b/web/src/pages/desktop/screen/mjpeg.tsx @@ -3,7 +3,6 @@ import clsx from 'clsx'; import { useAtomValue, useSetAtom } from 'jotai'; import { stopFrameDetect } from '@/api/stream.ts'; -import { getFrameDetect } from '@/lib/localstorage.ts'; import { captureMediaElement } from '@/lib/screenshot.ts'; import { getBaseUrl } from '@/lib/service.ts'; import { mouseStyleAtom } from '@/jotai/mouse.ts'; @@ -60,11 +59,9 @@ export const Mjpeg = () => { useEffect(() => () => setScreenshotSource(null), [setScreenshotSource]); useEffect(() => { - // stop frame detect for a while - const enabled = getFrameDetect(); - if (enabled) { - stopFrameDetect(10); - } + // Pause frame detect for a while so the first frames arrive. The server + // ignores this when frame detect is off, so it needs no local state. + void stopFrameDetect(10).catch(() => {}); window.clearTimeout(retryTimer.current); retryTimer.current = undefined; retryDelay.current = INITIAL_RETRY_DELAY; diff --git a/web/tests/video-permissions.test.mjs b/web/tests/video-permissions.test.mjs index 78586805..05286521 100644 --- a/web/tests/video-permissions.test.mjs +++ b/web/tests/video-permissions.test.mjs @@ -105,7 +105,6 @@ function applier() { storage: { setVideoMode: (value) => calls.local.push(['mode', value]), setDirectPlayback: (value) => calls.local.push(['playback', value]), - setFrameDetect: () => assert.fail('viewer must not persist frame detection'), setResolution: () => {}, setFps: () => {}, setGop: () => {}, From 93b7b47cd32584d5a8b672c5bec42bb4807723c8 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:26:47 +0300 Subject: [PATCH 08/98] web: apply the device time format where device times are shown The date and time settings promise that the 12/24-hour format is shared across the interface, including VPN handshake times, but useDeviceTime() was never called and timePreferencesAtom was read nowhere, so the format only affected the preview in the settings page. Wiring it is small, so do that rather than removing the promise: load the preferences once at the desktop root (re-polled every minute, and only replacing the atom value when it changes), show the handshake time in the device time zone and format as the tooltip of the relative age, and format the system log, boot and diagnostics timestamps with it instead of the browser's locale defaults. Co-Authored-By: Claude Opus 5.5 --- web/src/components/handshake-age.tsx | 12 ++++++++++-- web/src/hooks/useDeviceTime.ts | 9 ++++++++- web/src/pages/desktop/index.tsx | 3 +++ .../desktop/menu/settings/system/diagnostics.tsx | 6 +++++- web/src/pages/desktop/menu/settings/system/logs.tsx | 8 ++++++-- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/src/components/handshake-age.tsx b/web/src/components/handshake-age.tsx index be891d58..e3e1c1c0 100644 --- a/web/src/components/handshake-age.tsx +++ b/web/src/components/handshake-age.tsx @@ -1,9 +1,14 @@ import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; +import { formatDeviceTime } from '@/lib/date-time.ts'; +import { timePreferencesAtom } from '@/hooks/useDeviceTime.ts'; + // Anchor to the device clock, then advance locally without polling every second. export function HandshakeAge({ timestamp, serverNow }: { timestamp: number; serverNow?: number }) { const { i18n } = useTranslation(); + const preferences = useAtomValue(timePreferencesAtom); const [sample, setSample] = useState(() => ({ now: serverNow ?? Date.now(), at: performance.now() @@ -22,11 +27,14 @@ export function HandshakeAge({ timestamp, serverNow }: { timestamp: number; serv seconds < 60 ? 'second' : seconds < 3600 ? 'minute' : seconds < 86400 ? 'hour' : 'day'; const divisor = { second: 1, minute: 60, hour: 3600, day: 86400 }[unit]; return ( - <> + ); } diff --git a/web/src/hooks/useDeviceTime.ts b/web/src/hooks/useDeviceTime.ts index ad3f6251..5a4d0115 100644 --- a/web/src/hooks/useDeviceTime.ts +++ b/web/src/hooks/useDeviceTime.ts @@ -15,7 +15,14 @@ export function useDeviceTime() { void http .get('/api/vm/date-time') .then((rsp) => { - if (mounted && rsp.code === 0) setPreferences(rsp.data.config); + if (!mounted || rsp.code !== 0) return; + const { timezone, format } = rsp.data.config as TimePreferences; + // Keep the same object while nothing changed, so readers do not re-render. + setPreferences((current) => + current.timezone === timezone && current.format === format + ? current + : { timezone, format } + ); }) .catch(() => { /* Keep the last known format during a disconnect. */ diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index 75cd0689..68f7cb34 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -26,6 +26,7 @@ import { videoModeAtom } from '@/jotai/screen.ts'; import { usbInputAtom } from '@/jotai/usb-input.ts'; +import { useDeviceTime } from '@/hooks/useDeviceTime.ts'; import { Head } from '@/components/head.tsx'; import { CaptureStatusOverlay, useCaptureStatus } from './capture-status'; @@ -94,6 +95,8 @@ export const Desktop = () => { if (!usbInput.keyboard) setKeyboardOpen(false); }, [usbInput.keyboard, setKeyboardOpen]); const { t } = useTranslation(); + // Load the device time format once for every displayed device time. + useDeviceTime(); const isBigScreen = useMediaQuery({ minWidth: 850 }); const [activeVideoMode] = useState(getVideoMode); const [encoderReady, setEncoderReady] = useState(false); diff --git a/web/src/pages/desktop/menu/settings/system/diagnostics.tsx b/web/src/pages/desktop/menu/settings/system/diagnostics.tsx index 0d69176e..4d997d4f 100644 --- a/web/src/pages/desktop/menu/settings/system/diagnostics.tsx +++ b/web/src/pages/desktop/menu/settings/system/diagnostics.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; import { Alert, Button, Spin, Tabs } from 'antd'; +import { useAtomValue } from 'jotai'; import { DownloadIcon, RefreshCwIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { downloadDiagnosticsReport, getDiagnostics } from '@/api/vm'; +import { formatDeviceTime } from '@/lib/date-time.ts'; +import { timePreferencesAtom } from '@/hooks/useDeviceTime.ts'; import { Logs } from './logs'; @@ -48,6 +51,7 @@ const dash = (value?: string | null) => value || '—'; const DiagnosticsStatus = () => { const { t, i18n } = useTranslation(); + const timePreferences = useAtomValue(timePreferencesAtom); const [snapshot, setSnapshot] = useState(); const [error, setError] = useState(); const [loading, setLoading] = useState(true); @@ -156,7 +160,7 @@ const DiagnosticsStatus = () => { <>

{t('settings.system.diagnostics.collected', { - time: new Date(snapshot.collectedAt).toLocaleString(i18n.language) + time: formatDeviceTime(snapshot.collectedAt, timePreferences, i18n.language, true) })}

diff --git a/web/src/pages/desktop/menu/settings/system/logs.tsx b/web/src/pages/desktop/menu/settings/system/logs.tsx index 9ca97efc..7f7aa61c 100644 --- a/web/src/pages/desktop/menu/settings/system/logs.tsx +++ b/web/src/pages/desktop/menu/settings/system/logs.tsx @@ -1,5 +1,6 @@ import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Button, Input, Select, Switch } from 'antd'; +import { useAtomValue } from 'jotai'; import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -11,11 +12,14 @@ import { type LogSnapshot, type LogSource } from '@/api/logs'; +import { formatDeviceTime } from '@/lib/date-time.ts'; +import { timePreferencesAtom } from '@/hooks/useDeviceTime.ts'; const refreshInterval = 5000; export const Logs = () => { const { t, i18n } = useTranslation(); + const timePreferences = useAtomValue(timePreferencesAtom); const [source, setSource] = useState('system'); const [boot, setBoot] = useState('current'); const [boots, setBoots] = useState([{ id: 'current', startedAt: 0, savedAt: 0 }]); @@ -151,7 +155,7 @@ export const Logs = () => { }, [activeRow, snapshot, follow, boot]); const key = 'settings.system.logs'; - const timestamp = (at: number) => new Date(at).toLocaleTimeString(i18n.language); + const timestamp = (at: number) => formatDeviceTime(at, timePreferences, i18n.language); const sourceChanged = (value: LogSource) => { setSource(value); setMatchIndex(0); @@ -188,7 +192,7 @@ export const Logs = () => { label: t(`${key}.boots.${item.id}`) + (item.id !== 'current' && item.startedAt - ? ' · ' + new Date(item.startedAt).toLocaleString(i18n.language) + ? ' · ' + formatDeviceTime(item.startedAt, timePreferences, i18n.language, true) : '') }))} /> From fc8d29dfab65e0352001b1eca01295ebc6f4d1e9 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:26:49 +0300 Subject: [PATCH 09/98] web: use the native WebSocket instead of the websocket package In browsers the package only re-exports the native WebSocket through its w3cwebsocket shim (its constructor returns the native instance), so WsClient and the WebRTC signalling socket behave identically with WebSocket directly: same onopen/onclose/onerror/onmessage handlers, binaryType, reconnect and heartbeat logic. The shim and its es5-ext dependency leave the bundle (websocket chunk 7.5 kB -> 5.1 kB), and websocket/@types/websocket are removed with pnpm remove. @types/websocket was also what pulled Node types into the program, so the one Node-only test under src now references them explicitly. Tests that injected the shim now inject a WebSocket stub instead. Co-Authored-By: Claude Opus 5.5 --- web/package.json | 2 - web/pnpm-lock.yaml | 160 ------------------ web/src/lib/websocket.ts | 20 +-- .../settings/network/wifi-networks.test.ts | 1 + web/src/pages/desktop/screen/h264-webrtc.tsx | 3 +- web/tests/capture-input.test.mjs | 5 +- web/tests/input-connection.test.mjs | 2 +- 7 files changed, 15 insertions(+), 178 deletions(-) diff --git a/web/package.json b/web/package.json index 7fc79b67..1d2a0b27 100644 --- a/web/package.json +++ b/web/package.json @@ -43,7 +43,6 @@ "react-simple-keyboard": "3.8.278", "remark-gfm": "4.0.1", "vaul": "1.1.2", - "websocket": "1.0.35", "yocto-queue": "1.2.2" }, "devDependencies": { @@ -54,7 +53,6 @@ "@types/node": "24.19.1", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@types/websocket": "1.0.10", "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", "@typescript/native": "npm:typescript@7.0.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 044f7cea..e8435812 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: vaul: specifier: 1.1.2 version: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - websocket: - specifier: 1.0.35 - version: 1.0.35 yocto-queue: specifier: 1.2.2 version: 1.2.2 @@ -111,9 +108,6 @@ importers: '@types/react-dom': specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) - '@types/websocket': - specifier: 1.0.10 - version: 1.0.10 '@typescript-eslint/eslint-plugin': specifier: 8.71.0 version: 8.71.0(@typescript-eslint/parser@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)) @@ -1314,9 +1308,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/websocket@1.0.10': - resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==} - '@typescript-eslint/eslint-plugin@8.71.0': resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1597,10 +1588,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bufferutil@4.1.0: - resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} - engines: {node: '>=6.14.2'} - cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} @@ -1673,21 +1660,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - d@1.0.2: - resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} - engines: {node: '>=0.12'} - dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1751,17 +1726,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es5-ext@0.10.64: - resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} - engines: {node: '>=0.10'} - - es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - - es6-symbol@3.1.4: - resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} - engines: {node: '>=0.12'} - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -1818,10 +1782,6 @@ packages: jiti: optional: true - esniff@2.0.1: - resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} - engines: {node: '>=0.10'} - espree@11.2.0: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1845,12 +1805,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-emitter@0.3.5: - resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - - ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2067,9 +2021,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2446,9 +2397,6 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2480,13 +2428,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - node-releases@2.0.57: resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} @@ -2887,12 +2828,6 @@ packages: resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} - type@2.7.3: - resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - - typedarray-to-buffer@3.1.5: - resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typescript-eslint@8.71.0: resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2968,10 +2903,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} - engines: {node: '>=6.14.2'} - vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -3027,10 +2958,6 @@ packages: yaml: optional: true - websocket@1.0.35: - resolution: {integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==} - engines: {node: '>=4.0.0'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3048,11 +2975,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yaeti@0.0.6: - resolution: {integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==} - engines: {node: '>=0.10.32'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4138,10 +4060,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/websocket@1.0.10': - dependencies: - '@types/node': 24.19.1 - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0))': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4433,10 +4351,6 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) - bufferutil@4.1.0: - dependencies: - node-gyp-build: 4.8.4 - cacheable@2.5.0: dependencies: '@cacheable/memory': 2.2.0 @@ -4498,17 +4412,8 @@ snapshots: csstype@3.2.3: {} - d@1.0.2: - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - dayjs@1.11.23: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4561,24 +4466,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es5-ext@0.10.64: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - - es6-symbol@3.1.4: - dependencies: - d: 1.0.2 - ext: 1.7.0 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -4681,13 +4568,6 @@ snapshots: transitivePeerDependencies: - supports-color - esniff@2.0.1: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - espree@11.2.0: dependencies: acorn: 8.18.0 @@ -4708,15 +4588,6 @@ snapshots: esutils@2.0.3: {} - event-emitter@0.3.5: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - - ext@1.7.0: - dependencies: - type: 2.7.3 - extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4919,8 +4790,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-typedarray@1.0.0: {} - isexe@2.0.0: {} jiti@2.7.0: {} @@ -5445,8 +5314,6 @@ snapshots: dependencies: brace-expansion: 5.0.12 - ms@2.0.0: {} - ms@2.1.3: {} msw@3.0.1(@types/node@24.19.1)(@typescript/typescript6@6.0.2)(graphql@16.14.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): @@ -5477,10 +5344,6 @@ snapshots: natural-compare@1.4.0: {} - next-tick@1.1.0: {} - - node-gyp-build@4.8.4: {} - node-releases@2.0.57: {} object-assign@4.1.1: {} @@ -5838,12 +5701,6 @@ snapshots: dependencies: tagged-tag: 1.0.0 - type@2.7.3: {} - - typedarray-to-buffer@3.1.5: - dependencies: - is-typedarray: 1.0.0 - typescript-eslint@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)): dependencies: '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)) @@ -5946,10 +5803,6 @@ snapshots: dependencies: react: 19.3.0 - utf-8-validate@5.0.10: - dependencies: - node-gyp-build: 4.8.4 - vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -5984,17 +5837,6 @@ snapshots: tsx: 4.23.15 yaml: 2.9.1 - websocket@1.0.35: - dependencies: - bufferutil: 4.1.0 - debug: 2.6.9 - es5-ext: 0.10.64 - typedarray-to-buffer: 3.1.5 - utf-8-validate: 5.0.10 - yaeti: 0.0.6 - transitivePeerDependencies: - - supports-color - which@2.0.2: dependencies: isexe: 2.0.0 @@ -6009,8 +5851,6 @@ snapshots: y18n@5.0.8: {} - yaeti@0.0.6: {} - yallist@3.1.1: {} yaml@2.9.1: {} diff --git a/web/src/lib/websocket.ts b/web/src/lib/websocket.ts index 9b10f799..23011d8e 100644 --- a/web/src/lib/websocket.ts +++ b/web/src/lib/websocket.ts @@ -1,5 +1,3 @@ -import { ICloseEvent, IMessageEvent, w3cwebsocket as W3cWebSocket } from 'websocket'; - import { notifyAuthExpired } from '@/lib/auth-events.ts'; import { getBaseUrl } from '@/lib/service.ts'; @@ -10,7 +8,9 @@ export type InputConnectionStatus = | 'reconnecting' | 'disconnected'; -type MessageHandler = (message: IMessageEvent) => void; +// The exported MessageEvent enum below shadows the DOM type of the same name. +type SocketMessageEvent = globalThis.MessageEvent; +type MessageHandler = (message: SocketMessageEvent) => void; type SendData = number[] | ArrayBuffer | Uint8Array; export enum MessageEvent { @@ -36,7 +36,7 @@ const DEFAULT_OPTIONS: Required = { export class WsClient { private readonly options: Required; - private instance: W3cWebSocket | null = null; + private instance: WebSocket | null = null; private heartbeatTimer: ReturnType | null = null; private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; @@ -181,7 +181,7 @@ export class WsClient { } if (data instanceof ArrayBuffer || (data as unknown) instanceof Uint8Array) { - this.instance.send(data); + this.instance.send(data as BufferSource); } else { this.instance.send(JSON.stringify(data)); } @@ -190,7 +190,7 @@ export class WsClient { } public get isConnected(): boolean { - return this.instance?.readyState === W3cWebSocket.OPEN; + return this.instance?.readyState === WebSocket.OPEN; } private createConnection(): void { @@ -199,7 +199,7 @@ export class WsClient { const previous = this.instance; this.instance = null; previous?.close(); - const socket = new W3cWebSocket(this.options.url); + const socket = new WebSocket(this.options.url); this.instance = socket; socket.binaryType = 'arraybuffer'; @@ -226,7 +226,7 @@ export class WsClient { this.startHeartbeat(); } - private handleClose(event: ICloseEvent): void { + private handleClose(event: CloseEvent): void { this.stopHeartbeat(); this.inputReports.clear(); this.setControlEnabled(false); @@ -242,11 +242,11 @@ export class WsClient { this.scheduleReconnect(); } - private handleError(error: Error): void { + private handleError(error: Event): void { console.error('[WebSocket] Error:', error); } - private handleMessage(message: IMessageEvent): void { + private handleMessage(message: SocketMessageEvent): void { try { const data = JSON.parse(message.data as string); this.lastResponseAt = Date.now(); diff --git a/web/src/pages/desktop/menu/settings/network/wifi-networks.test.ts b/web/src/pages/desktop/menu/settings/network/wifi-networks.test.ts index 74a0e826..f351cbcb 100644 --- a/web/src/pages/desktop/menu/settings/network/wifi-networks.test.ts +++ b/web/src/pages/desktop/menu/settings/network/wifi-networks.test.ts @@ -1,3 +1,4 @@ +/// import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/web/src/pages/desktop/screen/h264-webrtc.tsx b/web/src/pages/desktop/screen/h264-webrtc.tsx index 5e3252f6..e2bbf60c 100644 --- a/web/src/pages/desktop/screen/h264-webrtc.tsx +++ b/web/src/pages/desktop/screen/h264-webrtc.tsx @@ -3,7 +3,6 @@ import { notification, Spin } from 'antd'; import clsx from 'clsx'; import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; -import { w3cwebsocket as W3cWebSocket } from 'websocket'; import { encoderCodecQuery, getEncoderCodec, supportsWebRTCH265 } from '@/lib/encoder.ts'; import { captureMediaElement } from '@/lib/screenshot.ts'; @@ -68,7 +67,7 @@ export const H264Webrtc = ({ const codec = requestedCodec === 'h265' && !supportsWebRTCH265() ? 'h264' : requestedCodec; const query = encoderCodecQuery(codec); const url = `${getBaseUrl('ws')}/api/stream/video?${query}`; - const ws = new W3cWebSocket(url); + const ws = new WebSocket(url); const videoElement = videoRef.current; let video: RTCPeerConnection | null = null; diff --git a/web/tests/capture-input.test.mjs b/web/tests/capture-input.test.mjs index a5bc9420..e7030dfb 100755 --- a/web/tests/capture-input.test.mjs +++ b/web/tests/capture-input.test.mjs @@ -13,12 +13,11 @@ class Socket { const source = readFileSync(new URL('../src/lib/websocket.ts', import.meta.url), 'utf8'); const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; const exports = {}; -new Function('exports', 'require', js)(exports, name => { - if (name === 'websocket') return { w3cwebsocket: Socket }; +new Function('exports', 'require', 'WebSocket', js)(exports, name => { if (name.endsWith('service.ts')) return { getBaseUrl: () => 'ws://test' }; if (name.endsWith('auth-events.ts')) return { notifyAuthExpired() {} }; throw new Error(name); -}); +}, Socket); test('capture input gate releases held controls, blocks all input representations and preserves heartbeat', () => { const c = new exports.WsClient(); diff --git a/web/tests/input-connection.test.mjs b/web/tests/input-connection.test.mjs index ff859edb..cfffcaa2 100644 --- a/web/tests/input-connection.test.mjs +++ b/web/tests/input-connection.test.mjs @@ -42,7 +42,7 @@ function fixture() { }).outputText; const sandbox = { exports: {}, - W3cWebSocket: Socket, + WebSocket: Socket, getBaseUrl: () => 'ws://test', notifyAuthExpired: () => expired++, console: { log() {}, error() {} }, From 3410844f748d482d5f70d1f9e0e0b8df064bcdd6 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:27:34 +0300 Subject: [PATCH 10/98] web: publish polled input region geometry only when it changes ManualRegion recomputed the centered input region every 250 ms and always stored a new object in inputRegionAtom, which the desktop root reads, so the whole desktop re-rendered four times a second while a manual region was selected. The region selector overlay did the same with its media size and frame rectangle. Keep the previous object when the geometry is unchanged (shallow comparison in isSameGeometry) and add a test for it. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/desktop/screen/geometry.ts | 9 +++++++++ .../desktop/screen/input-region-overlay.tsx | 13 ++++++++++--- web/src/pages/desktop/screen/manual-region.tsx | 8 ++++---- web/tests/geometry-equality.test.mjs | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 web/tests/geometry-equality.test.mjs diff --git a/web/src/pages/desktop/screen/geometry.ts b/web/src/pages/desktop/screen/geometry.ts index bf8290c9..6732d537 100644 --- a/web/src/pages/desktop/screen/geometry.ts +++ b/web/src/pages/desktop/screen/geometry.ts @@ -27,6 +27,15 @@ export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({ height: mediaSize.height }); +// Equal plain geometry objects (sizes, rects, regions). Polling keeps the +// previous state object while nothing changed, so readers do not re-render. +export function isSameGeometry(a: T | null, b: T | null) { + if (a === b) return true; + if (!a || !b) return false; + const keys = Object.keys(a) as (keyof T)[]; + return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); +} + export function getMediaSize(screen: Element, fallback?: MediaSize | null): MediaSize | null { const dataWidth = Number(screen.getAttribute(mediaWidthAttribute)); const dataHeight = Number(screen.getAttribute(mediaHeightAttribute)); diff --git a/web/src/pages/desktop/screen/input-region-overlay.tsx b/web/src/pages/desktop/screen/input-region-overlay.tsx index 670e0e64..c1cbc393 100644 --- a/web/src/pages/desktop/screen/input-region-overlay.tsx +++ b/web/src/pages/desktop/screen/input-region-overlay.tsx @@ -19,7 +19,13 @@ import { videoScaleAtom } from '@/jotai/screen.ts'; -import { getMediaSize, getRenderedMediaRect, MediaSize, RenderedMediaRect } from './geometry.ts'; +import { + getMediaSize, + getRenderedMediaRect, + isSameGeometry, + MediaSize, + RenderedMediaRect +} from './geometry.ts'; type SelectionRect = { left: number; @@ -191,8 +197,9 @@ export const InputRegionOverlay = () => { setSelection(null); } - setMediaSize(nextMediaSize); - setFrameRect(nextFrameRect); + // Polled every 250 ms: keep the previous objects while nothing changed. + setMediaSize((current) => (isSameGeometry(current, nextMediaSize) ? current : nextMediaSize)); + setFrameRect((current) => (isSameGeometry(current, nextFrameRect) ? current : nextFrameRect)); frameRectRef.current = nextFrameRect; } diff --git a/web/src/pages/desktop/screen/manual-region.tsx b/web/src/pages/desktop/screen/manual-region.tsx index ce4c31a5..6ad2326b 100644 --- a/web/src/pages/desktop/screen/manual-region.tsx +++ b/web/src/pages/desktop/screen/manual-region.tsx @@ -10,7 +10,7 @@ import { selectedOriginalResolutionAtom } from '@/jotai/screen.ts'; -import { getCenteredInputRegionByAspectRatio, getMediaSize } from './geometry.ts'; +import { getCenteredInputRegionByAspectRatio, getMediaSize, isSameGeometry } from './geometry.ts'; export const ManualRegion = () => { const mode = useAtomValue(controlRegionModeAtom); @@ -45,9 +45,9 @@ export const ManualRegion = () => { const target = document.getElementById('screen'); if (!target) return; const mediaSize = getMediaSize(target, resolution); - setInputRegion( - mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null - ); + const next = mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null; + // Polled every 250 ms: publish only real changes, or the desktop re-renders. + setInputRegion((current) => (isSameGeometry(current, next) ? current : next)); }; update(); const timer = window.setInterval(update, 250); diff --git a/web/tests/geometry-equality.test.mjs b/web/tests/geometry-equality.test.mjs new file mode 100644 index 00000000..41e76e36 --- /dev/null +++ b/web/tests/geometry-equality.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isSameGeometry } from '../src/pages/desktop/screen/geometry.ts'; + +test('polled regions keep their state object while the geometry is unchanged', () => { + const region = { frameWidth: 1920, frameHeight: 1080, left: 0, top: 0, width: 1440, height: 1080 }; + assert.equal(isSameGeometry(region, { ...region }), true); + assert.equal(isSameGeometry(region, { ...region, left: 1 }), false); + assert.equal(isSameGeometry({ width: 1, height: 2 }, { width: 1, height: 2, left: 0 }), false); +}); + +test('null regions compare by identity', () => { + assert.equal(isSameGeometry(null, null), true); + assert.equal(isSameGeometry(null, { width: 1, height: 1 }), false); + assert.equal(isSameGeometry({ width: 1, height: 1 }, null), false); +}); From b8270ab69f269f1402bf1b4c029dc2fc5a8b7efe Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:27:51 +0300 Subject: [PATCH 11/98] web: load the PicoClaw gateway client with the PicoClaw sidebar only The desktop menu imports api/picoclaw.ts for the runtime status poll, which also re-exported the gateway helpers and so pulled the gateway client (lib/picoclaw-gateway.ts) into the desktop page for every user. Move the gateway helpers unchanged into api/picoclaw-gateway.ts, used only by the lazily loaded sidebar; api/picoclaw.ts keeps the REST calls. The gateway singleton has no constructor side effects, so creating it when the sidebar first loads changes nothing at runtime. Shared picoclaw chunk on the desktop page: 8.7 kB -> 2.4 kB. Co-Authored-By: Claude Opus 5.5 --- web/src/api/picoclaw-gateway.ts | 60 +++++++++++++++++++ web/src/api/picoclaw.ts | 57 ------------------ .../pages/desktop/picoclaw/sidebar-actions.ts | 4 +- .../pages/desktop/picoclaw/sidebar-effects.ts | 2 +- .../picoclaw/sidebar-session-actions.ts | 8 ++- web/src/pages/desktop/picoclaw/sidebar.tsx | 2 +- 6 files changed, 68 insertions(+), 65 deletions(-) create mode 100644 web/src/api/picoclaw-gateway.ts diff --git a/web/src/api/picoclaw-gateway.ts b/web/src/api/picoclaw-gateway.ts new file mode 100644 index 00000000..3533a687 --- /dev/null +++ b/web/src/api/picoclaw-gateway.ts @@ -0,0 +1,60 @@ +// Gateway helpers live apart from the REST calls in ./picoclaw.ts so that the +// gateway client is only bundled with the PicoClaw sidebar, not the desktop page. +import { http } from '@/lib/http.ts'; +import { + picoclawGateway, + type GatewayRunState, + type GatewayTransportState +} from '@/lib/picoclaw-gateway.ts'; + +const sessionIDHeader = 'X-PicoClaw-Session-ID'; + +export function connectGateway(sessionId?: string) { + return picoclawGateway.connect({ sessionId }); +} + +export function releaseRuntimeSession(sessionId?: string) { + const activeSessionId = sessionId || picoclawGateway.getSessionId(); + if (!activeSessionId) { + return Promise.resolve(null); + } + + return http.request({ + method: 'delete', + url: '/api/picoclaw/runtime/session', + headers: { + [sessionIDHeader]: activeSessionId + } + }); +} + +export function sendChatMessage( + content: string, + options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean } +) { + return picoclawGateway.sendChatMessage(content, options); +} + +export function sendStopMessage() { + return picoclawGateway.sendStopMessage(); +} + +export async function closeGateway() { + const activeSessionId = picoclawGateway.getSessionId(); + picoclawGateway.close(); + if (!activeSessionId) { + return; + } + + await releaseRuntimeSession(activeSessionId).catch(() => undefined); +} + +export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) { + return picoclawGateway.on('transport_state', listener); +} + +export function onGatewayRunState(listener: (state: GatewayRunState) => void) { + return picoclawGateway.on('run_state', listener); +} + +export { picoclawGateway }; diff --git a/web/src/api/picoclaw.ts b/web/src/api/picoclaw.ts index e6c6f944..d43d4b7f 100644 --- a/web/src/api/picoclaw.ts +++ b/web/src/api/picoclaw.ts @@ -1,11 +1,4 @@ import { http } from '@/lib/http.ts'; -import { - picoclawGateway, - type GatewayRunState, - type GatewayTransportState -} from '@/lib/picoclaw-gateway.ts'; - -const sessionIDHeader = 'X-PicoClaw-Session-ID'; type ModelConfigRequest = { model: string; @@ -91,53 +84,3 @@ export function installRuntime() { export function uninstallRuntime() { return http.post('/api/picoclaw/runtime/uninstall'); } - -export function connectGateway(sessionId?: string) { - return picoclawGateway.connect({ sessionId }); -} - -export function releaseRuntimeSession(sessionId?: string) { - const activeSessionId = sessionId || picoclawGateway.getSessionId(); - if (!activeSessionId) { - return Promise.resolve(null); - } - - return http.request({ - method: 'delete', - url: '/api/picoclaw/runtime/session', - headers: { - [sessionIDHeader]: activeSessionId - } - }); -} - -export function sendChatMessage( - content: string, - options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean } -) { - return picoclawGateway.sendChatMessage(content, options); -} - -export function sendStopMessage() { - return picoclawGateway.sendStopMessage(); -} - -export async function closeGateway() { - const activeSessionId = picoclawGateway.getSessionId(); - picoclawGateway.close(); - if (!activeSessionId) { - return; - } - - await releaseRuntimeSession(activeSessionId).catch(() => undefined); -} - -export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) { - return picoclawGateway.on('transport_state', listener); -} - -export function onGatewayRunState(listener: (state: GatewayRunState) => void) { - return picoclawGateway.on('run_state', listener); -} - -export { picoclawGateway }; diff --git a/web/src/pages/desktop/picoclaw/sidebar-actions.ts b/web/src/pages/desktop/picoclaw/sidebar-actions.ts index e833883b..1bdd048a 100644 --- a/web/src/pages/desktop/picoclaw/sidebar-actions.ts +++ b/web/src/pages/desktop/picoclaw/sidebar-actions.ts @@ -1,12 +1,10 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { TFunction } from 'i18next'; +import { closeGateway, connectGateway, picoclawGateway } from '@/api/picoclaw-gateway.ts'; import { - closeGateway, - connectGateway, getRuntimeStatus, installRuntime, - picoclawGateway, setAIControlMode, setPicoclawAgentProfile, setPicoclawModelConfig, diff --git a/web/src/pages/desktop/picoclaw/sidebar-effects.ts b/web/src/pages/desktop/picoclaw/sidebar-effects.ts index 668c58dc..a11a9464 100644 --- a/web/src/pages/desktop/picoclaw/sidebar-effects.ts +++ b/web/src/pages/desktop/picoclaw/sidebar-effects.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { TFunction } from 'i18next'; -import { closeGateway, connectGateway, picoclawGateway } from '@/api/picoclaw.ts'; +import { closeGateway, connectGateway, picoclawGateway } from '@/api/picoclaw-gateway.ts'; import { clearPicoclawRuntimeInstallSnapshot, setPicoclawRuntimeInstallSnapshot, diff --git a/web/src/pages/desktop/picoclaw/sidebar-session-actions.ts b/web/src/pages/desktop/picoclaw/sidebar-session-actions.ts index 0a8a4150..05d4ed0f 100644 --- a/web/src/pages/desktop/picoclaw/sidebar-session-actions.ts +++ b/web/src/pages/desktop/picoclaw/sidebar-session-actions.ts @@ -4,12 +4,14 @@ import type { TFunction } from 'i18next'; import { closeGateway, connectGateway, + picoclawGateway, + sendChatMessage, + sendStopMessage +} from '@/api/picoclaw-gateway.ts'; +import { deletePicoclawSession, getPicoclawSession, listPicoclawSessions, - picoclawGateway, - sendChatMessage, - sendStopMessage, type PicoclawSessionListItem } from '@/api/picoclaw.ts'; import { generateUUIDv4 } from '@/lib/picoclaw-gateway.ts'; diff --git a/web/src/pages/desktop/picoclaw/sidebar.tsx b/web/src/pages/desktop/picoclaw/sidebar.tsx index 4765a849..af22b4e9 100644 --- a/web/src/pages/desktop/picoclaw/sidebar.tsx +++ b/web/src/pages/desktop/picoclaw/sidebar.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { closeGateway } from '@/api/picoclaw.ts'; +import { closeGateway } from '@/api/picoclaw-gateway.ts'; import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts'; import { MessageInput } from './message-input.tsx'; From d7b272d8364e1cd13692aa217e76336896eeedb6 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:28:39 +0300 Subject: [PATCH 12/98] web: translate remaining hard-coded interface strings Route the remaining literal labels through i18next with en and ru keys: the Extensions section and its load/empty states (previously chosen with startsWith("ru")), Dashboard and Ethernet titles, dashboard ZRAM/SD swap rows and the code-built fps, Mbit/s and MiB values, the preferred gateway options, the Wi-Fi "not detected" note, the USB pointer profile default, keyboard lock indicators (with their own keyboardDisabled key), the HDD LED tooltip, image mount modes, the touchpad guide diagram label, autostart table headers, the Wi-Fi setup password placeholder and the NetBird/Tailscale English error fallbacks. Add the missing audio.resume key. Virtual keyboard layouts are now listed by their native names. Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/en.ts | 38 ++++++++++++++++-- web/src/i18n/locales/ru.ts | 40 ++++++++++++++++--- .../desktop/keyboard-led-status/index.tsx | 17 +++----- web/src/pages/desktop/menu/audio.tsx | 2 +- web/src/pages/desktop/menu/image/index.tsx | 4 +- web/src/pages/desktop/menu/keyboard/paste.tsx | 2 +- .../desktop/menu/mouse/touchpad-guide.tsx | 19 +++++---- web/src/pages/desktop/menu/power/index.tsx | 4 +- .../desktop/menu/settings/dashboard/index.tsx | 24 +++++++---- .../settings/device/advanced/autostart.tsx | 4 +- web/src/pages/desktop/menu/settings/index.tsx | 20 +++------- .../desktop/menu/settings/netbird/device.tsx | 4 +- .../menu/settings/netbird/error-help.tsx | 2 +- .../desktop/menu/settings/netbird/index.tsx | 4 +- .../desktop/menu/settings/netbird/install.tsx | 2 +- .../desktop/menu/settings/netbird/login.tsx | 2 +- .../desktop/menu/settings/netbird/run.tsx | 2 +- .../menu/settings/netbird/uninstall.tsx | 4 +- .../desktop/menu/settings/network/gateway.tsx | 11 ++--- .../desktop/menu/settings/network/index.tsx | 18 +++++---- .../desktop/menu/settings/network/wifi.tsx | 2 +- .../menu/settings/tailscale/device.tsx | 2 +- .../desktop/menu/settings/tailscale/index.tsx | 4 +- .../desktop/menu/settings/tailscale/login.tsx | 2 +- .../desktop/menu/settings/tailscale/run.tsx | 2 +- .../pages/desktop/menu/settings/usb/index.tsx | 2 +- .../pages/desktop/virtual-keyboard/index.tsx | 11 ++--- web/src/pages/wifi/index.tsx | 2 +- 28 files changed, 157 insertions(+), 93 deletions(-) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 438a102e..90d38d31 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -14,6 +14,7 @@ const en = { }, title: 'USB audio', listen: 'Listen', + resume: 'Resume audio', volume: 'Volume', failed: 'Audio unavailable. Check the USB connection and try again.' }, @@ -102,7 +103,6 @@ const en = { passphrase: 'Private-key passphrase', save: 'Save', - keyboardDisabled: 'USB keyboard is disabled in USB Composition', description: 'Import WireGuard profiles and enable the one you need. The enabled profile reconnects after a restart.', import: 'Import .conf files', @@ -139,6 +139,7 @@ const en = { dashboard: { coreCount_one: '{{count}} core', coreCount_other: '{{count}} cores', + title: 'Dashboard', live: 'Updates while this page is open', stale: 'Some information could not be refreshed. Showing the last available values.', duration: '{{days}}d {{hours}}h {{minutes}}m', @@ -158,6 +159,9 @@ const en = { memory: 'Memory', available: 'Available RAM', cache: 'Cache', + zram: 'ZRAM', + sdSwap: 'SD swap', + swapUsage: '{{used}} / {{size}} MiB', compression: 'Compression', storage: 'Storage', systemStorage: 'System', @@ -559,6 +563,7 @@ const en = { touchpadGuide: { title: 'Touchpad guide', scope: 'Applies when Input Adapter is Touchpad and Mouse Mode is Relative.', + screen: 'Screen', swipeTitle: 'Swipe to move', swipeDesc: 'Swipe inside the active screen area to move the remote pointer.', tapTitle: 'Tap to click', @@ -598,6 +603,8 @@ const en = { loading: 'Loading...', empty: 'Nothing Found', mountMode: 'Mount mode', + massStorage: 'Mass storage', + cdrom: 'CD/DVD', mountFailed: 'Mount failed', mountDesc: 'On some systems, you need to eject the virtual disk from the remote host before mounting the image.', @@ -705,6 +712,7 @@ const en = { power: 'Power', powerShort: 'Power (short press)', powerLong: 'Power (long press)', + hddLed: 'HDD LED', resetConfirm: 'Proceed reset operation?', powerConfirm: 'Proceed power operation?', okBtn: 'Yes', @@ -1109,7 +1117,8 @@ const en = { scrollLockShort: 'Scr', on: 'On', off: 'Off', - unknown: 'Unknown' + unknown: 'Unknown', + keyboardDisabled: 'USB keyboard is disabled in USB Composition' }, device: { title: 'Device', @@ -1182,6 +1191,8 @@ const en = { no: 'No', scriptName: 'Autostart Script Name', scriptContent: 'Autostart Script Content', + name: 'Name', + actions: 'Actions', settings: 'Settings', nameRequired: 'Enter a script name' }, @@ -1200,6 +1211,7 @@ const en = { pointerProfile: 'Absolute pointer profile', pointerProfileHelp: 'Windows binds the pointer to the captured monitor (Windows 10 1903 or newer). Applying reconnects USB and HDMI.', + pointerProfileDefault: 'Default', off: 'Off', title: 'USB Composition', enabled: 'USB devices', @@ -1290,6 +1302,7 @@ const en = { wifi: { loading: 'Loading…', noAdapter: 'Wi-Fi adapter not detected', + notDetected: 'not detected', disabled: 'Off', band: 'Frequency band', band24: '2.4 GHz', @@ -1349,6 +1362,7 @@ const en = { }, ethernet: { title: 'Ethernet IPv4', + name: 'Ethernet', description: 'Choose DHCP or configure a persistent static IPv4 address', enable: 'Ethernet', disabled: 'Disabled', @@ -1424,6 +1438,7 @@ const en = { stop: 'Stop Tailscale?', stopDesc: 'Log out Tailscale and disable automatic startup on boot.', loading: 'Loading...', + statusFailed: 'Could not get Tailscale status', notInstall: 'Tailscale is not installed.', installDescription: 'Install Tailscale on demand. Existing settings are preserved.', install: 'Install Tailscale', @@ -1446,7 +1461,10 @@ const en = { uninstall: 'Uninstall Tailscale', uninstallDesc: 'Are you sure you want to uninstall Tailscale?', okBtn: 'Yes', - cancelBtn: 'No' + cancelBtn: 'No', + startFailed: 'Could not start Tailscale', + loginFailed: 'Sign-in failed', + logoutFailed: 'Sign-out failed' }, netbird: { title: 'NetBird', @@ -1489,7 +1507,14 @@ const en = { restartButton: 'Restart Service', refreshButton: 'Refresh Status', restartFailed: 'Restart failed', - stopFailed: 'Stop failed' + stopFailed: 'Stop failed', + statusFailed: 'Could not get NetBird status', + requestFailed: 'Request failed', + installFailed: 'Install failed', + startFailed: 'Start failed', + loginFailed: 'Sign-in failed', + disconnectFailed: 'Disconnect failed', + uninstallFailed: 'Uninstall failed' } }, rustdesk: { @@ -1593,6 +1618,11 @@ const en = { saveFailed: 'Failed to save user', deleteFailed: 'Failed to delete user' } + }, + extensions: { + title: 'Extensions', + loadFailed: 'Could not load extensions', + empty: 'No extensions installed' } }, picoclaw: { diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 3dd34fd5..f74cb5db 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -14,6 +14,7 @@ const ru = { }, title: 'USB-звук', listen: 'Слушать', + resume: 'Возобновить звук', volume: 'Громкость', failed: 'Звук недоступен. Проверьте USB-подключение и попробуйте снова.' }, @@ -103,7 +104,6 @@ const ru = { passphrase: 'Пароль приватного ключа', save: 'Сохранить', - keyboardDisabled: 'USB-клавиатура отключена в композиции USB', description: 'Импортируйте профили WireGuard и включите нужный. Включённый профиль подключается снова после перезапуска.', import: 'Импорт .conf файлов', @@ -143,6 +143,7 @@ const ru = { coreCount_few: '{{count}} ядра', coreCount_many: '{{count}} ядер', coreCount_other: '{{count}} ядра', + title: 'Обзор', live: 'Обновляется, пока открыта страница', stale: 'Часть данных не удалось обновить. Показаны последние доступные значения.', duration: '{{days}} д {{hours}} ч {{minutes}} мин', @@ -162,6 +163,9 @@ const ru = { memory: 'Память', available: 'Доступная RAM', cache: 'Кэш', + zram: 'ZRAM', + sdSwap: 'Swap на SD', + swapUsage: '{{used}} / {{size}} MiB', compression: 'Сжатие', storage: 'Накопитель', systemStorage: 'Система', @@ -568,6 +572,7 @@ const ru = { title: 'Руководство по тачпаду', scope: 'Применяется, когда адаптер ввода выбран как тачпад, а режим мыши выбран как относительный.', + screen: 'Экран', swipeTitle: 'Проведите, чтобы переместить', swipeDesc: 'Проведите в активной области экрана, чтобы переместить удаленный указатель.', tapTitle: 'Коснитесь, чтобы щелкнуть', @@ -610,6 +615,8 @@ const ru = { loading: 'Загрузка...', empty: 'Пусто', mountMode: 'Режим монтирования', + massStorage: 'USB-накопитель', + cdrom: 'CD/DVD', mountFailed: 'Монтирование образа не удалось', mountDesc: 'В некоторых системах необходимо отсоединить виртуальный диск на удаленном хосте перед монтированием образа.', @@ -717,6 +724,7 @@ const ru = { power: 'Питание', powerShort: 'Питание (короткое нажатие)', powerLong: 'Питание (длительное нажатие)', + hddLed: 'Индикатор HDD', resetConfirm: 'Вы уверены, что хотите выполнить экстренную перезагрузку?', powerConfirm: 'Вы уверены, что хотите нажать кнопку питания?', okBtn: 'Да', @@ -1126,7 +1134,8 @@ const ru = { scrollLockShort: 'Scr', on: 'Вкл.', off: 'Выкл.', - unknown: 'Неизвестно' + unknown: 'Неизвестно', + keyboardDisabled: 'USB-клавиатура отключена в композиции USB' }, device: { title: 'Устройство', @@ -1149,7 +1158,7 @@ const ru = { running: 'Текущая: {{mhz}} МГц', unavailable: 'Управление частотой недоступно в этом ядре', description: - 'Применяется сразу. Штатный и экономичный режимы сохраняются; разгон действует до перезагрузки устройства. Температура SoC доступна в Dashboard.', + 'Применяется сразу. Штатный и экономичный режимы сохраняются; разгон действует до перезагрузки устройства. Температура SoC доступна в разделе «Обзор».', failed: 'Не удалось изменить частоту CPU' }, oled: { @@ -1205,6 +1214,8 @@ const ru = { no: 'Нет', scriptName: 'Имя сценария автозапуска', scriptContent: 'Содержимое сценария автозапуска', + name: 'Имя', + actions: 'Действия', settings: 'Настройки', nameRequired: 'Введите имя сценария' }, @@ -1224,6 +1235,7 @@ const ru = { pointerProfile: 'Режим абсолютного указателя', pointerProfileHelp: 'Windows привязывает указатель к захватываемому монитору (Windows 10 1903 и новее). Применение переподключает USB и HDMI.', + pointerProfileDefault: 'По умолчанию', off: 'Выключено', title: 'Композиция USB', enabled: 'USB-устройства', @@ -1315,6 +1327,7 @@ const ru = { wifi: { loading: 'Загрузка…', noAdapter: 'Wi-Fi адаптер не обнаружен', + notDetected: 'не обнаружен', disabled: 'Выключен', band: 'Диапазон', band24: '2,4 ГГц', @@ -1375,6 +1388,7 @@ const ru = { }, ethernet: { title: 'Ethernet IPv4', + name: 'Ethernet', description: 'Выберите DHCP или настройте постоянный статический IPv4-адрес', enable: 'Ethernet', disabled: 'Отключён', @@ -1451,6 +1465,7 @@ const ru = { stop: 'Вы уверены что хотите остановить Tailscale?', stopDesc: 'Выйти из Tailscale и отключить его автоматический запуск при включении.', loading: 'Загрузка...', + statusFailed: 'Не удалось получить статус Tailscale', notInstall: 'Tailscale не установлен.', installDescription: 'Установите Tailscale по запросу. Существующие настройки сохранятся.', install: 'Установить Tailscale', @@ -1472,7 +1487,10 @@ const ru = { uninstall: 'Удалить Tailscale', uninstallDesc: 'Вы уверены, что хотите удалить Tailscale?', okBtn: 'Да', - cancelBtn: 'Нет' + cancelBtn: 'Нет', + startFailed: 'Не удалось запустить Tailscale', + loginFailed: 'Не удалось войти', + logoutFailed: 'Не удалось выйти' }, netbird: { title: 'NetBird', @@ -1514,7 +1532,14 @@ const ru = { restartButton: 'Перезапустить службу', refreshButton: 'Обновить статус', restartFailed: 'Не удалось перезапустить службу', - stopFailed: 'Не удалось остановить службу' + stopFailed: 'Не удалось остановить службу', + statusFailed: 'Не удалось получить статус NetBird', + requestFailed: 'Запрос не выполнен', + installFailed: 'Не удалось установить', + startFailed: 'Не удалось запустить', + loginFailed: 'Не удалось войти', + disconnectFailed: 'Не удалось отключиться', + uninstallFailed: 'Не удалось удалить' } }, rustdesk: { @@ -1595,6 +1620,11 @@ const ru = { logoutFailed: 'Не удалось выйти: сеанс по-прежнему активен.', okBtn: 'Да', cancelBtn: 'Нет' + }, + extensions: { + title: 'Расширения', + loadFailed: 'Не удалось загрузить расширения', + empty: 'Нет установленных расширений' } }, picoclaw: { diff --git a/web/src/pages/desktop/keyboard-led-status/index.tsx b/web/src/pages/desktop/keyboard-led-status/index.tsx index b792c39d..4be4b139 100644 --- a/web/src/pages/desktop/keyboard-led-status/index.tsx +++ b/web/src/pages/desktop/keyboard-led-status/index.tsx @@ -4,12 +4,6 @@ import { useTranslation } from 'react-i18next'; import { useKeyboardLedStatus } from './use-keyboard-led-status'; -const LOCK_INDICATORS = { - numLock: { label: 'Num Lock', shortLabel: 'Num' }, - capsLock: { label: 'Caps Lock', shortLabel: 'Caps' }, - scrollLock: { label: 'Scroll Lock', shortLabel: 'Scr' } -} as const; - type LockIndicatorProps = { labelKey: 'numLock' | 'capsLock' | 'scrollLock'; active: boolean; @@ -18,10 +12,10 @@ type LockIndicatorProps = { }; function LockIndicator({ labelKey, active, known, disabled }: LockIndicatorProps) { - const { label, shortLabel } = LOCK_INDICATORS[labelKey]; - const { t } = useTranslation(); - const state = disabled ? t('vpn.keyboardDisabled') : known ? (active ? 'On' : 'Off') : 'Unknown'; - const indicatorLabel = `${label}: ${state}`; + const { t } = useTranslation('translation', { keyPrefix: 'settings.keyboardLedStatus' }); + const shortLabel = t(`${labelKey}Short`); + const state = t(disabled ? 'keyboardDisabled' : known ? (active ? 'on' : 'off') : 'unknown'); + const indicatorLabel = t('indicatorLabel', { label: t(labelKey), state }); return ( @@ -50,6 +44,7 @@ function LockIndicator({ labelKey, active, known, disabled }: LockIndicatorProps } export function KeyboardLedStatus() { + const { t } = useTranslation(); const status = useKeyboardLedStatus(); const disabled = status?.keyboardEnabled === false; const known = !disabled && (status?.known ?? false); @@ -57,7 +52,7 @@ export function KeyboardLedStatus() { return (
})
{blocked && ( )}
diff --git a/web/src/pages/desktop/menu/image/index.tsx b/web/src/pages/desktop/menu/image/index.tsx index dafbdc97..62a3367f 100644 --- a/web/src/pages/desktop/menu/image/index.tsx +++ b/web/src/pages/desktop/menu/image/index.tsx @@ -38,7 +38,7 @@ export const Image = ({ tooltipPlacement = 'bottom' }: ImageProps) => { label: (
- Mass Storage + {t('image.massStorage')}
) }, @@ -47,7 +47,7 @@ export const Image = ({ tooltipPlacement = 'bottom' }: ImageProps) => { label: (
- CD/DVD + {t('image.cdrom')}
) } diff --git a/web/src/pages/desktop/menu/keyboard/paste.tsx b/web/src/pages/desktop/menu/keyboard/paste.tsx index 054dae02..699a1aac 100644 --- a/web/src/pages/desktop/menu/keyboard/paste.tsx +++ b/web/src/pages/desktop/menu/keyboard/paste.tsx @@ -242,7 +242,7 @@ export const Paste = () => { onClick={readFromClipboard} className="flex items-center" > - {t('keyboard.readClipboard') || 'Read from Clipboard'} + {t('keyboard.readClipboard')} )} diff --git a/web/src/pages/desktop/menu/mouse/touchpad-guide.tsx b/web/src/pages/desktop/menu/mouse/touchpad-guide.tsx index dd97ca7f..971adcc6 100644 --- a/web/src/pages/desktop/menu/mouse/touchpad-guide.tsx +++ b/web/src/pages/desktop/menu/mouse/touchpad-guide.tsx @@ -19,15 +19,18 @@ type GuideItem = { diagram: JSX.Element; }; -const TouchpadArea = ({ children }: { children: JSX.Element }) => ( -
-
-
- screen +const TouchpadArea = ({ children }: { children: JSX.Element }) => { + const { t } = useTranslation(); + return ( +
+
+
+ {t('mouse.touchpadGuide.screen')} +
+ {children}
- {children} -
-); + ); +}; const SwipeDiagram = () => ( diff --git a/web/src/pages/desktop/menu/power/index.tsx b/web/src/pages/desktop/menu/power/index.tsx index 745f2e38..6096b481 100644 --- a/web/src/pages/desktop/menu/power/index.tsx +++ b/web/src/pages/desktop/menu/power/index.tsx @@ -101,10 +101,10 @@ export const Power = ({ vertical = false }: { vertical?: boolean }) => { return (
- +
void }) => return (
-

Dashboard

+

{t('dashboard.title')}

{t('dashboard.live')} @@ -432,14 +432,18 @@ export const Dashboard = ({ navigate }: { navigate: (tab: string) => void }) => )} {line( t('dashboard.fps'), - video ? `${enabled ? video.measuredFps : 0} / ${video.fps} FPS` : '—' + video + ? t('videoSettings.fpsValue', { + value: `${enabled ? video.measuredFps : 0} / ${video.fps}` + }) + : '—' )} {line( t(mode === 'mjpeg' ? 'screen.quality' : 'videoSettings.bitrate'), video ? mode === 'mjpeg' ? `${video.quality}%` - : `${video.bitRate / 1000} Mbit/s` + : t('videoSettings.mbps', { value: video.bitRate / 1000 }) : '—' )} {line(t('dashboard.sessions'), sessions)} @@ -452,10 +456,13 @@ export const Dashboard = ({ navigate }: { navigate: (tab: string) => void }) => {line(t('dashboard.available'), bytes(memory?.availableBytes))} {line(t('dashboard.cache'), bytes(memory?.cachedBytes))} {line( - 'zram', + t('dashboard.zram'), memory ? memory.zram.enabled - ? `${bytes(memory.zram.usedBytes)} / ${memory.zram.sizeMiB} MiB` + ? t('dashboard.swapUsage', { + used: bytes(memory.zram.usedBytes), + size: memory.zram.sizeMiB + }) : state('off') : '—' )} @@ -465,10 +472,13 @@ export const Dashboard = ({ navigate }: { navigate: (tab: string) => void }) => `${memory.zram.algorithm}${memory.zram.recompressReady ? ' + zstd' : ''}` )} {line( - 'Swap (SD)', + t('dashboard.sdSwap'), memory ? memory.sd.enabled - ? `${bytes(memory.sd.usedBytes)} / ${memory.sd.sizeMiB} MiB` + ? t('dashboard.swapUsage', { + used: bytes(memory.sd.usedBytes), + size: memory.sd.sizeMiB + }) : state('off') : '—' )} diff --git a/web/src/pages/desktop/menu/settings/device/advanced/autostart.tsx b/web/src/pages/desktop/menu/settings/device/advanced/autostart.tsx index 5e73dc54..71d6d104 100644 --- a/web/src/pages/desktop/menu/settings/device/advanced/autostart.tsx +++ b/web/src/pages/desktop/menu/settings/device/advanced/autostart.tsx @@ -27,12 +27,12 @@ export const Autostart = () => { const autostartColumns: TableProps['columns'] = [ { - title: 'Name', + title: t('settings.device.autostart.name'), dataIndex: 'name', key: 'name' }, { - title: 'Action', + title: t('settings.device.autostart.actions'), key: 'action', render: (_, record) => ( <> diff --git a/web/src/pages/desktop/menu/settings/index.tsx b/web/src/pages/desktop/menu/settings/index.tsx index 6e8452f9..95218d34 100644 --- a/web/src/pages/desktop/menu/settings/index.tsx +++ b/web/src/pages/desktop/menu/settings/index.tsx @@ -124,10 +124,6 @@ export const Settings = ({ const [inventory, setInventory] = useAtom(addonInventoryAtom); const [inventoryError, setInventoryError] = useState(false); const [picoclawStatus] = useAtom(picoclawRuntimeStatusAtom); - const { i18n } = useTranslation(); - const extensionsTitle = (i18n.resolvedLanguage || i18n.language).startsWith('ru') - ? 'Расширения' - : 'Extensions'; const setPicoclawOpen = useSetAtom(picoclawChatOpenAtom); const scrollViewportRef = useRef(null); @@ -413,12 +409,12 @@ export const Settings = ({ } function tabTitle(id: string) { - if (id === 'dashboard') return 'Dashboard'; + if (id === 'dashboard') return t('dashboard.title'); if (id === 'system-date-time') return t('dateTime.title'); if (id === 'video') return t('videoSettings.title'); if (id === 'network') return t('settings.network.title'); if (id === 'network-wifi') return t('settings.network.wifi.title'); - if (id === 'network-ethernet') return 'Ethernet'; + if (id === 'network-ethernet') return t('settings.network.ethernet.name'); if (id === 'network-general') return t('settings.network.general'); if (id === 'system') return t('settings.system.title'); if (id === 'system-general') return t('settings.system.general'); @@ -427,7 +423,7 @@ export const Settings = ({ if (id === 'system-users') return t('settings.account.title'); if (id === 'system-mcp') return t('settings.mcp.title'); if (id === 'system-updates') return t('settings.updates.title'); - if (id === 'extensions') return extensionsTitle; + if (id === 'extensions') return t('settings.extensions.title'); if (id === 'extensions-rustdesk') return 'RustDesk'; if (id === 'extensions-picoclaw') return 'PicoClaw'; if (id === 'software') return t('settings.software.title'); @@ -588,15 +584,9 @@ export const Settings = ({ {!inventory && !inventoryError ? ( ) : inventoryError ? ( - (i18n.resolvedLanguage || i18n.language).startsWith('ru') ? ( - 'Не удалось загрузить расширения' - ) : ( - 'Could not load extensions' - ) - ) : (i18n.resolvedLanguage || i18n.language).startsWith('ru') ? ( - 'Нет установленных расширений' + t('settings.extensions.loadFailed') ) : ( - 'No extensions installed' + t('settings.extensions.empty') )}
)} diff --git a/web/src/pages/desktop/menu/settings/netbird/device.tsx b/web/src/pages/desktop/menu/settings/netbird/device.tsx index d72bf756..e185b900 100644 --- a/web/src/pages/desktop/menu/settings/netbird/device.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/device.tsx @@ -50,7 +50,7 @@ export const Device = ({ status, onLogout }: DeviceProps) => { // obtain a new observation instead of applying an optimistic toggle. onLogout(); } catch (err: any) { - setErrMsg(err?.message || "Request failed"); + setErrMsg(err?.message || t("settings.netbird.error.requestFailed")); } finally { setIsUpdating(false); } @@ -71,7 +71,7 @@ export const Device = ({ status, onLogout }: DeviceProps) => { onLogout(); }) .catch((err) => { - setErrMsg(err.message || "Disconnect failed"); + setErrMsg(err.message || t("settings.netbird.error.disconnectFailed")); }) .finally(() => { setIsDisconnecting(false); diff --git a/web/src/pages/desktop/menu/settings/netbird/error-help.tsx b/web/src/pages/desktop/menu/settings/netbird/error-help.tsx index 6455e1f2..56617057 100644 --- a/web/src/pages/desktop/menu/settings/netbird/error-help.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/error-help.tsx @@ -38,7 +38,7 @@ export const ErrorHelp = ({ onRefresh(); }) .catch((err) => { - setActionError(err.message || "Restart failed"); + setActionError(err.message || t("settings.netbird.error.restartFailed")); }) .finally(() => { setIsRestarting(false); diff --git a/web/src/pages/desktop/menu/settings/netbird/index.tsx b/web/src/pages/desktop/menu/settings/netbird/index.tsx index 1871bbc7..6d44f2d3 100644 --- a/web/src/pages/desktop/menu/settings/netbird/index.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/index.tsx @@ -42,13 +42,13 @@ export const Netbird = ({ setIsLocked }: NetbirdProps) => { setStatus(rsp.data); } catch (err: any) { if (!isMounted.current || currentRequestId !== requestId.current) return; - setErrMsg(err?.message || "Failed to get status"); + setErrMsg(err?.message || t("settings.netbird.error.statusFailed")); } finally { if (isMounted.current && currentRequestId === requestId.current) { setIsLoading(false); } } - }, []); + }, [t]); useEffect(() => { // Effects are mounted twice in development Strict Mode. Restore the guard diff --git a/web/src/pages/desktop/menu/settings/netbird/install.tsx b/web/src/pages/desktop/menu/settings/netbird/install.tsx index 2ce68668..8a973cb4 100644 --- a/web/src/pages/desktop/menu/settings/netbird/install.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/install.tsx @@ -33,7 +33,7 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => { onSuccess(); }) .catch((err) => { - setErrMsg(err.message || "Install failed"); + setErrMsg(err.message || t("settings.netbird.error.installFailed")); }) .finally(() => { setIsLoading(false); diff --git a/web/src/pages/desktop/menu/settings/netbird/login.tsx b/web/src/pages/desktop/menu/settings/netbird/login.tsx index c934f825..78ccb6a9 100644 --- a/web/src/pages/desktop/menu/settings/netbird/login.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/login.tsx @@ -49,7 +49,7 @@ export const Login = ({ onSuccess }: LoginProps) => { loginTimer.current = setTimeout(() => setLoginUrl(""), 10 * 60 * 1000); }) .catch((err) => { - setErrMsg(err.message || "Login failed"); + setErrMsg(err.message || t("settings.netbird.error.loginFailed")); }) .finally(() => { setIsLoading(false); diff --git a/web/src/pages/desktop/menu/settings/netbird/run.tsx b/web/src/pages/desktop/menu/settings/netbird/run.tsx index 2edd83e8..ea779426 100644 --- a/web/src/pages/desktop/menu/settings/netbird/run.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/run.tsx @@ -32,7 +32,7 @@ export const Run = ({ onSuccess }: RunProps) => { onSuccess(); }) .catch((err) => { - setErrMsg(err.message || "Start failed"); + setErrMsg(err.message || t("settings.netbird.error.startFailed")); }) .finally(() => { setIsLoading(false); diff --git a/web/src/pages/desktop/menu/settings/netbird/uninstall.tsx b/web/src/pages/desktop/menu/settings/netbird/uninstall.tsx index 295c6cd8..e5804b82 100644 --- a/web/src/pages/desktop/menu/settings/netbird/uninstall.tsx +++ b/web/src/pages/desktop/menu/settings/netbird/uninstall.tsx @@ -29,7 +29,7 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => { .uninstall() .then((rsp) => { if (rsp.code !== 0) { - setErrMsg(rsp.msg || "Uninstall failed"); + setErrMsg(rsp.msg || t("settings.netbird.error.uninstallFailed")); return; } @@ -40,7 +40,7 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => { onSuccess(); }) .catch((err) => { - setErrMsg(err.message || "Uninstall failed"); + setErrMsg(err.message || t("settings.netbird.error.uninstallFailed")); }) .finally(() => { uninstallInFlight.current = false; diff --git a/web/src/pages/desktop/menu/settings/network/gateway.tsx b/web/src/pages/desktop/menu/settings/network/gateway.tsx index bd8ed356..a577cc61 100644 --- a/web/src/pages/desktop/menu/settings/network/gateway.tsx +++ b/web/src/pages/desktop/menu/settings/network/gateway.tsx @@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next'; import * as api from '@/api/network.ts'; -const labelFor = (route: api.GatewayRoute) => - `${route.interface.startsWith('eth') ? 'Ethernet' : 'Wi‑Fi'} · ${route.gateway}`; - export const Gateway = () => { const { t } = useTranslation(); + const ethernet = t('settings.network.ethernet.name'); + const wifi = t('settings.network.wifi.title'); + const labelFor = (route: api.GatewayRoute) => + `${route.interface.startsWith('eth') ? ethernet : wifi} · ${route.gateway}`; const [status, setStatus] = useState(); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -63,8 +64,8 @@ export const Gateway = () => { onChange={(value) => void change(value as api.GatewayPreference)} options={[ { label: t('settings.network.gateway.auto'), value: 'auto' }, - { label: 'Ethernet', value: 'ethernet', disabled: !available.has('ethernet') }, - { label: 'Wi‑Fi', value: 'wifi', disabled: !available.has('wifi') } + { label: ethernet, value: 'ethernet', disabled: !available.has('ethernet') }, + { label: wifi, value: 'wifi', disabled: !available.has('wifi') } ]} /> {status && routes.length === 0 ? ( diff --git a/web/src/pages/desktop/menu/settings/network/index.tsx b/web/src/pages/desktop/menu/settings/network/index.tsx index 3c0143c6..6bc85906 100644 --- a/web/src/pages/desktop/menu/settings/network/index.tsx +++ b/web/src/pages/desktop/menu/settings/network/index.tsx @@ -1,4 +1,5 @@ import { Divider } from 'antd'; +import { useTranslation } from 'react-i18next'; import { DNS } from './dns.tsx'; import { Ethernet } from './ethernet.tsx'; @@ -22,10 +23,13 @@ export const Network = () => ( export const WifiSettings = () => ; -export const EthernetSettings = () => ( - <> -
Ethernet
- - - -); +export const EthernetSettings = () => { + const { t } = useTranslation(); + return ( + <> +
{t('settings.network.ethernet.name')}
+ + + + ); +}; diff --git a/web/src/pages/desktop/menu/settings/network/wifi.tsx b/web/src/pages/desktop/menu/settings/network/wifi.tsx index 4b8f4e6d..71a4d20f 100644 --- a/web/src/pages/desktop/menu/settings/network/wifi.tsx +++ b/web/src/pages/desktop/menu/settings/network/wifi.tsx @@ -221,7 +221,7 @@ export const Wifi = () => { {tr('title')} {state && (!state.supported || state.model) && ( - {state.supported ? state.model : 'not detected'} + {state.supported ? state.model : tr('notDetected')} )}
diff --git a/web/src/pages/desktop/menu/settings/tailscale/device.tsx b/web/src/pages/desktop/menu/settings/tailscale/device.tsx index ee94ad32..13c9430b 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/device.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/device.tsx @@ -56,7 +56,7 @@ export const Device = ({ status, onLogout }: DeviceProps) => { onLogout(); }) .catch((err) => { - setErrMsg(err?.message || 'Failed to logout'); + setErrMsg(err?.message || t('settings.tailscale.logoutFailed')); }) .finally(() => { setIsLogging(false); diff --git a/web/src/pages/desktop/menu/settings/tailscale/index.tsx b/web/src/pages/desktop/menu/settings/tailscale/index.tsx index bf2e2a01..45f161ea 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/index.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/index.tsx @@ -40,13 +40,13 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => { setStatus(rsp.data); }) .catch((err) => { - setErrMsg(err?.message || 'Failed to get status'); + setErrMsg(err?.message || t('settings.tailscale.statusFailed')); }) .finally(() => { statusInFlight.current = false; setIsLoading(false); }); - }, []); + }, [t]); useEffect(() => { getStatus(); diff --git a/web/src/pages/desktop/menu/settings/tailscale/login.tsx b/web/src/pages/desktop/menu/settings/tailscale/login.tsx index 389d974b..98617f5e 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/login.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/login.tsx @@ -39,7 +39,7 @@ export const Login = ({ onSuccess }: LoginProps) => { setTimeout(() => setLoginUrl(''), 10 * 60 * 1000); }) .catch((err) => { - setErrMsg(err?.message || 'Failed to login'); + setErrMsg(err?.message || t('settings.tailscale.loginFailed')); }) .finally(() => { setIsLoading(false); diff --git a/web/src/pages/desktop/menu/settings/tailscale/run.tsx b/web/src/pages/desktop/menu/settings/tailscale/run.tsx index 55580a79..c3a3cf1f 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/run.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/run.tsx @@ -30,7 +30,7 @@ export const Run = ({ onSuccess }: RunProps) => { onSuccess(); }) .catch((err) => { - setErrMsg(err?.message || 'Failed to run tailscale'); + setErrMsg(err?.message || t('settings.tailscale.startFailed')); }) .finally(() => { setIsLoading(false); diff --git a/web/src/pages/desktop/menu/settings/usb/index.tsx b/web/src/pages/desktop/menu/settings/usb/index.tsx index ac57cce9..f320438e 100644 --- a/web/src/pages/desktop/menu/settings/usb/index.tsx +++ b/web/src/pages/desktop/menu/settings/usb/index.tsx @@ -293,7 +293,7 @@ export const Usb = () => { value={draft.pointerProfile ?? 'default'} disabled={loading || !status} options={[ - { value: 'default', label: 'Default' }, + { value: 'default', label: t('settings.usb.pointerProfileDefault') }, { value: 'windows', label: 'Windows', disabled: !status?.windowsPointerSupported } ]} onChange={(value) => setDraft({ ...draft, pointerProfile: value })} diff --git a/web/src/pages/desktop/virtual-keyboard/index.tsx b/web/src/pages/desktop/virtual-keyboard/index.tsx index f4684165..127447c8 100644 --- a/web/src/pages/desktop/virtual-keyboard/index.tsx +++ b/web/src/pages/desktop/virtual-keyboard/index.tsx @@ -27,13 +27,14 @@ import { specialKeyMap } from './virtual-keys.ts'; +// Layout names are shown in their own language, as in the interface language list. const languages = [ { value: 'en', label: 'English' }, - { value: 'fr', label: 'French' }, - { value: 'de', label: 'German' }, - { value: 'ru', label: 'Russian' }, - { value: 'ko', label: 'Korean' }, - { value: 'ja', label: 'Japanese' } + { value: 'fr', label: 'Français' }, + { value: 'de', label: 'Deutsch' }, + { value: 'ru', label: 'Русский' }, + { value: 'ko', label: '한국어' }, + { value: 'ja', label: '日本語' } ]; export const VirtualKeyboard = () => { diff --git a/web/src/pages/wifi/index.tsx b/web/src/pages/wifi/index.tsx index fc04d8ed..54a0a77b 100644 --- a/web/src/pages/wifi/index.tsx +++ b/web/src/pages/wifi/index.tsx @@ -146,7 +146,7 @@ export const Wifi = () => { - } placeholder="Password" /> + } placeholder={t('auth.placeholderPassword')} /> From af580d891c2b895619fa99a64c5834ea36320e7c Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:29:28 +0300 Subject: [PATCH 13/98] i18n(ru): translate the Users feature and leftover English values Add the 22 keys the Russian locale was missing (current password, password length, roles and the whole user management section) and translate values that were still copied from English: PicoClaw control, connection and start texts, Route Allowed IPs, the SSH banner style, the credits button and the USB gadget diagnostic label. Brand names, protocol names and placeholders remain identical on purpose. Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/ru.ts | 79 +++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index f74cb5db..417d4fe6 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -119,8 +119,8 @@ const ru = { systemRequired: 'Системные инструменты WireGuard отсутствуют. Установите системный образ с поддержкой WireGuard.', routingNote: - 'По умолчанию создаётся только маршрут подсети интерфейса. Route Allowed IPs добавляет маршруты из AllowedIPs.', - routeAllowedIPs: 'Route Allowed IPs', + 'По умолчанию создаётся только маршрут подсети интерфейса. Включите «Маршруты из AllowedIPs», чтобы добавить маршруты пира.', + routeAllowedIPs: 'Маршруты из AllowedIPs', routingHelp: 'Добавлять маршруты из AllowedIPs, включая маршрут по умолчанию для 0.0.0.0/0 или ::/0.', routingDisableFirst: 'Выключите профиль перед изменением маршрутизации.', @@ -354,15 +354,18 @@ const ru = { login: 'Войти', placeholderUsername: 'Введите имя пользователя', placeholderPassword: 'Введите пароль', + placeholderCurrentPassword: 'Текущий пароль', placeholderPassword2: 'Введите пароль снова', noEmptyUsername: 'Имя пользователя не может быть пустым', noEmptyPassword: 'Пароль не может быть пустым', + passwordLength: 'Пароль должен содержать от 8 до 72 символов', noAccount: 'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль', invalidUser: 'Неверное имя пользователя или пароль', locked: 'Слишком много входов. Повторите попытку позже.', globalLocked: 'Система находится под защитой. Повторите попытку позже.', error: 'Непредвиденная ошибка', + invalidCurrentPassword: 'Текущий пароль указан неверно', changePassword: 'Изменить пароль', changePasswordDesc: 'Для безопасности вашего устройства, пожалуйста поменяйте пароль!', differentPassword: 'Пароли не совпадают', @@ -920,7 +923,7 @@ const ru = { input: 'Вход', output: 'Выход', fps: 'Измеренный FPS', - usb: 'USB gadget', + usb: 'USB-гаджет', binding: 'Привязка контроллера', selected: 'Выбранные функции', firewall: 'nftables', @@ -991,7 +994,7 @@ const ru = { specialThanksWife: 'Моей жене — за терпение, поддержку и за то, что она переносила моё отсутствие в самую активную фазу разработки.', credits: { - button: 'Credits', + button: 'Титры', title: 'Титры', pause: 'Пауза', resume: 'Продолжить', @@ -1099,7 +1102,7 @@ const ru = { title: 'SSH-баннер', description: 'Выберите баннер для новых интерактивных SSH-сеансов.', default: 'Обычный', - rainbow: 'Rainbow', + rainbow: 'Радуга', failed: 'Не удалось сохранить стиль SSH-баннера.' }, title: 'Внешний вид', @@ -1613,13 +1616,36 @@ const ru = { account: { title: 'Пользователи', webAccount: 'Имя веб-аккаунта', + role: 'Роль', password: 'Пароль', updateBtn: 'Обновить', logoutBtn: 'Выйти', logoutDesc: 'Вы действительно хотите выйти?', logoutFailed: 'Не удалось выйти: сеанс по-прежнему активен.', okBtn: 'Да', - cancelBtn: 'Нет' + cancelBtn: 'Нет', + roles: { + admin: 'Администратор', + user: 'Пользователь' + }, + users: { + title: 'Пользователи', + create: 'Создать пользователя', + enabled: 'Включён', + disabled: 'Отключён', + deviceOwner: 'Владелец устройства', + rename: 'Переименовать', + resetPassword: 'Сбросить пароль', + delete: 'Удалить', + deleteConfirm: 'Удалить пользователя и завершить все его сеансы?', + created: 'Пользователь создан', + deleted: 'Пользователь удалён', + passwordUpdated: 'Пароль обновлён', + usernameUpdated: 'Имя пользователя обновлено', + loadFailed: 'Не удалось загрузить пользователей', + saveFailed: 'Не удалось сохранить пользователя', + deleteFailed: 'Не удалось удалить пользователя' + } }, extensions: { title: 'Расширения', @@ -1658,24 +1684,24 @@ const ru = { connection: { runtime: { checking: 'Проверка', - restoring: 'Restoring PicoClaw', + restoring: 'Восстановление PicoClaw', ready: 'Runtime готов', stopped: 'Runtime остановлен', blockedByMCP: 'Внешнее управление MCP активно', readyBlockedByMCP: - 'The runtime is running, but external MCP currently controls device input.', + 'Runtime запущен, но вводом на устройстве сейчас управляет внешний MCP.', readyWithoutControl: - 'The runtime is running. Grant PicoClaw device control before reconnecting.', + 'Runtime запущен. Передайте PicoClaw управление устройством, прежде чем переподключаться.', unavailable: 'Runtime недоступен', configError: 'Ошибка конфигурации' }, transport: { connecting: 'Подключение', connected: 'Подключено', - disconnected: 'Disconnected', - reconnect: 'Reconnect', - reconnectDescription: 'Reconnect to the running PicoClaw session.', - reconnectBlocked: 'PicoClaw needs device control before reconnecting.' + disconnected: 'Отключено', + reconnect: 'Переподключить', + reconnectDescription: 'Переподключиться к запущенному сеансу PicoClaw.', + reconnectBlocked: 'Для переподключения PicoClaw нужно управление устройством.' }, run: { idle: 'Простой', @@ -1692,21 +1718,22 @@ const ru = { }, control: { picoclaw: 'Управление устройством: PicoClaw', - picoclawDescription: 'PicoClaw can write keyboard and mouse input. Manual input may pause.', + picoclawDescription: + 'PicoClaw может вводить с клавиатуры и мыши. Ручной ввод может приостанавливаться.', mcp: 'Управление устройством: внешний MCP', - mcpDescription: 'External MCP can write to the device. PicoClaw will not take over input.', + mcpDescription: + 'Внешний MCP может управлять устройством. PicoClaw не будет перехватывать ввод.', off: 'Управление устройством: выкл.', - offDescription: - 'AI will not write keyboard or mouse input. Manual control remains available.', - transitioning: 'Device control: switching', - transitioningDescription: 'Device control is syncing. Please wait.', + offDescription: 'ИИ не управляет клавиатурой и мышью. Ручное управление доступно.', + transitioning: 'Управление устройством: переключение', + transitioningDescription: 'Управление устройством синхронизируется. Подождите.', grant: 'Передать управление', release: 'Освободить', - releasing: 'Releasing...', - switching: 'Switching...', - releasingLabel: 'Device control: releasing', + releasing: 'Освобождение…', + switching: 'Переключение…', + releasingLabel: 'Управление устройством: освобождение', releasingDescription: - 'Device control is being returned. PicoClaw has stopped current writes.', + 'Управление устройством возвращается. PicoClaw прекратил текущий ввод.', granted: 'Управление PicoClaw предоставлено', released: 'Управление PicoClaw освобождено', grantFailed: 'Не удалось предоставить управление PicoClaw', @@ -1744,7 +1771,7 @@ const ru = { menuLabel: 'Настройка модели', modelIdentifier: 'Идентификатор модели', modelIdentifierPlaceholder: 'openai/gpt-5.4', - apiBase: 'API Base URL', + apiBase: 'Базовый URL API', apiBasePlaceholder: 'https://api.example.com/v1', apiKey: 'API-ключ', apiKeyPlaceholder: 'Введите API-ключ модели', @@ -1790,8 +1817,8 @@ const ru = { enableConfirmCancel: 'Отмена', title: 'Запустить PicoClaw', description: 'Запустите runtime, чтобы начать использовать помощник PicoClaw.', - switchFromMCP: 'Switch to PicoClaw and start', - takeoverAndStart: 'Take over and start' + switchFromMCP: 'Переключить на PicoClaw и запустить', + takeoverAndStart: 'Перехватить управление и запустить' } }, error: { From c89a342d85cc62b1057f4046b488886474fd1799 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:30:27 +0300 Subject: [PATCH 14/98] web: load only the video player used by the session The screen statically imported the MJPEG, WebRTC (H.264) and Direct players although a session uses exactly one (switching modes reloads the page). Each player is now its own chunk (mjpeg 1.6 kB, h264-direct 3.9 kB, h264-webrtc 8.8 kB). The desktop page fetches the chosen player alongside the encoder state and only then marks the encoder ready, so the player still mounts in the same commit as the mouse/region handlers that attach to its #screen element, and no fallback is shown. If that preload failed, the screen retries the import, renders nothing until it arrives and reports a second failure to the error boundary. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/desktop/index.tsx | 5 +++ web/src/pages/desktop/screen/index.tsx | 43 +++++++++++++++-------- web/src/pages/desktop/screen/players.ts | 46 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 web/src/pages/desktop/screen/players.ts diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index 75cd0689..1a9e6a63 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -46,6 +46,7 @@ import { } from './screen/geometry.ts'; import { InputRegionOverlay } from './screen/input-region-overlay.tsx'; import { ManualRegion } from './screen/manual-region.tsx'; +import { preloadPlayer } from './screen/players.ts'; function getVideoMode() { const directSupported = window.isSecureContext && !!window.VideoDecoder; @@ -151,6 +152,9 @@ export const Desktop = () => { } await initializeEncoderCodec(activeVideoMode === 'h264' ? 'webrtc' : 'direct', codec); }; + // Fetch the player chunk alongside the encoder state, so that the player + // mounts together with the input handlers that attach to #screen. + const playerReady = preloadPlayer(activeVideoMode); void join() .catch((error: unknown) => { if (!active) return; @@ -160,6 +164,7 @@ export const Desktop = () => { : 'screen.encoderStateFailed' ); }) + .then(() => playerReady) .finally(() => { if (!active) return; setVideoMode(activeVideoMode); diff --git a/web/src/pages/desktop/screen/index.tsx b/web/src/pages/desktop/screen/index.tsx index b4d57c61..f6127e6d 100644 --- a/web/src/pages/desktop/screen/index.tsx +++ b/web/src/pages/desktop/screen/index.tsx @@ -1,25 +1,40 @@ +import { useEffect, useState } from 'react'; import { useAtomValue } from 'jotai'; import { videoModeAtom } from '@/jotai/screen.ts'; -import { H264Direct } from './h264-direct.tsx'; -import { H264Webrtc } from './h264-webrtc.tsx'; -import { Mjpeg } from './mjpeg.tsx'; +import { getLoadedPlayer, hasPlayer, loadPlayer, type PlayerProps } from './players.ts'; -export const Screen = ({ onEncoderConflict }: { onEncoderConflict: () => boolean }) => { - const videoMode = useAtomValue(videoModeAtom); +// The desktop page preloads the player before it first renders the screen, so +// the player normally mounts in the same commit as the input handlers that +// attach to its #screen element. Otherwise nothing is shown until it loads. +const PlayerHost = ({ mode, ...props }: PlayerProps & { mode: string }) => { + const [Player, setPlayer] = useState(() => getLoadedPlayer(mode)); + const [error, setError] = useState(null); - if (videoMode === 'mjpeg') { - return ; - } + useEffect(() => { + if (Player) return; + let active = true; + loadPlayer(mode)?.then( + (player) => active && setPlayer(() => player), + (reason: unknown) => active && setError(reason) + ); + return () => { + active = false; + }; + }, [mode, Player]); - if (videoMode === 'direct') { - return ; - } + // Surface a failed chunk request to the error boundary, as a static import would. + if (error) throw error; + return Player ? : null; +}; + +export const Screen = ({ onEncoderConflict }: PlayerProps) => { + const videoMode = useAtomValue(videoModeAtom); - if (videoMode === 'h264') { - return ; + if (!hasPlayer(videoMode)) { + return null; } - return null; + return ; }; diff --git a/web/src/pages/desktop/screen/players.ts b/web/src/pages/desktop/screen/players.ts new file mode 100644 index 00000000..ea5deb9b --- /dev/null +++ b/web/src/pages/desktop/screen/players.ts @@ -0,0 +1,46 @@ +import type { ComponentType } from 'react'; + +export type PlayerProps = { onEncoderConflict: () => boolean }; +type Player = ComponentType; + +// Only one player is used per session, so each one is a separate chunk. +const imports = new Map Promise>([ + ['mjpeg', () => import('./mjpeg.tsx').then((module) => module.Mjpeg)], + ['direct', () => import('./h264-direct.tsx').then((module) => module.H264Direct)], + ['h264', () => import('./h264-webrtc.tsx').then((module) => module.H264Webrtc)] +]); + +const loaded = new Map(); +const pending = new Map>(); + +export function hasPlayer(mode: string): boolean { + return imports.has(mode); +} + +export function getLoadedPlayer(mode: string): Player | undefined { + return loaded.get(mode); +} + +export function loadPlayer(mode: string): Promise | undefined { + const load = imports.get(mode); + if (!load) return undefined; + let promise = pending.get(mode); + if (!promise) { + promise = load().then((player) => { + loaded.set(mode, player); + return player; + }); + // A failed chunk request may be retried by the next caller. + promise.catch(() => pending.delete(mode)); + pending.set(mode, promise); + } + return promise; +} + +// Resolves (never rejects) once the player for `mode` can render synchronously. +export function preloadPlayer(mode: string): Promise { + return Promise.resolve(loadPlayer(mode)).then( + () => undefined, + () => undefined + ); +} From 35b20e9fd8cac5d44249a20abbddbaa74891d47c Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:30:40 +0300 Subject: [PATCH 15/98] i18n: drop orphan keys and mark partial languages The 22 community locales still carried six settings.tailscale keys from the removed manual-install instructions. Remove them, and flag every locale that translates well under 90% of the English keys (all of them except en and ru, at about 42%) as partial. The language selector shows such languages with a translated "partial" note, since half of their interface falls back to English. Danish, Italian and Indonesian are listed by their native names like the other languages. Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/languages.ts | 47 ++++++++++--------- web/src/i18n/locales/ca.ts | 6 --- web/src/i18n/locales/cz.ts | 6 --- web/src/i18n/locales/da.ts | 6 --- web/src/i18n/locales/de.ts | 6 --- web/src/i18n/locales/en.ts | 2 + web/src/i18n/locales/es.ts | 6 --- web/src/i18n/locales/fr.ts | 6 --- web/src/i18n/locales/hu.ts | 6 --- web/src/i18n/locales/id.ts | 6 --- web/src/i18n/locales/it.ts | 6 --- web/src/i18n/locales/ja.ts | 6 --- web/src/i18n/locales/ko.ts | 6 --- web/src/i18n/locales/nb.ts | 6 --- web/src/i18n/locales/nl.ts | 6 --- web/src/i18n/locales/pl.ts | 6 --- web/src/i18n/locales/pt_br.ts | 6 --- web/src/i18n/locales/ru.ts | 2 + web/src/i18n/locales/se.ts | 6 --- web/src/i18n/locales/th.ts | 6 --- web/src/i18n/locales/tr.ts | 6 --- web/src/i18n/locales/uk.ts | 6 --- web/src/i18n/locales/vi.ts | 6 --- web/src/i18n/locales/zh.ts | 6 --- web/src/i18n/locales/zh_tw.ts | 6 --- .../menu/settings/appearance/language.tsx | 11 ++++- 26 files changed, 39 insertions(+), 155 deletions(-) diff --git a/web/src/i18n/languages.ts b/web/src/i18n/languages.ts index 51b67dbc..8c512d78 100644 --- a/web/src/i18n/languages.ts +++ b/web/src/i18n/languages.ts @@ -1,28 +1,31 @@ +// `partial` marks locales that translate well under 90% of the English keys; +// the rest of their interface falls back to English. tests/i18n-keys.test.mjs +// requires every locale without the flag to be complete. const languages = [ - { key: 'ca', name: 'Català' }, - { key: 'nl', name: 'Nederlands' }, - { key: 'da', name: 'Danish' }, - { key: 'de', name: 'Deutsch' }, + { key: 'ca', name: 'Català', partial: true }, + { key: 'nl', name: 'Nederlands', partial: true }, + { key: 'da', name: 'Dansk', partial: true }, + { key: 'de', name: 'Deutsch', partial: true }, { key: 'en', name: 'English' }, - { key: 'es', name: 'Español' }, - { key: 'fr', name: 'Français' }, - { key: 'id', name: 'Indonesia' }, - { key: 'it', name: 'Italian' }, - { key: 'pl', name: 'Polski' }, - { key: 'pt_br', name: 'Português (Brasil)' }, + { key: 'es', name: 'Español', partial: true }, + { key: 'fr', name: 'Français', partial: true }, + { key: 'id', name: 'Bahasa Indonesia', partial: true }, + { key: 'it', name: 'Italiano', partial: true }, + { key: 'pl', name: 'Polski', partial: true }, + { key: 'pt_br', name: 'Português (Brasil)', partial: true }, { key: 'ru', name: 'Русский' }, - { key: 'tr', name: 'Türkçe'}, - { key: 'ko', name: '한국어' }, - { key: 'zh', name: '简体中文' }, - { key: 'zh_tw', name: '繁體中文' }, - { key: 'hu', name: 'Magyar' }, - { key: 'vi', name: 'Tiếng Việt' }, - { key: 'ja', name: '日本語' }, - { key: 'cz', name: 'Česky' }, - { key: 'uk', name: 'Українська' }, - { key: 'nb', name: 'Norsk, bokmål' }, - { key: 'th', name: 'ภาษาไทย' }, - { key: 'se', name: "Svenska"} + { key: 'tr', name: 'Türkçe', partial: true }, + { key: 'ko', name: '한국어', partial: true }, + { key: 'zh', name: '简体中文', partial: true }, + { key: 'zh_tw', name: '繁體中文', partial: true }, + { key: 'hu', name: 'Magyar', partial: true }, + { key: 'vi', name: 'Tiếng Việt', partial: true }, + { key: 'ja', name: '日本語', partial: true }, + { key: 'cz', name: 'Česky', partial: true }, + { key: 'uk', name: 'Українська', partial: true }, + { key: 'nb', name: 'Norsk, bokmål', partial: true }, + { key: 'th', name: 'ภาษาไทย', partial: true }, + { key: 'se', name: "Svenska", partial: true } ]; languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })); diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index ed40f879..7cf4be7f 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -564,12 +564,6 @@ const ca = { installing: 'Instal·lant', failed: 'Error en la instal·lació', retry: 'Actualitza i torna-ho a provar. O instal·la manualment', - download: 'Descarrega el', - package: "paquet d'instal·lació", - unzip: 'i descomprimeix-lo', - upTailscale: 'Puja tailscale al directori /usr/bin/ del NanoKVM', - upTailscaled: 'Puja tailscaled al directori /usr/sbin/ del NanoKVM', - refresh: 'Actualitza la pàgina', notRunning: "Tailscale no s'està executant. Si us plau, inicieu-lo per continuar.", run: 'Comença', notLogin: 'El dispositiu no està vinculat. Inicia sessió per vincular-lo.', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 79955060..286b85b6 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -564,12 +564,6 @@ const cz = { installing: 'Instalace probíhá', failed: 'Instalace se nezdařila', retry: 'Obnovte stránku a zkuste to znovu. Nebo zkuste instalaci manuálně', - download: 'Stáhnout', - package: 'instalační balíček', - unzip: 'a rozbalit ho', - upTailscale: 'Nahrajte Tailscale do adresáře NanoKVM /usr/bin/', - upTailscaled: 'Nahrajte Tailscaled do adresáře NanoKVM /usr/sbin/', - refresh: 'Obnovit stránku', notRunning: 'Tailscale neběží. Chcete-li pokračovat, spusťte jej.', run: 'Spustit', notLogin: diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 286468cc..7ac0b7f6 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -561,12 +561,6 @@ const da = { installing: 'Installerer', failed: 'Installation mislykkedes', retry: 'Opdater siden og prøv igen. Ellers prøv at installere manuelt.', - download: 'Download', - package: 'installationspakken', - unzip: 'og udpak den', - upTailscale: 'Upload tailscale til NanoKVM-mappen /usr/bin/', - upTailscaled: 'Upload tailscaled til NanoKVM-mappen /usr/sbin/', - refresh: 'Opdater sides', notRunning: 'Tailscale kører ikke. Start det for at fortsætte.', run: 'Start', notLogin: diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index b53d7527..717f8978 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -572,12 +572,6 @@ const de = { installing: 'Installiere', failed: 'Installation fehlgeschlagen', retry: 'Bitte Seite neu laden und erneut versuchen oder manuelle Installation versuchen.', - download: 'Laden Sie das', - package: 'Installations-Paket herunter', - unzip: 'und entpacken Sie es', - upTailscale: 'Tailscale nach /usr/bin/ auf NanoKVM hochladen', - upTailscaled: 'Tailscaled nach /usr/bin/ auf NanoKVM hochladen', - refresh: 'Aktuelle Seite neu laden', notRunning: 'Tailscale läuft nicht. Bitte starten Sie es, um fortzufahren.', run: 'Start', notLogin: diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 90d38d31..bbf8cae8 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -1091,6 +1091,8 @@ const en = { customize: 'Customize', language: 'Language', languageDesc: 'Select the language for the interface', + languagePartial: 'partial', + languagePartialHint: 'Partially translated. Missing texts are shown in English.', webTitle: 'Web Title', webTitleDesc: 'Customize the web page title', menuBar: { diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 701043fe..c48f89cb 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -569,12 +569,6 @@ const es = { failed: 'La instalación falló', retry: 'Por favor, actualiza la página e inténtalo de nuevo. O intenta instalarlo manualmente', - download: 'Descargar el', - package: 'paquete de instalación', - unzip: 'y descomprimirlo', - upTailscale: 'Sube tailscale al directorio /usr/bin/ del NanoKVM', - upTailscaled: 'Sube tailscaled al directorio /usr/sbin/ del NanoKVM', - refresh: 'Actualizar la página actual', notRunning: 'Tailscale no se está ejecutando. Por favor, inícialo para continuar.', run: 'Iniciar', notLogin: diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index f0b03923..5581c8e8 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -570,12 +570,6 @@ const fr = { installing: 'Installation', failed: 'Installation échouée', retry: "Veuillez rafraîchir et réessayer. Ou essayez d'installer manuellement", - download: 'Télécharger le', - package: "paquet d'installation", - unzip: 'et décompressez-le', - upTailscale: 'Téléverser tailscale dans le répertoire NanoKVM /usr/sbin/', - upTailscaled: 'Téléverser tailscaled dans le répertoire NanoKVM /usr/sbin/', - refresh: 'Rafraîchir la page courante', notRunning: "Tailscale n'est pas en cours d'exécution. Veuillez le démarrer pour continuer.", run: 'Début', diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 698dc5e0..3b9995ef 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -567,12 +567,6 @@ const hu = { installing: 'Telepítés folyamatban', failed: 'Telepítés sikertelen', retry: 'Frissítse az oldalt, majd próbálja újra. Vagy próbálja meg manuálisan telepíteni.', - download: 'Letöltés a', - package: 'telepítési csomag', - unzip: 'és kicsomagolás', - upTailscale: 'Töltsön fel tailscale-t a NanoKVM /usr/bin/ könyvtárába', - upTailscaled: 'Töltsön fel tailscaled-t a NanoKVM /usr/sbin/ könyvtárába', - refresh: 'Frissítse az aktuális oldalt', notRunning: 'Tailscale nem fut. Kérjük, indítsa el a folytatáshoz.', run: 'Indítás', notLogin: diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index b3c9e078..eef785fe 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -563,12 +563,6 @@ const id = { installing: 'Memasangkan', failed: 'Gagal memasangkan', retry: 'Harap segarkan dan coba lagi. Atau coba instal secara manual', - download: 'Mengunduh', - package: 'paket instalasi', - unzip: 'dan unzip itu', - upTailscale: 'Unggah tailscale ke direktori NanoKVM /usr/bin/', - upTailscaled: 'Unggah tailscaled ke direktori NanoKVM /usr/sbin/', - refresh: 'Segarkan halaman ini', notRunning: 'Tailscale tidak berjalan. Silakan mulai untuk melanjutkan.', run: 'Mulai', notLogin: diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 39b38f2a..7983a1c0 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -569,12 +569,6 @@ const it = { installing: 'Installazione in corso', failed: 'Installazione fallita', retry: 'Riprova aggiornando la pagina o installa manualmente', - download: 'Scarica il', - package: 'pacchetto di installazione', - unzip: 'e decomprimilo', - upTailscale: 'Carica tailscale nella directory /usr/bin/ del NanoKVM', - upTailscaled: 'Carica tailscaled nella directory /usr/sbin/ del NanoKVM', - refresh: 'Aggiorna la pagina corrente', notRunning: 'Tailscale non è in esecuzione. Per favore avvialo per continuare.', run: 'Inizio', notLogin: diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 0cf44de3..fbc5ee1f 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -565,12 +565,6 @@ const ja = { installing: 'インストール中', failed: 'インストールに失敗しました', retry: 'ページを更新してもう一度お試しいただくか、手動でインストールしてください', - download: 'ダウンロードして', - package: 'インストールパッケージを', - unzip: '解凍してください', - upTailscale: 'tailscale ファイルを NanoKVM の /usr/bin ディレクトリにアップロードします', - upTailscaled: 'tailscaled ファイルを NanoKVM の /usr/sbin ディレクトリにアップロードします', - refresh: 'ページを更新します', notRunning: 'Tailscale はまだ実行されていません。起動操作を実行してください', run: '起動', notLogin: diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 9af82987..2c9b84b1 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -559,12 +559,6 @@ const ko = { installing: '설치중', failed: '설치 실패', retry: '새로고침하고 다시 시도하거나, 수동으로 설치하세요', - download: '다운로드 중 :', - package: '패키지 설치', - unzip: '압축 해제', - upTailscale: 'tailscale을 NanoKVM의 /usr/bin/ 경로에 업로드 했습니다.', - upTailscaled: 'tailscaled을 NanoKVM의 /usr/sbin/ 경로에 업로드 했습니다.', - refresh: '현재 페이지 새로고침', notRunning: 'Tailscale이 실행되고 있지 않습니다. 계속하려면 시작해 주세요.', run: '시작', notLogin: '이 기기는 현재 연동 되지 않았습니다. 로그인해서 계정에 이 장치를 연동하세요.', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 4589f5d4..7c8fa2b3 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -562,12 +562,6 @@ const nb = { installing: 'Installerer', failed: 'Installering feilet', retry: 'Vennligst last inn siden på nytt og forsøk igjen eller installer manuelt', - download: 'Last ned', - package: 'installasjonspakken', - unzip: 'og pakk den ut', - upTailscale: 'Last opp Tailscale til NanoKVM-enhetens mappe /usr/bin/', - upTailscaled: 'Last opp tailscaled til NanoKVM-enhetens mappe /usr/sbin/', - refresh: 'Last inn denne siden på nytt', notRunning: 'Tailscale kjører ikke. Start den for å fortsette.', run: 'Start', notLogin: diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index bb164d49..d0820371 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -572,12 +572,6 @@ const nl = { installing: 'Installeren bezig', failed: 'Installatie mislukt', retry: 'Vernieuw en probeer opnieuw. Of probeer handmatig te installeren', - download: 'Download het', - package: 'installatiepakket', - unzip: 'en pak het uit', - upTailscale: 'Upload tailscale naar NanoKVM directory /usr/bin/', - upTailscaled: 'Upload tailscaled naar NanoKVM directory /usr/sbin/', - refresh: 'Vernieuw huidige pagina', notRunning: 'Tailscale is niet actief. Start het programma om door te gaan.', run: 'Begin', notLogin: diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index 418a4dba..b8a09dbd 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -568,12 +568,6 @@ const pl = { installing: 'Instalowanie', failed: 'Instalowanie nie powiodło się', retry: 'Odśwież stronę i spróbuj ponownie, albo spróbuj zainstalować manualnie.', - download: 'Pobierz', - package: 'pakiet instalacyjny', - unzip: 'i wypakuj pliki', - upTailscale: 'Prześlij tailscale do NanoKVM w katalogu /usr/bin/', - upTailscaled: 'Prześlij tailscaled do NanoKVM w katalogu /usr/sbin/', - refresh: 'Odśwież obecną stronę', notRunning: 'Tailscale nie działa. Rozpocznij, aby kontynuować.', run: 'Rozpocznij', notLogin: diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 55f70687..9c07fde3 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -567,12 +567,6 @@ const pt_br = { installing: 'Instalando', failed: 'Falha na instalação', retry: 'Por favor, atualize e tente novamente. Ou tente instalar manualmente', - download: 'Baixar o', - package: 'pacote de instalação', - unzip: 'e descompacte-o', - upTailscale: 'Fazer upload do tailscale para o diretório NanoKVM /usr/bin/', - upTailscaled: 'Fazer upload do tailscaled para o diretório NanoKVM /usr/sbin/', - refresh: 'Atualizar página atual', notRunning: 'Tailscale não está em execução. Por favor, inicie-o para continuar.', run: 'Iniciar', notLogin: diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 417d4fe6..028055b8 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -1110,6 +1110,8 @@ const ru = { customize: 'Настройка', language: 'Язык', languageDesc: 'Выбор языка интерфейса', + languagePartial: 'частично', + languagePartialHint: 'Перевод неполный. Недостающие тексты показываются на английском.', webTitle: 'Заголовок страницы', webTitleDesc: 'Изменить заголовок страницы', menuBar: { diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index f0bfe3a1..31a9f34e 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -561,12 +561,6 @@ const se = { installing: 'Installerar', failed: 'Installationen misslyckades', retry: 'Uppdatera sidan och försök igen. Eller installera manuellt', - download: 'Ladda ner', - package: 'installationspaketet', - unzip: 'och packa upp det', - upTailscale: 'Ladda upp tailscale till NanoKVM-katalogen /usr/bin/', - upTailscaled: 'Ladda upp tailscaled till NanoKVM-katalogen /usr/sbin/', - refresh: 'Uppdatera sidan', notRunning: 'Tailscale körs inte. Starta den för att fortsätta.', run: 'Start', notLogin: 'Enheten är ännu inte bunden. Logga in och bind enheten till ditt konto.', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index 8ffddf74..77857023 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -554,12 +554,6 @@ const th = { installing: 'กำลังติดตั้ง', failed: 'การติดตั้งล้มเหลว', retry: 'กรุณารีเฟรชและลองใหม่ หรือพยายามติดตั้งด้วยตนเอง', - download: 'ดาวน์โหลด', - package: 'แพ็กเกจการติดตั้ง', - unzip: 'และแตกไฟล์', - upTailscale: 'อัปโหลด tailscale ไปยังไดเรกทอรี /usr/bin/ ของ NanoKVM', - upTailscaled: 'อัปโหลด tailscaled ไปยังไดเรกทอรี /usr/sbin/ ของ NanoKVM', - refresh: 'รีเฟรชหน้าปัจจุบัน', notRunning: 'Tailscale ไม่ทำงาน กรุณาเริ่มต้นเพื่อดำเนินการต่อ', run: 'เริ่ม', notLogin: 'อุปกรณ์ยังไม่ได้ผูกบัญชี กรุณาล็อกอินและผูกอุปกรณ์นี้กับบัญชีของคุณ', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 78c48fac..635f75e6 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -569,12 +569,6 @@ const tr = { installing: 'İndiriliyor', failed: 'İndirme başarısız oldu', retry: 'Lütfen sayfayı yenileyin ve tekrar deneyin, ya da manuel indirin', - download: 'İndir', - package: 'yükleme paketi', - unzip: 'sıkışmış dosyayı açın', - upTailscale: "tailscale dosyasını NanoKVM'in /usr/bin dizinine yükleyin", - upTailscaled: "tailscaled dosyasını NanoKVM'in /usr/sbin dizinine yükleyin", - refresh: 'İçinde bulunduğunuz sayfayı yenileyin', notRunning: 'Tailscale çalışmıyor. Devam etmek için lütfen başlatın.', run: 'Başlat', notLogin: 'Cihaz bağlı değil. Lütfen giriş yapıp cihazınızı hesabınıza bağlayın.', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 4551af85..c8b43db7 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -566,12 +566,6 @@ const uk = { installing: 'Встановлення', failed: 'Не вдалося встановити', retry: 'Будь ласка, оновіть сторінку та спробуйте ще раз. Або спробуйте встановити вручну', - download: 'Завантажте', - package: 'пакет встановлення', - unzip: 'та розпакуйте його', - upTailscale: 'Завантажте Tailscale до каталогу /usr/bin/ на NanoKVM', - upTailscaled: 'Завантажте Tailscaled до каталогу /usr/sbin/ на NanoKVM', - refresh: 'Оновіть поточну сторінку', notRunning: 'Tailscale не працює. Щоб продовжити, запустіть його.', run: 'Початок', notLogin: diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index 07caa823..28145bae 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -560,12 +560,6 @@ const vi = { installing: 'Đang cài đặt', failed: 'Cài đặt thất bại', retry: 'Vui lòng làm mới và thử lại. Hoặc thử cài đặt thủ công', - download: 'Tải xuống', - package: 'gói cài đặt', - unzip: 'và giải nén nó', - upTailscale: 'Tải tailscale lên thư mục /usr/bin/ của NanoKVM', - upTailscaled: 'Tải tailscaled lên thư mục /usr/sbin/ của NanoKVM', - refresh: 'Làm mới trang hiện tại', notRunning: 'Tailscale không chạy. Hãy bắt đầu nó để tiếp tục.', run: 'Bắt đầu', notLogin: diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index a40a6188..dc81abfa 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -562,12 +562,6 @@ const zh = { installing: '安装中', failed: '安装失败', retry: '请刷新后重试,或尝试手动安装', - download: '下载', - package: '安装包', - unzip: '并解压', - upTailscale: '将 tailscale 上传到 NanoKVM 的 /usr/bin/ 目录', - upTailscaled: '将 tailscaled 上传到 NanoKVM 的 /usr/sbin/ 目录', - refresh: '刷新页面', notRunning: 'Tailscale 尚未运行,请先执行启动操作', run: '启动', notLogin: '该设备尚未绑定,请点击登录并将这台设备绑定到您的账号。', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index eda76dfc..7f4f2146 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -548,12 +548,6 @@ const zh_tw = { installing: '安裝中', failed: '安裝失敗', retry: '請重新整理並重試。或嘗試手動安裝', - download: '下載', - package: '安裝包', - unzip: '並解壓縮它', - upTailscale: '將 Tailscale 上傳到 NanoKVM 的 /usr/bin/ 資料夾', - upTailscaled: '將 Tailscale 上傳到 NanoKVM 的 /usr/sbin/ 資料夾', - refresh: '重新整理頁面', notRunning: 'Tailscale 尚未執行', run: '啟動', notLogin: '設備尚未綁定。請登入並將該裝置綁定到您的帳戶。', diff --git a/web/src/pages/desktop/menu/settings/appearance/language.tsx b/web/src/pages/desktop/menu/settings/appearance/language.tsx index 964ac9d8..19a44a03 100644 --- a/web/src/pages/desktop/menu/settings/appearance/language.tsx +++ b/web/src/pages/desktop/menu/settings/appearance/language.tsx @@ -12,7 +12,16 @@ export const Language = () => { const options = languages.map((language) => ({ value: localeTag(language.key), - label: language.name + label: language.partial ? ( + + {language.name}{' '} + + ({t('settings.appearance.languagePartial')}) + + + ) : ( + language.name + ) })); async function changeLanguage(value: string) { From 41d47083e42c62a5503df0a0abc06f4c304be052 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:31:21 +0300 Subject: [PATCH 16/98] web: report failed requests instead of swallowing them Many actions only logged a failure with console.log or had no rejection handler at all, so the user saw nothing (or an unhandled rejection in the console) when a script upload, image mount or delete, Mouse Jiggler, hostname, web title, mDNS, reboot, keyboard shortcut or Tailscale restart/stop/uninstall request failed; the HDMI idle timeout silently reverted, and a failed HDMI state read left the switch spinning. Add lib/request-error.ts, which recognizes the failures the shared HTTP client already handles (aborted requests, 401 session expiry, the forced password change redirect) and builds the text to show: a localized lead plus the server's message as detail. showRequestError() shows it with the antd message API, as the settings pages already do, and takes a translation key so loaders called from mount effects need no `t` dependency. Wake-on-LAN keeps reporting in its own status line, the image mount keeps its existing notification, and the background leader key read now just keeps the current key on failure. The HTTP client now logs only unexpected errors rather than every aborted or 401 request. The ISO upload keeps fetch() for its progress handling but no longer assumes a JSON body: a 413 or other non-JSON error page now shows "Download failed (HTTP 413)" instead of a JSON parse error, and a 401 signals session expiry like the shared client. Add error.requestFailed (en, ru) and tests for the error classification. Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/en.ts | 3 +- web/src/i18n/locales/ru.ts | 3 +- web/src/lib/http.ts | 7 +- web/src/lib/request-error.ts | 32 +++++ web/src/lib/show-request-error.ts | 12 ++ .../pages/desktop/keyboard/useLeaderKey.ts | 15 ++- web/src/pages/desktop/menu/download.tsx | 14 ++- web/src/pages/desktop/menu/image/images.tsx | 24 ++-- .../desktop/menu/keyboard/shortcuts/index.tsx | 13 ++- web/src/pages/desktop/menu/script/index.tsx | 56 +++++---- .../menu/settings/appearance/web-title.tsx | 8 +- .../desktop/menu/settings/device/hdmi.tsx | 40 ++++--- .../menu/settings/device/mouse-jiggler.tsx | 10 +- .../desktop/menu/settings/device/reboot.tsx | 5 +- .../menu/settings/network/hostname.tsx | 5 +- .../desktop/menu/settings/network/mdns.tsx | 9 +- .../menu/settings/tailscale/header.tsx | 29 +++-- .../menu/settings/tailscale/uninstall.tsx | 17 ++- web/src/pages/desktop/menu/wol/index.tsx | 110 ++++++++++-------- web/tests/request-error.test.mjs | 33 ++++++ 20 files changed, 307 insertions(+), 138 deletions(-) create mode 100644 web/src/lib/request-error.ts create mode 100644 web/src/lib/show-request-error.ts create mode 100644 web/tests/request-error.test.mjs diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 68fefd5f..245f7893 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -1696,7 +1696,8 @@ const en = { }, error: { title: "We've ran into an issue", - refresh: 'Refresh' + refresh: 'Refresh', + requestFailed: 'The request failed. Check the connection and try again.' }, fullscreen: { toggle: 'Toggle Fullscreen' diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 861bb05c..24a86cc7 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -1698,7 +1698,8 @@ const ru = { }, error: { title: 'У нас возникла проблема', - refresh: 'Обновить страницу' + refresh: 'Обновить страницу', + requestFailed: 'Не удалось выполнить запрос. Проверьте подключение и повторите попытку.' }, fullscreen: { toggle: 'Полноэкранный режим' diff --git a/web/src/lib/http.ts b/web/src/lib/http.ts index 057f6873..67f7faab 100644 --- a/web/src/lib/http.ts +++ b/web/src/lib/http.ts @@ -1,11 +1,9 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { notifyAuthExpired } from '@/lib/auth-events.ts'; +import { isHandledRequestError, passwordChangeRequiredCode } from '@/lib/request-error.ts'; import { getBaseUrl } from '@/lib/service.ts'; -// Matches middleware.PasswordChangeRequiredCode on the server. -const passwordChangeRequiredCode = -10; - type Response = { code: number; msg: string; @@ -42,7 +40,8 @@ class Http { return response.data; }, (error) => { - console.log(error); + // Aborts, session expiry and the password redirect are expected. + if (!isHandledRequestError(error)) console.log(error); const code = error.response?.status; if (code === 401) { notifyAuthExpired(); diff --git a/web/src/lib/request-error.ts b/web/src/lib/request-error.ts new file mode 100644 index 00000000..e4c72837 --- /dev/null +++ b/web/src/lib/request-error.ts @@ -0,0 +1,32 @@ +import { isAxiosError, isCancel } from 'axios'; + +// Matches middleware.PasswordChangeRequiredCode on the server. +export const passwordChangeRequiredCode = -10; + +// Failures the shared HTTP client already handles: aborted requests, an +// expired session (the app returns to the login page) and the forced +// password change (the app opens the password page). +export function isHandledRequestError(error: unknown) { + if (isCancel(error)) return true; + if (!isAxiosError(error)) return false; + if (error.code === 'ERR_CANCELED') return true; + const status = error.response?.status; + return ( + status === 401 || (status === 403 && error.response?.data?.code === passwordChangeRequiredCode) + ); +} + +function serverMessage(error: unknown) { + const body = isAxiosError(error) ? error.response?.data : error; + if (!body || typeof body !== 'object' || !('msg' in body)) return ''; + return typeof body.msg === 'string' ? body.msg.trim() : ''; +} + +// What to tell the user about a failed request: a rejected request or an API +// response with a non-zero code. The localized fallback leads; the server's +// own message follows as detail. Null when nothing should be shown. +export function requestErrorText(error: unknown, fallback: string): string | null { + if (isHandledRequestError(error)) return null; + const detail = serverMessage(error); + return detail && detail !== fallback ? `${fallback}: ${detail}` : fallback; +} diff --git a/web/src/lib/show-request-error.ts b/web/src/lib/show-request-error.ts new file mode 100644 index 00000000..cc8a833a --- /dev/null +++ b/web/src/lib/show-request-error.ts @@ -0,0 +1,12 @@ +import { message } from 'antd'; +import i18n from 'i18next'; + +import { requestErrorText } from '@/lib/request-error.ts'; + +// Show a failed request (a rejection or an API response with a non-zero code) +// with the antd message API, unless the shared client already handled it. +// Takes a translation key so loaders need no `t` dependency. +export function showRequestError(error: unknown, key = 'error.requestFailed') { + const text = requestErrorText(error, i18n.t(key)); + if (text) void message.error(text); +} diff --git a/web/src/pages/desktop/keyboard/useLeaderKey.ts b/web/src/pages/desktop/keyboard/useLeaderKey.ts index e48f0c34..b3903a65 100644 --- a/web/src/pages/desktop/keyboard/useLeaderKey.ts +++ b/web/src/pages/desktop/keyboard/useLeaderKey.ts @@ -40,11 +40,16 @@ export function useLeaderKey( // Get leader key useEffect(() => { - api.getLeaderKey().then((rsp: any) => { - if (rsp.code === 0 && rsp.data?.key !== undefined) { - setLeaderKeyCode(rsp.data.key); - } - }); + api + .getLeaderKey() + .then((rsp: any) => { + if (rsp.code === 0 && rsp.data?.key !== undefined) { + setLeaderKeyCode(rsp.data.key); + } + }) + .catch(() => { + /* Keep the current leader key; the request is retried on the next page load. */ + }); }, [setLeaderKeyCode]); // Update leader key diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx index 43d367a9..b66aba79 100644 --- a/web/src/pages/desktop/menu/download.tsx +++ b/web/src/pages/desktop/menu/download.tsx @@ -8,6 +8,7 @@ import { DownloadIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cancelDownloadImage, downloadImage, imageEnabled, statusImage } from '@/api/download.ts'; +import { notifyAuthExpired } from '@/lib/auth-events.ts'; import { readTransferProgress, transferBytes } from '@/lib/download-progress'; import { keyboardLockAtom } from '@/jotai/keyboard.ts'; import { MenuItem } from '@/components/menu-item.tsx'; @@ -309,10 +310,15 @@ export const DownloadImage = () => { body: formData }) .then(async (response) => { - const rsp = await response.json(); - if (!response.ok || rsp.code !== 0) { - const message = rsp.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp.msg; - throw new Error(message || t('download.failed')); + // Error pages from the server or a proxy (e.g. 413) need not be JSON. + const rsp = await response.json().catch(() => null); + if (response.status === 401) { + // Same as the shared HTTP client: the session expired, sign in again. + notifyAuthExpired(); + } + if (!response.ok || rsp?.code !== 0) { + const message = rsp?.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp?.msg; + throw new Error(message || `${t('download.failed')} (HTTP ${response.status})`); } finishImageTransfer(true); diff --git a/web/src/pages/desktop/menu/image/images.tsx b/web/src/pages/desktop/menu/image/images.tsx index 608517f6..9fed93fb 100644 --- a/web/src/pages/desktop/menu/image/images.tsx +++ b/web/src/pages/desktop/menu/image/images.tsx @@ -12,6 +12,7 @@ import { import { useTranslation } from 'react-i18next'; import * as api from '@/api/storage.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { client } from '@/lib/websocket.ts'; const imageUpdatedEvent = 'nanokvm:image-updated'; @@ -38,13 +39,18 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = // get mounted image const getMountedImage = useCallback(() => { - api.getMountedImage().then((rsp) => { - if (rsp.code !== 0) return; + api + .getMountedImage() + .then((rsp) => { + if (rsp.code !== 0) return; - const file = rsp.data?.file; - setMountedImage(file); - setIsMounted(!!file); - }); + const file = rsp.data?.file; + setMountedImage(file); + setIsMounted(!!file); + }) + .catch(() => { + /* The image list read below reports the failure. */ + }); }, [setIsMounted]); const imageReadInFlight = useRef(false); @@ -70,6 +76,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = setImages([]); } }) + .catch((err) => showRequestError(err)) .finally(() => { imageReadInFlight.current = false; setIsLoading(false); @@ -105,7 +112,6 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = .mountImage(filename, cdrom, force) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); if (rsp.code === -4 && isMounted && !force) { setForceEjectImage(image); } else { @@ -117,6 +123,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = setMountedImage(filename); setIsMounted(!!filename); }) + .catch(() => openNotification(isMounted)) .finally(() => { setMountingImage(''); client.connect(); @@ -149,7 +156,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = .deleteImage(selectedImage) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } @@ -157,6 +164,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = setSelectedImage(''); }) + .catch((err) => showRequestError(err)) .finally(() => { setDeletingImage(''); }); diff --git a/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx b/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx index e5575dc2..2610ee91 100644 --- a/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx +++ b/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx @@ -5,6 +5,7 @@ import { CommandIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/hid.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { MenuSubmenu } from '@/components/menu-item.tsx'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -44,13 +45,13 @@ export const Shortcuts = () => { try { const rsp = await api.getShortcuts(); if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } setCustomShortcuts(rsp.data.shortcuts); } catch (err) { - console.log(err); + showRequestError(err); } } @@ -58,13 +59,13 @@ export const Shortcuts = () => { try { const rsp = await api.addShortcut(shortcut.keys); if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } await getShortcuts(); } catch (err) { - console.log(err); + showRequestError(err); } } @@ -74,13 +75,13 @@ export const Shortcuts = () => { const rsp = await api.deleteShortcut(shortcut.id); if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } await getShortcuts(); } catch (err) { - console.log(err); + showRequestError(err); } } diff --git a/web/src/pages/desktop/menu/script/index.tsx b/web/src/pages/desktop/menu/script/index.tsx index b6465340..963908f5 100644 --- a/web/src/pages/desktop/menu/script/index.tsx +++ b/web/src/pages/desktop/menu/script/index.tsx @@ -6,6 +6,7 @@ import { ChevronRightIcon, FileJsonIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/script.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { MenuItem } from '@/components/menu-item.tsx'; import { Run } from './run'; @@ -50,7 +51,7 @@ export const Script = () => { .uploadScript(formData) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } @@ -58,6 +59,7 @@ export const Script = () => { setScripts([...scripts, rsp.data.file]); } }) + .catch((err) => showRequestError(err)) .finally(() => { setIsUploading(false); }); @@ -69,39 +71,45 @@ export const Script = () => { if (type === 'foreground') { setIsRunning(true); } else { - api.runScript(currentScript, type).then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } - }); + api + .runScript(currentScript, type) + .then((rsp) => { + if (rsp.code !== 0) showRequestError(rsp, 'script.runFailed'); + }) + .catch((err) => showRequestError(err, 'script.runFailed')); } } function getScripts() { - api.getScripts().then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } - - if (rsp.data?.files?.length > 0) { - setScripts(rsp.data.files); - } - }); + api + .getScripts() + .then((rsp) => { + if (rsp.code !== 0) { + showRequestError(rsp); + return; + } + + if (rsp.data?.files?.length > 0) { + setScripts(rsp.data.files); + } + }) + .catch((err) => showRequestError(err)); } function deleteScript() { if (!currentScript) return; - api.deleteScript(currentScript).then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } + api + .deleteScript(currentScript) + .then((rsp) => { + if (rsp.code !== 0) { + showRequestError(rsp); + return; + } - setScripts(scripts.filter((script) => script !== currentScript)); - }); + setScripts(scripts.filter((script) => script !== currentScript)); + }) + .catch((err) => showRequestError(err)); } function activate(script: string) { diff --git a/web/src/pages/desktop/menu/settings/appearance/web-title.tsx b/web/src/pages/desktop/menu/settings/appearance/web-title.tsx index 35d918d9..819ef80e 100644 --- a/web/src/pages/desktop/menu/settings/appearance/web-title.tsx +++ b/web/src/pages/desktop/menu/settings/appearance/web-title.tsx @@ -4,6 +4,7 @@ import { useAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/vm.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { webTitleAtom } from '@/jotai/settings.ts'; export const WebTitle = () => { @@ -22,6 +23,7 @@ export const WebTitle = () => { setWebTitle(rsp.data.title); } }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); @@ -34,11 +36,9 @@ export const WebTitle = () => { api .setWebTitle(webTitle) .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } + if (rsp.code !== 0) showRequestError(rsp); }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); diff --git a/web/src/pages/desktop/menu/settings/device/hdmi.tsx b/web/src/pages/desktop/menu/settings/device/hdmi.tsx index 56be8094..65299e39 100644 --- a/web/src/pages/desktop/menu/settings/device/hdmi.tsx +++ b/web/src/pages/desktop/menu/settings/device/hdmi.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import * as api from '@/api/vm.ts'; import { changeCapture, refreshCapture } from '@/lib/capture-control.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { isHdmiEnabledAtom } from '@/jotai/screen.ts'; export const Hdmi = () => { @@ -24,26 +25,34 @@ export const Hdmi = () => { }, []); async function getHardware() { - const rsp = await api.getHardware(); - if (rsp.code !== 0) { - return; + try { + const rsp = await api.getHardware(); + if (rsp.code !== 0) { + return; + } + + setIsPcie(rsp.data?.version === 'PCIE'); + } catch (err) { + showRequestError(err); } - - setIsPcie(rsp.data?.version === 'PCIE'); } async function getHdmiState() { setIsLoading(true); - const rsp = await api.getHdmiState(); - if (rsp.code === 0) { - await refreshCapture(); - const timeout = rsp.data.idleTimeout ?? 0; - setIdleTimeout(timeout); - setIdleTimeoutInput(timeout); + try { + const rsp = await api.getHdmiState(); + if (rsp.code === 0) { + await refreshCapture(); + const timeout = rsp.data.idleTimeout ?? 0; + setIdleTimeout(timeout); + setIdleTimeoutInput(timeout); + } + } catch (err) { + showRequestError(err); + } finally { + setIsLoading(false); } - - setIsLoading(false); } function updateIdleTimeout() { @@ -62,11 +71,16 @@ export const Hdmi = () => { .then((rsp) => { if (rsp.code !== 0) { setIdleTimeoutInput(idleTimeout); + showRequestError(rsp); return; } setIdleTimeout(idleTimeoutInput); }) + .catch((err) => { + setIdleTimeoutInput(idleTimeout); + showRequestError(err); + }) .finally(() => { setIsIdleTimeoutLoading(false); }); diff --git a/web/src/pages/desktop/menu/settings/device/mouse-jiggler.tsx b/web/src/pages/desktop/menu/settings/device/mouse-jiggler.tsx index 37963496..f01924c1 100644 --- a/web/src/pages/desktop/menu/settings/device/mouse-jiggler.tsx +++ b/web/src/pages/desktop/menu/settings/device/mouse-jiggler.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import * as api from '@/api/vm.ts'; import { setMouseJiggler } from '@/api/vm.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; export const MouseJiggler = () => { const { t } = useTranslation(); @@ -29,13 +30,14 @@ export const MouseJiggler = () => { .getMouseJiggler() .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } setEnabled(rsp.data.enabled); setMode(rsp.data.mode); }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); @@ -49,12 +51,13 @@ export const MouseJiggler = () => { .setMouseJiggler(true, mode) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } setEnabled(true); }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); @@ -70,13 +73,14 @@ export const MouseJiggler = () => { setMouseJiggler(_enabled, _mode) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } setEnabled(_enabled); setMode(_mode); }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); diff --git a/web/src/pages/desktop/menu/settings/device/reboot.tsx b/web/src/pages/desktop/menu/settings/device/reboot.tsx index e91607ad..ae118abf 100644 --- a/web/src/pages/desktop/menu/settings/device/reboot.tsx +++ b/web/src/pages/desktop/menu/settings/device/reboot.tsx @@ -4,6 +4,7 @@ import { Button, Popconfirm } from 'antd'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/vm.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; export const Reboot = () => { const { t } = useTranslation(); @@ -22,13 +23,13 @@ export const Reboot = () => { .reboot() .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); setIsLoading(false); clearTimeout(timeoutId); } }) .catch((err) => { - console.log(err); + showRequestError(err); setIsLoading(false); clearTimeout(timeoutId); }); diff --git a/web/src/pages/desktop/menu/settings/network/hostname.tsx b/web/src/pages/desktop/menu/settings/network/hostname.tsx index a11bd9ed..bb157c6a 100644 --- a/web/src/pages/desktop/menu/settings/network/hostname.tsx +++ b/web/src/pages/desktop/menu/settings/network/hostname.tsx @@ -5,6 +5,7 @@ import { ClipboardPenIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/vm.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; export const Hostname = ({ editable = false }: { editable?: boolean }) => { const { t } = useTranslation(); @@ -29,6 +30,7 @@ export const Hostname = ({ editable = false }: { editable?: boolean }) => { setHostname(rsp.data?.hostname); } }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); @@ -52,13 +54,14 @@ export const Hostname = ({ editable = false }: { editable?: boolean }) => { .setHostname(input) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } setHostname(input); setEditState('edited'); }) + .catch((err) => showRequestError(err)) .finally(() => { setIsLoading(false); }); diff --git a/web/src/pages/desktop/menu/settings/network/mdns.tsx b/web/src/pages/desktop/menu/settings/network/mdns.tsx index ee304a88..5ac30193 100644 --- a/web/src/pages/desktop/menu/settings/network/mdns.tsx +++ b/web/src/pages/desktop/menu/settings/network/mdns.tsx @@ -4,6 +4,7 @@ import { CircleAlertIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/vm.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; export const Mdns = () => { const { t } = useTranslation(); @@ -35,15 +36,19 @@ export const Mdns = () => { try { const next = !isEnabled; const rsp = next ? await api.enableMdns() : await api.disableMdns(); - if (rsp.code !== 0) return; + if (rsp.code !== 0) { + showRequestError(rsp); + return; + } setIsEnabled(next); setAddress(''); if (next) { const info = await api.getInfo(); if (info.code === 0) setAddress(info.data?.mdns || ''); } - } catch { + } catch (err) { // Keep the last confirmed state if the request fails. + showRequestError(err); } finally { setIsLoading(false); } diff --git a/web/src/pages/desktop/menu/settings/tailscale/header.tsx b/web/src/pages/desktop/menu/settings/tailscale/header.tsx index 7c53bc0b..67726df3 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/header.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/header.tsx @@ -4,6 +4,7 @@ import { CircleStopIcon, EllipsisIcon, LoaderIcon, RotateCwIcon } from 'lucide-r import { useTranslation } from 'react-i18next'; import * as api from '@/api/extensions/tailscale.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { VPNVersion } from '../vpn/version'; import type { State } from './types.ts'; @@ -25,20 +26,32 @@ export const Header = ({ state, onSuccess }: HeaderProps) => { if (loading !== '') return; setLoading('restarting'); - api.restart().finally(() => { - setLoading(''); - onSuccess(); - }); + api + .restart() + .then((rsp) => { + if (rsp.code !== 0) showRequestError(rsp); + }) + .catch((err) => showRequestError(err)) + .finally(() => { + setLoading(''); + onSuccess(); + }); } function stop() { if (loading !== '') return; setLoading('stopping'); - api.stop().finally(() => { - setLoading(''); - onSuccess(); - }); + api + .stop() + .then((rsp) => { + if (rsp.code !== 0) showRequestError(rsp); + }) + .catch((err) => showRequestError(err)) + .finally(() => { + setLoading(''); + onSuccess(); + }); } return ( diff --git a/web/src/pages/desktop/menu/settings/tailscale/uninstall.tsx b/web/src/pages/desktop/menu/settings/tailscale/uninstall.tsx index 1ed65cb1..6c00b213 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/uninstall.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/uninstall.tsx @@ -4,6 +4,7 @@ import { Trash2Icon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/extensions/tailscale.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; type UninstallProps = { onSuccess: () => void; @@ -19,11 +20,17 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => { if (isLoading) return; setIsLoading(true); - api.uninstall().finally(() => { - setIsModalOpen(false); - setIsLoading(false); - onSuccess(); - }); + api + .uninstall() + .then((rsp) => { + if (rsp.code !== 0) showRequestError(rsp); + }) + .catch((err) => showRequestError(err)) + .finally(() => { + setIsModalOpen(false); + setIsLoading(false); + onSuccess(); + }); } const title = ( diff --git a/web/src/pages/desktop/menu/wol/index.tsx b/web/src/pages/desktop/menu/wol/index.tsx index 9105c93e..1e789607 100644 --- a/web/src/pages/desktop/menu/wol/index.tsx +++ b/web/src/pages/desktop/menu/wol/index.tsx @@ -17,6 +17,7 @@ import { import { useTranslation } from 'react-i18next'; import { deleteWolMac, getWolInterfaces, getWolMacs, setWolMacName, wol } from '@/api/network.ts'; +import { isHandledRequestError } from '@/lib/request-error.ts'; import { keyboardLockAtom } from '@/jotai/keyboard.ts'; import { MenuItem } from '@/components/menu-item.tsx'; @@ -60,49 +61,60 @@ export const Wol = () => { } function getInterfaces() { - getWolInterfaces().then((rsp) => { - if (rsp.code !== 0) { - setInterfaces([]); - setNetworkInterface(''); - return; - } + getWolInterfaces() + .then((rsp) => { + if (rsp.code !== 0) { + setInterfaces([]); + setNetworkInterface(''); + return; + } - const available = rsp.data.interfaces as string[]; - setInterfaces(available); - setNetworkInterface((current) => { - if (available.includes(current)) return current; - if (available.includes('eth0')) return 'eth0'; - return available[0] || ''; - }); - }); + const available = rsp.data.interfaces as string[]; + setInterfaces(available); + setNetworkInterface((current) => { + if (available.includes(current)) return current; + if (available.includes('eth0')) return 'eth0'; + return available[0] || ''; + }); + }) + .catch(showFailure); } function handleChange(e: ChangeEvent) { setInput(e.target.value); } + // Requests here report failures in the popover's status line. + function showFailure(error?: unknown, detail?: string) { + if (isHandledRequestError(error)) return; + setStatus('failed'); + setLog(detail || t('auth.error')); + } + function getMacs() { - getWolMacs().then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } + getWolMacs() + .then((rsp) => { + if (rsp.code !== 0) { + showFailure(undefined, rsp.msg); + return; + } - const isEdit = false; - const macList = rsp.data.macs - .map((item: string) => item.trim()) - .filter((item: string) => item !== '') - .map((item: string) => { - const separator = item.search(/\s/); - const mac = separator === -1 ? item : item.slice(0, separator); - const name = separator === -1 ? '' : item.slice(separator).trim(); - const isName = name !== ''; - const isShow = !isName; - return { name, mac, isShow, isName, isEdit }; - }); + const isEdit = false; + const macList = rsp.data.macs + .map((item: string) => item.trim()) + .filter((item: string) => item !== '') + .map((item: string) => { + const separator = item.search(/\s/); + const mac = separator === -1 ? item : item.slice(0, separator); + const name = separator === -1 ? '' : item.slice(separator).trim(); + const isName = name !== ''; + const isShow = !isName; + return { name, mac, isShow, isName, isEdit }; + }); - setMacList(macList); - }); + setMacList(macList); + }) + .catch(showFailure); } function toggleShow(mac: string) { @@ -121,21 +133,28 @@ export const Wol = () => { value = value.trim(); if (!value) return; - const rsp = await setWolMacName(mac, value); - if (rsp.code !== 0) { - setStatus('failed'); - setLog(rsp.msg || t('auth.error')); - return; + try { + const rsp = await setWolMacName(mac, value); + if (rsp.code !== 0) { + showFailure(undefined, rsp.msg); + return; + } + getMacs(); + } catch (err) { + showFailure(err); } - getMacs(); } function deleteMac(mac: string) { - deleteWolMac(mac).then((rsp) => { - if (rsp.code === 0) { + deleteWolMac(mac) + .then((rsp) => { + if (rsp.code !== 0) { + showFailure(undefined, rsp.msg); + return; + } getMacs(); - } - }); + }) + .catch(showFailure); } function wake(mac?: string) { @@ -160,10 +179,7 @@ export const Wol = () => { getMacs(); setInput(''); }) - .catch(() => { - setStatus('failed'); - setLog(t('auth.error')); - }); + .catch(showFailure); } const content = ( diff --git a/web/tests/request-error.test.mjs b/web/tests/request-error.test.mjs new file mode 100644 index 00000000..d7e5e8ab --- /dev/null +++ b/web/tests/request-error.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AxiosError, CanceledError } from 'axios'; + +import { isHandledRequestError, requestErrorText } from '../src/lib/request-error.ts'; + +const httpError = (status, data = {}) => + new AxiosError('Request failed', 'ERR_BAD_REQUEST', undefined, undefined, { + status, + data, + statusText: '', + headers: {}, + config: {} + }); + +test('aborted requests, expired sessions and the password redirect stay silent', () => { + assert.equal(isHandledRequestError(new CanceledError()), true); + assert.equal(isHandledRequestError(httpError(401)), true); + assert.equal(isHandledRequestError(httpError(403, { code: -10 })), true); + assert.equal(requestErrorText(httpError(401), 'Failed'), null); +}); + +test('unexpected failures are shown with the server detail when present', () => { + assert.equal(isHandledRequestError(httpError(403, { code: -1 })), false); + assert.equal(requestErrorText(httpError(500), 'Failed'), 'Failed'); + assert.equal( + requestErrorText(httpError(400, { msg: 'invalid hostname' }), 'Failed'), + 'Failed: invalid hostname' + ); + assert.equal(requestErrorText({ code: -2, msg: 'busy' }, 'Failed'), 'Failed: busy'); + assert.equal(requestErrorText({ code: -2, msg: '' }, 'Failed'), 'Failed'); + assert.equal(requestErrorText(new Error('Network Error'), 'Failed'), 'Failed'); +}); From b493719d5ac61b024553cb8cb7b2136d2df9bb2d Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:31:25 +0300 Subject: [PATCH 17/98] web: check locale keys in the test suite tests/i18n-keys.test.mjs loads every locale and fails when a locale has keys that en.ts does not define, when a language without the partial flag misses an English key, when a translation interpolates a value English does not pass, or when languages.ts and the locale files disagree. Plural suffixes are compared per language. CI already runs tests/*.test.mjs; pnpm test:i18n runs the check alone. Co-Authored-By: Claude Opus 5.5 --- web/package.json | 1 + web/src/i18n/README.md | 3 ++ web/tests/i18n-keys.test.mjs | 78 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 web/tests/i18n-keys.test.mjs diff --git a/web/package.json b/web/package.json index 7fc79b67..1833e775 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "build": "tsc && vite build", "test:usb": "node --experimental-strip-types --test tests/usb-composition.test.mjs", "test:direct": "node --import tsx --test tests/direct-playout.test.mjs tests/direct-worker.test.mjs", + "test:i18n": "node --import tsx --test tests/i18n-keys.test.mjs", "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "preview": "vite preview" diff --git a/web/src/i18n/README.md b/web/src/i18n/README.md index 765b7896..7748e9a9 100644 --- a/web/src/i18n/README.md +++ b/web/src/i18n/README.md @@ -2,3 +2,6 @@ 1. Add a language file in i18n/locales folder (for example: en.ts). 2. Add language key and name in i18n/languages.ts (for example: { key: 'en', name: 'English' }). +3. Add `partial: true` while the file covers less than about 90% of the keys in `en.ts`. + `tests/i18n-keys.test.mjs` fails if a locale has keys that are not in `en.ts`, or if a + locale without the flag misses any key. diff --git a/web/tests/i18n-keys.test.mjs b/web/tests/i18n-keys.test.mjs new file mode 100644 index 00000000..2dcfe091 --- /dev/null +++ b/web/tests/i18n-keys.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { readdirSync } from 'node:fs'; +import test from 'node:test'; + +import languages from '../src/i18n/languages.ts'; + +const localesDir = new URL('../src/i18n/locales/', import.meta.url); +const pluralSuffix = /_(zero|one|two|few|many|other)$/; + +function flatten(object, prefix = '', out = new Map()) { + for (const [key, value] of Object.entries(object)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object') flatten(value, path, out); + else out.set(path, value); + } + return out; +} + +const names = readdirSync(localesDir) + .filter((file) => file.endsWith('.ts')) + .map((file) => file.slice(0, -3)) + .sort(); +const locales = new Map(); +for (const name of names) { + const module = await import(new URL(`${name}.ts`, localesDir)); + locales.set(name, flatten(module.default.translation)); +} +const en = locales.get('en'); +// Plural forms differ between languages: a locale may use any CLDR suffix +// for a key that English pluralizes, and any suffix satisfies it. +const base = (key) => key.replace(pluralSuffix, ''); +const englishBases = new Set([...en.keys()].map(base)); +const has = (keys, key) => + keys.has(key) || (pluralSuffix.test(key) && [...keys.keys()].some((k) => base(k) === base(key))); +const placeholders = (value) => + new Set([...String(value).matchAll(/\{\{\s*([\w.]+)[^}]*\}\}/g)].map((match) => match[1])); + +test('the language list and locale files match', () => { + assert.deepEqual(languages.map(({ key }) => key).sort(), names); +}); + +test('locales contain only keys that exist in en.ts', () => { + const orphans = []; + for (const [name, keys] of locales) { + for (const key of keys.keys()) { + if (!en.has(key) && !(pluralSuffix.test(key) && englishBases.has(base(key)))) { + orphans.push(`${name}: ${key}`); + } + } + } + assert.deepEqual(orphans, [], 'remove these keys or add them to en.ts'); +}); + +test('complete languages translate every key in en.ts', () => { + const missing = []; + for (const { key: name, partial } of languages) { + if (partial) continue; + const keys = locales.get(name); + for (const key of en.keys()) if (!has(keys, key)) missing.push(`${name}: ${key}`); + } + assert.deepEqual(missing, [], 'translate these keys or mark the language as partial'); +}); + +test('translations use only interpolation values that English provides', () => { + const unknown = []; + for (const [name, keys] of locales) { + for (const [key, value] of keys) { + const english = en.get(key) ?? en.get(`${base(key)}_other`) ?? en.get(base(key)); + if (english === undefined) continue; + const allowed = placeholders(english); + if (pluralSuffix.test(key) || en.has(`${base(key)}_other`)) allowed.add('count'); + for (const variable of placeholders(value)) { + if (!allowed.has(variable)) unknown.push(`${name}: ${key} uses {{${variable}}}`); + } + } + } + assert.deepEqual(unknown, []); +}); From 03f10bc5761d5a8f5a477aecd974cdcb5b9b8d2d Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:32:36 +0300 Subject: [PATCH 18/98] web: explain and complete the forced password change flow The password page gave no reason when it was forced on a factory password, returned to the login page without saying the change worked, and replaced the server's validation message (code -5, e.g. a password that is too weak) with "Unexpected error". Cancel silently logged out on a forced change, and otherwise reloaded the whole application. The HTTP client handled the server's "password change required" answer by setting the URL hash directly. The route guard's account still said no change was required, so the page opened in its non-forced form and Cancel led straight back to a page whose requests bounced to it again. - Show auth.changePasswordDesc when the change is forced, and a success message after the change before returning to the login page. - Show the server's message for code -5. - The client now raises a password-change-required event; the route guard marks the account and redirects, so the page knows it is forced. - A forced Cancel is labelled "Cancel and log out"; otherwise Cancel goes back in the router history (or to the desktop) without a reload. - The tips card said the change also resets the root/SSH password for every administrator. The server does that only for the built-in (system) admin account (service/auth/password.go), and GET /api/auth/account does not say which account that is, so reword the tip to name the built-in admin account (en, ru). - Drop initialValues={{ remember: true }} from the login and password forms, which have no such field. Co-Authored-By: Claude Opus 5.5 --- web/src/components/auth.tsx | 7 +++- web/src/i18n/locales/en.ts | 4 ++- web/src/i18n/locales/ru.ts | 5 ++- web/src/lib/auth-events.ts | 7 ++++ web/src/lib/http.ts | 6 ++-- web/src/pages/auth/login/index.tsx | 6 +--- web/src/pages/auth/password/index.tsx | 46 ++++++++++++++++++--------- 7 files changed, 56 insertions(+), 25 deletions(-) diff --git a/web/src/components/auth.tsx b/web/src/components/auth.tsx index f2f438d8..b01b3a4d 100644 --- a/web/src/components/auth.tsx +++ b/web/src/components/auth.tsx @@ -4,7 +4,7 @@ import { Spin } from 'antd'; import { Navigate, useLocation } from 'react-router-dom'; import { Account, getAccount } from '@/api/auth.ts'; -import { AUTH_EXPIRED_EVENT } from '@/lib/auth-events.ts'; +import { AUTH_EXPIRED_EVENT, PASSWORD_CHANGE_REQUIRED_EVENT } from '@/lib/auth-events.ts'; export const ProtectedRoute = ({ children }: { children: ReactNode }) => { const location = useLocation(); @@ -41,11 +41,16 @@ export const ProtectedRoute = ({ children }: { children: ReactNode }) => { setIsAuthenticated(false); setIsLoading(false); }; + const handlePasswordChangeRequired = () => { + setAccount((current) => current && { ...current, mustChangePassword: true }); + }; window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired); + window.addEventListener(PASSWORD_CHANGE_REQUIRED_EVENT, handlePasswordChangeRequired); return () => { active = false; window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired); + window.removeEventListener(PASSWORD_CHANGE_REQUIRED_EVENT, handlePasswordChangeRequired); }; }, []); diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 245f7893..a36c486f 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -356,6 +356,8 @@ const en = { invalidCurrentPassword: 'Current password is incorrect', changePassword: 'Change Password', changePasswordDesc: 'For the security of your device, please change the password!', + passwordChanged: 'Password changed. Sign in with the new password.', + cancelAndLogout: 'Cancel and log out', differentPassword: 'Passwords do not match', illegalUsername: 'Username contains illegal characters', illegalPassword: 'Password contains illegal characters', @@ -369,7 +371,7 @@ const en = { reset2: 'For detailed steps, please consult this document:', reset3: 'Web default account:', reset4: 'SSH default account:', - change1: 'Please note that this action will change the following passwords:', + change1: 'For the built-in admin account, this action changes the following passwords:', change2: 'Web login password', change3: 'System root password (SSH login password)', change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.' diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 24a86cc7..275fe48f 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -361,6 +361,8 @@ const ru = { error: 'Непредвиденная ошибка', changePassword: 'Изменить пароль', changePasswordDesc: 'Для безопасности вашего устройства, пожалуйста поменяйте пароль!', + passwordChanged: 'Пароль изменён. Войдите с новым паролем.', + cancelAndLogout: 'Отменить и выйти', differentPassword: 'Пароли не совпадают', illegalUsername: 'Имя пользователя содержит недопустимые символы', illegalPassword: 'Пароль содержит недопустимые символы', @@ -374,7 +376,8 @@ const ru = { reset2: 'Подробные шаги описаны в этом документе:', reset3: 'Аккаунт веб-интерфейса по умолчанию:', reset4: 'Аккаунт SSH по умолчанию:', - change1: 'Пожалуйста учтите, что это действие изменит следующие пароли:', + change1: + 'Для встроенной учётной записи администратора это действие изменит следующие пароли:', change2: 'Пароль для входа в веб-интерфейс', change3: 'Пароль системного пользователя "root" (пароль для входа по SSH)', change4: 'Чтобы сбросить пароли, нажмите и удерживайте кнопку BOOT на NanoKVM.' diff --git a/web/src/lib/auth-events.ts b/web/src/lib/auth-events.ts index d64c576f..69582187 100644 --- a/web/src/lib/auth-events.ts +++ b/web/src/lib/auth-events.ts @@ -1,5 +1,12 @@ export const AUTH_EXPIRED_EVENT = 'nano-kvm:auth-expired'; +export const PASSWORD_CHANGE_REQUIRED_EVENT = 'nano-kvm:password-change-required'; export function notifyAuthExpired() { window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)); } + +// The server refuses everything but the password change until the factory +// password is replaced; the route guard then opens the password page. +export function notifyPasswordChangeRequired() { + window.dispatchEvent(new Event(PASSWORD_CHANGE_REQUIRED_EVENT)); +} diff --git a/web/src/lib/http.ts b/web/src/lib/http.ts index 67f7faab..8485b5a6 100644 --- a/web/src/lib/http.ts +++ b/web/src/lib/http.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import { notifyAuthExpired } from '@/lib/auth-events.ts'; +import { notifyAuthExpired, notifyPasswordChangeRequired } from '@/lib/auth-events.ts'; import { isHandledRequestError, passwordChangeRequiredCode } from '@/lib/request-error.ts'; import { getBaseUrl } from '@/lib/service.ts'; @@ -46,7 +46,9 @@ class Http { if (code === 401) { notifyAuthExpired(); } else if (code === 403 && error.response?.data?.code === passwordChangeRequiredCode) { - window.location.hash = '#/auth/password'; + // Let the route guard show the forced change, so the page knows + // why it opened and Cancel cannot lead back here in a loop. + notifyPasswordChangeRequired(); } return Promise.reject(error); } diff --git a/web/src/pages/auth/login/index.tsx b/web/src/pages/auth/login/index.tsx index ce26022c..da4f54a0 100644 --- a/web/src/pages/auth/login/index.tsx +++ b/web/src/pages/auth/login/index.tsx @@ -73,11 +73,7 @@ export const Login = (): ReactElement => {
-
+
{ const { t } = useTranslation(); const [msg, setMsg] = useState(''); const navigate = useNavigate(); const { account } = useAuth(); + const forced = account.mustChangePassword === true; useEffect(() => { if (msg) { @@ -41,11 +44,15 @@ export const Password = () => { setMsg( rsp.code === invalidCurrentPasswordCode ? t('auth.invalidCurrentPassword') - : t('auth.error') + : rsp.code === invalidNewPasswordCode && rsp.msg + ? rsp.msg + : t('auth.error') ); return; } + // The server ended every session of this user; sign in again. + void message.success(t('auth.passwordChanged')); notifyAuthExpired(); navigate('/auth/login', { replace: true }); }) @@ -55,15 +62,23 @@ export const Password = () => { } function cancel() { - // The device stays locked to this page until the factory password changes. - if (account.mustChangePassword) { - void api.logout().finally(() => { - notifyAuthExpired(); - navigate('/auth/login', { replace: true }); - }); + // The device stays locked to this page until the factory password changes, + // so cancelling a forced change logs out (the button says so). + if (forced) { + void api + .logout() + .catch(() => { + /* The session is dropped locally either way. */ + }) + .finally(() => { + notifyAuthExpired(); + navigate('/auth/login', { replace: true }); + }); return; } - window.location.replace('/'); + // Return to the previous page without reloading the application. + if ((window.history.state?.idx ?? 0) > 0) navigate(-1); + else navigate('/', { replace: true }); } return ( @@ -72,12 +87,13 @@ export const Password = () => {

{t('auth.changePassword')}

+ {forced && ( +

+ {t('auth.changePasswordDesc')} +

+ )} - + { {t('auth.ok')}
From cf60cdd0b34e57f11a9ed7ce24adb3b31f8f8e8b Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:33:13 +0300 Subject: [PATCH 19/98] web: stop reporting Wi-Fi setup failures as success The hotspot setup page showed the success text after any exception and after unknown response codes. Only code 0 is a success now; an answer from the device with another code is a failure. A request that fails without any response is expected while the device leaves the hotspot to join the network, so it shows a neutral "connecting, the hotspot may disconnect" text (en, ru) instead of claiming success or failure. The "Finished" button that replaced the submit button after success had no action; remove it and keep the submit button, so a wrong network can be corrected. The password placeholder was a hard-coded English string and now uses auth.placeholderPassword. Drop the stray initialValues={{ remember: true }} from both forms. Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/en.ts | 2 ++ web/src/i18n/locales/ru.ts | 2 ++ web/src/pages/wifi/index.tsx | 55 ++++++++++++++---------------------- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index a36c486f..6f55e296 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -382,6 +382,8 @@ const en = { description: 'Configure Wi-Fi for NanoKVM', success: 'Please go to the device to check the network status of NanoKVM.', failed: 'Operation failed, please try again.', + pending: + 'Connecting to the network. The NanoKVM hotspot may disconnect; check the network status on the device.', invalidMode: 'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.', confirmBtn: 'Ok', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 275fe48f..f7b13b6b 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -388,6 +388,8 @@ const ru = { description: 'Настройка Wi-Fi для NanoKVM', success: 'Пожалуйста, проверьте сетевой статус NanoKVM и откройте в браузере новый IP адрес.', failed: 'Операция не удалась, пожалуйста попробуйте снова.', + pending: + 'Подключение к сети. Точка доступа NanoKVM может отключиться; проверьте состояние сети на устройстве.', invalidMode: 'Текущий режим не поддерживает настройку сети. Пожалуйста, перейдите на свое устройство и включите режим конфигурации Wi-Fi.', confirmBtn: 'OK', diff --git a/web/src/pages/wifi/index.tsx b/web/src/pages/wifi/index.tsx index fc04d8ed..c354015c 100644 --- a/web/src/pages/wifi/index.tsx +++ b/web/src/pages/wifi/index.tsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { CheckOutlined, KeyOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons'; +import { KeyOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons'; import { Button, Form, Input } from 'antd'; +import { isAxiosError } from 'axios'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import * as api from '@/api/network.ts'; import { Head } from '@/components/head.tsx'; -type State = '' | 'loading' | 'success' | 'failed' | 'denied'; +type State = '' | 'loading' | 'success' | 'pending' | 'failed' | 'denied'; type VerifyState = '' | 'failed' | 'denied'; export const Wifi = () => { @@ -70,16 +71,13 @@ export const Wifi = () => { case -4: setState('denied'); return; - case -2: - case -3: - setState('failed'); - return; } + setState('failed'); } catch (err) { - console.log(err); + // Joining the network can take the hotspot (and this response) down; + // that is expected, so only an answer from the device means failure. + setState(isAxiosError(err) && !err.response ? 'pending' : 'failed'); } - - setState('success'); } if (!isAuthenticated) { @@ -88,11 +86,7 @@ export const Wifi = () => {
- +
{t('wifi.ap.authTitle')} @@ -129,11 +123,7 @@ export const Wifi = () => {
- +
{t('wifi.title')} @@ -146,24 +136,18 @@ export const Wifi = () => { - } placeholder="Password" /> + } placeholder={t('auth.placeholderPassword')} /> - {state === 'success' ? ( - - ) : ( - - )} + @@ -171,6 +155,9 @@ export const Wifi = () => { {state === 'success' && ( {t('wifi.success')} )} + {state === 'pending' && ( + {t('wifi.pending')} + )} {state === 'failed' && {t('wifi.failed')} } {state === 'denied' && ( From 06fd16b24452871febf1aa0cb7f9e93a820be7f4 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:33:40 +0300 Subject: [PATCH 20/98] web: show terminal disconnects and offer a reconnect The terminal page reacted only to close code 4401 (session expired). Any other close (the shell exited, the server restarted, the network dropped, the WebSocket never connected) left a silent, dead terminal. Write a visible "Connection closed. Press Enter to reconnect." line (en, ru) into the terminal on any other close that the page did not initiate itself; Enter reloads the page, which reconnects with the same serial parameters because they are kept in sessionStorage. Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/en.ts | 1 + web/src/i18n/locales/ru.ts | 1 + web/src/pages/terminal/index.tsx | 9 +++++++++ 3 files changed, 11 insertions(+) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 6f55e296..01d37f4b 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -651,6 +651,7 @@ const en = { usbSerial: 'USB serial console', usbSpeed: 'Virtual USB serial — baud rate does not limit transfer speed.', invalidParameters: 'Invalid serial parameters.', + disconnected: 'Connection closed. Press Enter to reconnect.', serial: 'Serial Port Terminal', serialPort: 'Serial Port', serialPortPlaceholder: 'Please enter the serial port', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index f7b13b6b..476b0f33 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -664,6 +664,7 @@ const ru = { usbSerial: 'USB serial-консоль', usbSpeed: 'Виртуальный USB serial — baud rate не ограничивает скорость передачи.', invalidParameters: 'Некорректные параметры serial-порта.', + disconnected: 'Соединение закрыто. Нажмите Enter, чтобы переподключиться.', serial: 'Терминал COM-порта', serialPort: 'COM-порт', serialPortPlaceholder: 'Введите COM-порт', diff --git a/web/src/pages/terminal/index.tsx b/web/src/pages/terminal/index.tsx index 47da5a3a..313a4157 100644 --- a/web/src/pages/terminal/index.tsx +++ b/web/src/pages/terminal/index.tsx @@ -18,6 +18,7 @@ export const Terminal = () => { const invalidParameters = useEffectEvent(() => t('terminal.invalidParameters', { defaultValue: 'Invalid serial parameters.' }) ); + const disconnected = useEffectEvent(() => t('terminal.disconnected')); useEffect(() => { const terminalEle = document.getElementById('terminal'); @@ -64,7 +65,15 @@ export const Terminal = () => { ws.addEventListener('close', (event) => { if (event.code === 4401) { notifyAuthExpired(); + return; } + if (disposed) return; + // Any other close ends the session: say so and offer a reconnect. A + // reload keeps the serial parameters, which live in sessionStorage. + terminal.writeln(`\r\n\x1b[31m${disconnected()}\x1b[0m`); + terminal.onKey(({ domEvent }) => { + if (domEvent.key === 'Enter') window.location.reload(); + }); }); ws.onopen = () => { From c8d7eecd04b6af93d401d1b5570d95a2a851db7a Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:34:08 +0300 Subject: [PATCH 21/98] i18n: make English interface text consistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use sentence case for labels, buttons and titles, "OK" instead of "Ok", the single ellipsis character, "Sign in"/"Sign out" for the account actions (key names unchanged), lowercase "fps" in prose and labels and "HID-only" and "leader key" as common nouns. Remove the unused screen.videoDirectTips string, which pointed to the wrong settings page, from all locales. In Russian, use the ellipsis character and Cyrillic "ОК" throughout, describe frame rate in words and fix the leader-key recorder prompt that called a shortcut a "ярлык". Co-Authored-By: Claude Opus 5.5 --- web/src/i18n/locales/ca.ts | 1 - web/src/i18n/locales/cz.ts | 1 - web/src/i18n/locales/da.ts | 1 - web/src/i18n/locales/de.ts | 2 - web/src/i18n/locales/en.ts | 275 +++++++++++++++++----------------- web/src/i18n/locales/es.ts | 1 - web/src/i18n/locales/fr.ts | 1 - web/src/i18n/locales/hu.ts | 2 - web/src/i18n/locales/id.ts | 1 - web/src/i18n/locales/it.ts | 2 - web/src/i18n/locales/ja.ts | 1 - web/src/i18n/locales/ko.ts | 1 - web/src/i18n/locales/nb.ts | 1 - web/src/i18n/locales/nl.ts | 1 - web/src/i18n/locales/pl.ts | 1 - web/src/i18n/locales/pt_br.ts | 1 - web/src/i18n/locales/ru.ts | 35 +++-- web/src/i18n/locales/se.ts | 1 - web/src/i18n/locales/th.ts | 1 - web/src/i18n/locales/tr.ts | 1 - web/src/i18n/locales/uk.ts | 1 - web/src/i18n/locales/vi.ts | 1 - web/src/i18n/locales/zh.ts | 1 - web/src/i18n/locales/zh_tw.ts | 1 - 24 files changed, 154 insertions(+), 181 deletions(-) diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index 7cf4be7f..f50c1539 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -62,7 +62,6 @@ const ca = { scale: 'Escala', title: 'Pantalla', video: 'Mode de vídeo', - videoDirectTips: "Activa HTTPS a 'Configuració > Dispositiu' per utilitzar aquest mode", resolution: 'Resolució', controlRegion: { title: 'Calibratge del ratolí', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 286b85b6..0c6b04fa 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -63,7 +63,6 @@ const cz = { scale: 'Měřítko', title: 'Obrazovka', video: 'Režim videa', - videoDirectTips: 'Chcete-li používat tento režim, povolte HTTPS v "Nastavení > Zařízení"', resolution: 'Rozlišení', controlRegion: { title: 'Kalibrace myši', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 7ac0b7f6..65b363e7 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -62,7 +62,6 @@ const da = { scale: 'Skala', title: 'Skærm', video: 'Videotilstand', - videoDirectTips: 'Aktiver HTTPS i "Indstillinger > Enhed" for at bruge denne tilstand', resolution: 'Opløsning', controlRegion: { title: 'Musekalibrering', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 717f8978..d8547ee5 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -64,8 +64,6 @@ const de = { scale: 'Skala', title: 'Bildschirm', video: 'Video Modus', - videoDirectTips: - 'Aktivieren Sie HTTPS unter „Einstellungen > Gerät“, um diesen Modus zu verwenden', resolution: 'Auflösung', controlRegion: { title: 'Mauskalibrierung', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index bbf8cae8..73c30cee 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -36,7 +36,7 @@ const en = { }, dateTime: { - title: 'Date & Time', + title: 'Date & time', deviceTime: 'Device time', timezone: 'Time zone', format: 'Time format', @@ -118,8 +118,8 @@ const en = { systemRequired: 'WireGuard system tools are missing. Install a system image with WireGuard support.', routingNote: - 'Only the interface subnet is routed by default. Enable Route Allowed IPs to add the peer routes.', - routeAllowedIPs: 'Route Allowed IPs', + 'Only the interface subnet is routed by default. Enable Route allowed IPs to add the peer routes.', + routeAllowedIPs: 'Route allowed IPs', routingHelp: 'Add routes from AllowedIPs, including a default route for 0.0.0.0/0 or ::/0.', routingDisableFirst: 'Disable this profile before changing routing.', note: 'One WireGuard profile can be enabled at a time. Import does not connect automatically. DNS accepts IP addresses; executable hooks and SaveConfig are not supported. Idle means the tunnel has no recent handshake, not necessarily a connection failure.', @@ -254,7 +254,7 @@ const en = { 'MJPEG is limited to 1920 pixels in width to preserve 4:2:2 while H.264/H.265 uses a wider output. The selected resolution resumes when that video stream ends.', mjpegChromaFallback_video: 'Only one capture output supports widths above 1920 pixels. MJPEG uses 4:2:0 while both streams are wider; 4:2:2 resumes when the video stream ends or either stream is reduced.', - mjpegChromaFallback_frameDetection: 'Turn off Frame Detect to use 4:2:2.', + mjpegChromaFallback_frameDetection: 'Turn off frame detection to use 4:2:2.', mjpegChromaFallback_diagnostic: '4:2:2 is unavailable in the current capture mode.', mjpegChromaFallback_hardware: 'Capture fell back to 4:2:0 after an error. Retry 4:2:2 when the signal is stable.', @@ -311,7 +311,7 @@ const en = { 'Changing the virtual monitor profile is unavailable on this device. Input detection remains automatic.', stream: 'Video stream', streamHint: - 'Resolution, FPS and bitrate affect all viewers. Changing stream resolution does not change the computer’s desktop. New viewers automatically use the active encoder codec; transport and display scale remain individual.', + 'Resolution, frame rate and bitrate affect all viewers. Changing stream resolution does not change the computer’s desktop. New viewers automatically use the active encoder codec; transport and display scale remain individual.', advanced: 'Advanced and recovery', gopMode: 'H.265 GOP mode', gopModeHint: @@ -322,7 +322,7 @@ const en = { gopHint: 'GOP is the interval between keyframes. HDMI recovery restarts capture if the source stops responding.', fpsLimited: - 'The current input limits capture to {{fps}} FPS. The saved request is kept for the next source mode.', + 'The current input limits capture to {{fps}} fps. The saved request is kept for the next source mode.', statusFailed: 'Could not refresh video status.', retry: 'Retry' }, @@ -337,14 +337,14 @@ const en = { storageUnavailable: 'Image storage is not writable. Check free space and storage state.' }, head: { - desktop: 'Remote Desktop', - login: 'Login', - changePassword: 'Change Password', + desktop: 'Remote desktop', + login: 'Sign in', + changePassword: 'Change password', terminal: 'Terminal', wifi: 'Wi-Fi' }, auth: { - login: 'Login', + login: 'Sign in', placeholderUsername: 'Username', placeholderPassword: 'Password', placeholderCurrentPassword: 'Current password', @@ -354,19 +354,19 @@ const en = { passwordLength: 'Password must be between 8 and 72 characters', noAccount: 'Failed to get user information, please refresh web page or reset password', invalidUser: 'Invalid username or password', - locked: 'Too many logins, please try again later', + locked: 'Too many sign-in attempts, please try again later', globalLocked: 'System under protection, please try again later', error: 'Unexpected error', invalidCurrentPassword: 'Current password is incorrect', - changePassword: 'Change Password', + changePassword: 'Change password', changePasswordDesc: 'For the security of your device, please change the password!', differentPassword: 'Passwords do not match', illegalUsername: 'Username contains illegal characters', illegalPassword: 'Password contains illegal characters', - forgetPassword: 'Forgot Password', - ok: 'Ok', + forgetPassword: 'Forgot password', + ok: 'OK', cancel: 'Cancel', - loginButtonText: 'Login', + loginButtonText: 'Sign in', tips: { reset1: 'To reset the passwords, press and hold the BOOT button on the NanoKVM for 10 seconds.', @@ -386,10 +386,10 @@ const en = { failed: 'Operation failed, please try again.', invalidMode: 'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.', - confirmBtn: 'Ok', + confirmBtn: 'OK', finishBtn: 'Finished', ap: { - authTitle: 'Authentication Required', + authTitle: 'Authentication required', authDescription: 'Please enter the AP password to continue', authFailed: 'Invalid AP password', passPlaceholder: 'AP password', @@ -402,32 +402,31 @@ const en = { monitorFailed: 'Could not change the HDMI monitor resolution', scale: 'Scale', title: 'Screen', - video: 'Video Mode', + video: 'Video mode', codec: 'Codec', unsupported: 'unsupported', - videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode', resolution: 'Resolution', controlRegion: { - title: 'Mouse Calibration', + title: 'Mouse calibration', description: 'Use this setting when the controlled device uses a non-16:9 resolution and the cursor is misaligned horizontally or vertically.', off: 'Off', auto: 'Auto', autoWarning: 'Calibration may fail when the user application has a pure black background.', manual: 'Manual', - selectedResolution: 'Selected Area Resolution', + selectedResolution: 'Selected area resolution', unused: 'Not used', - originalResolution: 'Original Resolution', + originalResolution: 'Original resolution', selectResolution: 'Select original resolution', addResolution: 'Add custom resolution', add: 'Add', duplicateResolution: 'This resolution already exists.', width: 'Width', height: 'Height', - apply: 'Calculate and Apply', + apply: 'Calculate and apply', invalidResolution: 'Enter a valid original resolution after the video is ready.', - select: 'Select Area', - clear: 'Restore Automatic', + select: 'Select area', + clear: 'Restore automatic', saveFailed: 'Failed to save the input area.', tooSmall: 'The selected area is too small.', previewUnavailable: 'Preview unavailable', @@ -440,14 +439,14 @@ const en = { auto: 'Automatic', autoTips: 'Restores the default monitor profile and lets the connected computer choose its resolution.', - fps: 'FPS', + fps: 'Frame rate', customizeFps: 'Customize', quality: 'Quality', qualityLossless: 'Lossless', qualityHigh: 'High', qualityMedium: 'Medium', qualityLow: 'Low', - frameDetect: 'Frame Detect', + frameDetect: 'Frame detection', frameDetectTip: "Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.", resetHdmi: 'Recover HDMI', @@ -472,8 +471,8 @@ const en = { captureStatus: { hdmiError: 'HDMI screen error', unsupportedResolution: 'Current resolution is not supported', - retrieving: 'Getting screen...', - changingResolution: 'Switching resolution...', + retrieving: 'Getting screen…', + changingResolution: 'Switching resolution…', updateFailed: 'Screen cannot update right now', videoError: 'Video display error', noHdmi: 'No HDMI signal detected', @@ -487,7 +486,7 @@ const en = { placeholder: 'Please input', submit: 'Submit', virtual: 'Keyboard', - readClipboard: 'Read from Clipboard', + readClipboard: 'Read from clipboard', clipboardPermissionDenied: 'Clipboard permission denied. Please allow clipboard access in your browser.', clipboardReadError: 'Failed to read clipboard', @@ -507,23 +506,23 @@ const en = { enterFullScreen: 'Toggle full-screen mode.' }, leaderKey: { - title: 'Leader Key', + title: 'Leader key', desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.', - howToUse: 'How to Use', + howToUse: 'How to use', simultaneous: { - title: 'Simultaneous Mode', - desc1: 'Press and hold the Leader Key, then press the shortcut.', + title: 'Simultaneous mode', + desc1: 'Press and hold the leader key, then press the shortcut.', desc2: 'Intuitive, but may conflict with system shortcuts.' }, sequential: { - title: 'Sequential Mode', + title: 'Sequential mode', desc1: - 'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.', + 'Press the leader key → press the shortcut in sequence → press the leader key again.', desc2: 'Requires more steps, but completely avoids system conflicts.' }, - enable: 'Enable Leader Key', - tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.', - placeholder: 'Please press the Leader Key', + enable: 'Enable leader key', + tip: 'When assigned as a leader key, this key functions exclusively as a shortcut trigger and loses its default behavior.', + placeholder: 'Please press the leader key', shiftRight: 'Right Shift', ctrlRight: 'Right Ctrl', metaRight: 'Right Win', @@ -531,7 +530,7 @@ const en = { recorder: { rec: 'REC', activate: 'Activate keys', - input: 'Please press the shortcut...' + input: 'Please press the shortcut…' } } }, @@ -555,14 +554,14 @@ const en = { slow: 'Slow', requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.', inputAdapter: { - title: 'Input Adapter', + title: 'Input adapter', auto: 'Auto', 'pointer-lock': 'Pointer Lock', touchpad: 'Touchpad' }, touchpadGuide: { title: 'Touchpad guide', - scope: 'Applies when Input Adapter is Touchpad and Mouse Mode is Relative.', + scope: 'Applies when Input adapter is Touchpad and Mouse mode is Relative.', screen: 'Screen', swipeTitle: 'Swipe to move', swipeDesc: 'Swipe inside the active screen area to move the remote pointer.', @@ -575,13 +574,13 @@ const en = { }, resetHid: 'Reset HID', hidOnly: { - title: 'HID-Only mode', - desc: "If your mouse and keyboard stop responding and resetting HID doesn't help, it could be a compatibility issue between the NanoKVM and the device. Try to enable HID-Only mode for better compatibility.", - tip1: 'Enabling HID-Only mode will unmount the virtual U-disk and virtual network', - tip2: 'In HID-Only mode, image mounting is disabled', + title: 'HID-only mode', + desc: "If your mouse and keyboard stop responding and resetting HID doesn't help, it could be a compatibility issue between the NanoKVM and the device. Try to enable HID-only mode for better compatibility.", + tip1: 'Enabling HID-only mode will unmount the virtual U-disk and virtual network', + tip2: 'In HID-only mode, image mounting is disabled', tip3: 'NanoKVM will automatically reboot after switching modes', - enable: 'Enable HID-Only mode', - disable: 'Disable HID-Only mode' + enable: 'Enable HID-only mode', + disable: 'Disable HID-only mode' } }, image: { @@ -600,8 +599,8 @@ const en = { disconnectFailed: 'Could not disconnect the ISO. Try again.' }, title: 'Images', - loading: 'Loading...', - empty: 'Nothing Found', + loading: 'Loading…', + empty: 'Nothing found', mountMode: 'Mount mode', massStorage: 'Mass storage', cdrom: 'CD/DVD', @@ -622,12 +621,12 @@ const en = { tips: { title: 'How to upload', usb1: 'Connect the NanoKVM to your computer via USB.', - usb2: 'Ensure that the virtual disk is mounted (Settings - Virtual Disk).', + usb2: 'Ensure that the virtual disk is mounted (Settings - Virtual disk).', usb3: 'Open the virtual disk on your computer and copy the image file to the root directory of the virtual disk.', scp1: 'Make sure the NanoKVM and your computer are on the same local network.', scp2: 'Open a terminal on your computer and use the SCP command to upload the image file to the /data directory on the NanoKVM.', scp3: 'Example: scp your-image-path root@your-nanokvm-ip:/data', - tfCard: 'TF Card', + tfCard: 'TF card', tf1: 'This method is supported on Linux system', tf2: 'Get TF card from the NanoKVM (for the FULL version, disassemble the case first).', tf3: 'Insert the TF card into a card reader and connect it to your computer.', @@ -639,7 +638,7 @@ const en = { title: 'Scripts', upload: 'Upload', run: 'Run', - runBackground: 'Run Background', + runBackground: 'Run in background', runFailed: 'Run failed', attention: 'Attention', delDesc: 'Are you sure you want to delete this file?', @@ -650,12 +649,12 @@ const en = { }, terminal: { title: 'Terminal', - nanokvm: 'NanoKVM Terminal', + nanokvm: 'NanoKVM terminal', usbSerial: 'USB serial console', usbSpeed: 'Virtual USB serial — baud rate does not limit transfer speed.', invalidParameters: 'Invalid serial parameters.', - serial: 'Serial Port Terminal', - serialPort: 'Serial Port', + serial: 'Serial port terminal', + serialPort: 'Serial port', serialPortPlaceholder: 'Please enter the serial port', baudrate: 'Baud rate', parity: 'Parity', @@ -668,15 +667,15 @@ const en = { flowControlHard: 'Hard', dataBits: 'Data bits', stopBits: 'Stop bits', - confirm: 'Ok' + confirm: 'OK' }, wol: { title: 'Wake-on-LAN', - sending: 'Sending command...', + sending: 'Sending command…', sent: 'Magic packet sent. The target may still be off or unavailable.', input: 'Please enter the MAC', interface: 'Interface', - ok: 'Ok', + ok: 'OK', name: 'Device name', save: 'Save name', cancel: 'Cancel', @@ -688,12 +687,12 @@ const en = { downloading: 'Downloading', uploading: 'Uploading', finishing: 'Finishing…', - title: 'Image Downloader', + title: 'Image downloader', input: 'Please enter a remote image URL', - ok: 'Ok', + ok: 'OK', disabled: '/data partition is RO, so we cannot download the image', uploadbox: 'Drop file here or click to select', - inputfile: 'Please enter the image File', + inputfile: 'Please enter the image file', NoISO: 'No ISO', sha256: 'SHA-256 (optional)', sha256Placeholder: 'Enter a 64-character SHA-256 checksum', @@ -905,7 +904,7 @@ const en = { profile: 'EDID profile', input: 'Input', output: 'Output', - fps: 'Measured FPS', + fps: 'Measured fps', usb: 'USB gadget', binding: 'Controller binding', selected: 'Selected functions', @@ -952,14 +951,14 @@ const en = { back: 'Back', close: 'Close', mcp: { - title: 'MCP Service', + title: 'MCP service', service: 'Remote control MCP', serviceDesc: 'Allow trusted MCP clients to control the keyboard and mouse and capture screenshots', securityWarning: 'Anyone with this API key can control the remote host and view its screen. Use HTTPS and enable it only on trusted networks.', endpoint: 'Endpoint', - apiKey: 'API Key', + apiKey: 'API key', regenerateConfirmTitle: 'Regenerate MCP API key?', regenerateConfirmDesc: 'The current key will stop working immediately.', enableConfirmTitle: 'Enable external MCP control?', @@ -1032,11 +1031,11 @@ const en = { information: 'Information', ip: 'IP', mdns: 'mDNS', - application: 'Application Version', + application: 'Application version', applicationTip: 'NanoKVM web application version', - image: 'Image Version', + image: 'Image version', imageTip: 'NanoKVM system image version', - deviceKey: 'Device Key', + deviceKey: 'Device key', community: 'Community', hostname: 'Hostname', hostnameUpdated: 'Hostname updated. Reboot to apply.', @@ -1093,18 +1092,18 @@ const en = { languageDesc: 'Select the language for the interface', languagePartial: 'partial', languagePartialHint: 'Partially translated. Missing texts are shown in English.', - webTitle: 'Web Title', + webTitle: 'Web title', webTitleDesc: 'Customize the web page title', menuBar: { - title: 'Menu Bar', - mode: 'Display Mode', + title: 'Menu bar', + mode: 'Display mode', modeDesc: 'Display menu bar on the screen', modeOff: 'Off', modeAuto: 'Auto hide', modeAlways: 'Always visible', keyboardLedStatus: 'Keyboard lock indicators', keyboardLedStatusDesc: 'Display remote Num Lock, Caps Lock, and Scroll Lock status', - icons: 'Submenu Icons', + icons: 'Submenu icons', iconsDesc: 'Display submenu icons in the menu bar' } }, @@ -1120,7 +1119,7 @@ const en = { on: 'On', off: 'Off', unknown: 'Unknown', - keyboardDisabled: 'USB keyboard is disabled in USB Composition' + keyboardDisabled: 'USB keyboard is disabled in USB composition' }, device: { title: 'Device', @@ -1158,9 +1157,9 @@ const en = { }, ssh: { description: 'Enable SSH remote access', - tip: 'Set a strong password before enabling (Account - Change Password)' + tip: 'Set a strong password before enabling (Account - Change password)' }, - advanced: 'Advanced Settings', + advanced: 'Advanced settings', swap: { title: 'Swap', disable: 'Disable', @@ -1168,11 +1167,11 @@ const en = { tip: "Enabling this feature could shorten your SD card's usable life!" }, mouseJiggler: { - title: 'Mouse Jiggler', + title: 'Mouse jiggler', description: 'Prevent the remote host from sleeping', disable: 'Disable', - absolute: 'Absolute Mode', - relative: 'Relative Mode' + absolute: 'Absolute mode', + relative: 'Relative mode' }, mdns: { description: 'Enable mDNS discovery service', @@ -1185,24 +1184,24 @@ const en = { minutes: 'min' }, autostart: { - title: 'Autostart Scripts Settings', + title: 'Autostart scripts', description: 'Manage scripts that run automatically on system startup', new: 'New', deleteConfirm: 'Are you sure you want to delete this file?', yes: 'Yes', no: 'No', - scriptName: 'Autostart Script Name', - scriptContent: 'Autostart Script Content', + scriptName: 'Autostart script name', + scriptContent: 'Autostart script content', name: 'Name', actions: 'Actions', settings: 'Settings', nameRequired: 'Enter a script name' }, - hidOnly: 'HID-Only Mode', + hidOnly: 'HID-only mode', hidOnlyDesc: 'Stop emulating virtual devices, retaining only basic HID control', - disk: 'Virtual Disk', + disk: 'Virtual disk', diskDesc: 'Mount SD card on the remote host', - network: 'Virtual Network', + network: 'Virtual network', networkDesc: 'Mount virtual network card on the remote host', reboot: 'Reboot', rebootDesc: 'Are you sure you want to reboot NanoKVM?', @@ -1215,7 +1214,7 @@ const en = { 'Windows binds the pointer to the captured monitor (Windows 10 1903 or newer). Applying reconnects USB and HDMI.', pointerProfileDefault: 'Default', off: 'Off', - title: 'USB Composition', + title: 'USB composition', enabled: 'USB devices', budgetTitle: 'Endpoint budget', budgetDescription: @@ -1263,27 +1262,27 @@ const en = { description: 'Boot-compatible keyboard and LED reports' }, relative: { - title: 'Relative Mouse', + title: 'Relative mouse', description: 'Pointer-lock mouse for firmware, installers and games' }, absolute: { - title: 'Absolute Pointer', + title: 'Absolute pointer', description: 'Direct screen-coordinate mapping without pointer drift' }, network: { - title: 'Virtual Network', + title: 'Virtual network', description: 'NCM network adapter on the remote host' }, disk: { - title: 'Virtual Disk', + title: 'Virtual disk', description: 'Present a mounted image as a USB drive' }, audio: { - title: 'USB Audio', + title: 'USB audio', description: 'Stereo audio output for the connected computer (48 kHz, 16-bit)' }, serial: { - title: 'USB Serial Console', + title: 'USB serial console', description: 'CDC ACM serial port for the connected computer' } } @@ -1341,7 +1340,7 @@ const en = { ssid: 'Name', password: 'Password', joinBtn: 'Join', - confirmBtn: 'Ok', + confirmBtn: 'OK', cancelBtn: 'Cancel' }, tls: { @@ -1375,12 +1374,12 @@ const en = { 'Saving will immediately end connections using Ethernet. Make sure another management connection is available.', dhcp: 'DHCP', static: 'Static', - ipv4: 'IPv4 Configuration', + ipv4: 'IPv4 configuration', dhcpDescription: 'IP address and gateway are obtained automatically from DHCP', staticDescription: 'Settings are applied immediately and retained after a reboot', - ipAddress: 'IP Address', + ipAddress: 'IP address', addressPlaceholder: '192.168.10.32', - subnetMask: 'Subnet Mask', + subnetMask: 'Subnet mask', subnetMaskPlaceholder: '255.255.255.0', gateway: 'Gateway', gatewayPlaceholder: '192.168.10.1', @@ -1415,13 +1414,13 @@ const en = { saveFailed: 'Failed to save DNS settings', unsaved: 'Unsaved changes', maxServers: 'Maximum {{count}} DNS servers allowed', - dnsServers: 'DNS Servers', + dnsServers: 'DNS servers', dhcpServersDescription: 'DNS servers are automatically obtained from DHCP', manualServersDescription: 'DNS servers can be edited manually', - networkDetails: 'Network Details', + networkDetails: 'Network details', interface: 'Interface', - ipAddress: 'IP Address', - subnetMask: 'Subnet Mask', + ipAddress: 'IP address', + subnetMask: 'Subnet mask', router: 'Router', none: 'None' } @@ -1438,8 +1437,8 @@ const en = { }, restart: 'Restart Tailscale?', stop: 'Stop Tailscale?', - stopDesc: 'Log out Tailscale and disable automatic startup on boot.', - loading: 'Loading...', + stopDesc: 'Sign out of Tailscale and disable automatic startup on boot.', + loading: 'Loading…', statusFailed: 'Could not get Tailscale status', notInstall: 'Tailscale is not installed.', installDescription: 'Install Tailscale on demand. Existing settings are preserved.', @@ -1450,16 +1449,16 @@ const en = { notRunning: 'Tailscale is not running. Please start it to continue.', run: 'Start', notLogin: - 'The device has not been bound yet. Please login and bind this device to your account.', + 'The device has not been bound yet. Please sign in and bind this device to your account.', urlPeriod: 'This url is valid for 10 minutes', - login: 'Login', - loginSuccess: 'Login Success', + login: 'Sign in', + loginSuccess: 'I have signed in', enable: 'Enable Tailscale', - deviceName: 'Device Name', + deviceName: 'Device name', deviceIP: 'Device IP', account: 'Account', - logout: 'Logout', - logoutDesc: 'Are you sure you want to logout?', + logout: 'Sign out', + logoutDesc: 'Are you sure you want to sign out?', uninstall: 'Uninstall Tailscale', uninstallDesc: 'Are you sure you want to uninstall Tailscale?', okBtn: 'Yes', @@ -1473,7 +1472,7 @@ const en = { restart: 'Restart NetBird?', stop: 'Stop NetBird?', stopDesc: 'Stop the NetBird service.', - loading: 'Loading...', + loading: 'Loading…', statusUnknown: 'NetBird status is unknown', statusStale: 'Showing the last confirmed NetBird status', notInstall: 'NetBird is not installed.', @@ -1483,12 +1482,12 @@ const en = { notRunning: 'NetBird service is not running.', run: 'Start', notLogin: - 'The device has not been bound yet. Please login and bind this device to your account.', + 'The device has not been bound yet. Please sign in and bind this device to your account.', urlPeriod: 'This url is valid for 10 minutes', - login: 'Login', - loginSuccess: 'I have logged in', + login: 'Sign in', + loginSuccess: 'I have signed in', enable: 'Enable NetBird', - deviceName: 'Device Name', + deviceName: 'Device name', deviceIP: 'Device IP', uninstall: 'Uninstall NetBird', uninstallDesc: 'Are you sure you want to uninstall NetBird?', @@ -1503,11 +1502,11 @@ const en = { title: 'NetBird operation failed', intro: 'Error details:', stepWait: '1. Wait 10-15 seconds and retry the action.', - stepRestartUI: '2. Click "Restart Service" below.', + stepRestartUI: '2. Click "Restart service" below.', stepRestartSSH: '3. If needed, run: /etc/init.d/S99netbird restart', stepReboot: '4. Reboot NanoKVM only if the steps above do not help.', - restartButton: 'Restart Service', - refreshButton: 'Refresh Status', + restartButton: 'Restart service', + refreshButton: 'Refresh status', restartFailed: 'Restart failed', stopFailed: 'Stop failed', statusFailed: 'Could not get NetBird status', @@ -1589,7 +1588,7 @@ const en = { }, account: { title: 'Users', - webAccount: 'Web Account Name', + webAccount: 'Web account name', role: 'Role', roles: { admin: 'Administrator', @@ -1597,19 +1596,19 @@ const en = { }, password: 'Password', updateBtn: 'Change', - logoutBtn: 'Logout', - logoutDesc: 'Are you sure you want to logout?', - logoutFailed: 'Logout failed; this session is still signed in.', + logoutBtn: 'Sign out', + logoutDesc: 'Are you sure you want to sign out?', + logoutFailed: 'Sign-out failed; this session is still signed in.', okBtn: 'Yes', cancelBtn: 'No', users: { title: 'Users', - create: 'Create User', + create: 'Create user', enabled: 'Enabled', disabled: 'Disabled', deviceOwner: 'Device owner', rename: 'Rename', - resetPassword: 'Reset Password', + resetPassword: 'Reset password', delete: 'Delete', deleteConfirm: 'Delete this user and revoke all of their sessions?', created: 'User created', @@ -1629,15 +1628,15 @@ const en = { }, picoclaw: { moreActions: 'More actions', - title: 'PicoClaw Assistant', + title: 'PicoClaw assistant', empty: 'Open the panel and start a task to begin.', inputPlaceholder: 'Describe what you want the PicoClaw to do', newConversation: 'New conversation', - processing: 'Processing...', + processing: 'Processing…', agent: { - defaultTitle: 'General Assistant', + defaultTitle: 'General assistant', defaultDescription: 'General chat, search, and workspace help.', - kvmTitle: 'Remote Control', + kvmTitle: 'Remote control', kvmDescription: 'Operate the remote host through NanoKVM.', switched: 'Agent role switched', switchFailed: 'Failed to switch agent role' @@ -1645,7 +1644,7 @@ const en = { send: 'Send', cancel: 'Cancel', status: { - connecting: 'Connecting to gateway...', + connecting: 'Connecting to gateway…', connected: 'PicoClaw session connected', disconnected: 'PicoClaw session closed', stopped: 'Stop request sent', @@ -1702,8 +1701,8 @@ const en = { transitioningDescription: 'Device control is syncing. Please wait.', grant: 'Take over', release: 'Return control', - releasing: 'Releasing...', - switching: 'Switching...', + releasing: 'Releasing…', + switching: 'Switching…', releasingLabel: 'Device control: releasing', releasingDescription: 'Device control is being returned. PicoClaw has stopped current writes.', @@ -1719,7 +1718,7 @@ const en = { installing: 'Installing PicoClaw', success: 'PicoClaw installed successfully', failed: 'Failed to install PicoClaw', - uninstalling: 'Uninstalling runtime...', + uninstalling: 'Uninstalling runtime…', uninstalled: 'Runtime uninstalled successfully.', uninstallFailed: 'Uninstall failed.', requiredTitle: 'PicoClaw is not installed', @@ -1732,21 +1731,21 @@ const en = { verifying: 'Verifying', installing: 'Installing', installed: 'Installed', - install_timeout: 'Timed Out', + install_timeout: 'Timed out', install_failed: 'Failed' } }, model: { requiredTitle: 'Model configuration is required', requiredDescription: 'Configure the PicoClaw model before using PicoClaw chat.', - docsTitle: 'Configuration Guide', + docsTitle: 'Configuration guide', docsDesc: 'Supported models and protocols', menuLabel: 'Configure model', - modelIdentifier: 'Model Identifier', + modelIdentifier: 'Model identifier', modelIdentifierPlaceholder: 'openai/gpt-5.4', - apiBase: 'API Base URL', + apiBase: 'API base URL', apiBasePlaceholder: 'https://api.example.com/v1', - apiKey: 'API Key', + apiKey: 'API key', apiKeyPlaceholder: 'Enter the model API key', apiKeyOptionalPlaceholder: 'Optional for this local provider', save: 'Save', @@ -1766,7 +1765,7 @@ const en = { }, history: { title: 'History', - loading: 'Loading sessions...', + loading: 'Loading sessions…', emptyTitle: 'No history yet', emptyDescription: 'Previous PicoClaw sessions will appear here.', loadFailed: 'Failed to load session history', @@ -1799,11 +1798,11 @@ const en = { refresh: 'Refresh' }, fullscreen: { - toggle: 'Toggle Fullscreen' + toggle: 'Toggle fullscreen' }, menu: { - collapse: 'Collapse Menu', - expand: 'Expand Menu' + collapse: 'Collapse menu', + expand: 'Expand menu' } } }; diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index c48f89cb..9f3f2330 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -63,7 +63,6 @@ const es = { scale: 'Escala', title: 'Pantalla', video: 'Modo de vídeo', - videoDirectTips: 'Habilita HTTPS en "Ajustes > Dispositivo" para usar este modo', resolution: 'Resolución', controlRegion: { title: 'Calibración del ratón', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 5581c8e8..fd40442c 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -65,7 +65,6 @@ const fr = { scale: 'Échelle', title: 'Écran', video: 'Mode vidéo', - videoDirectTips: 'Activez HTTPS dans "Paramètres > Appareil" pour utiliser ce mode', resolution: 'Résolution', controlRegion: { title: 'Étalonnage de la souris', diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 3b9995ef..d35433b6 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -63,8 +63,6 @@ const hu = { scale: 'Skála', title: 'Képernyő', video: 'Videó mód', - videoDirectTips: - 'Engedélyezze az HTTPS elemet a "Beállítások > Eszköz" menüpontban ennek a módnak a használatához', resolution: 'Felbontás', controlRegion: { title: 'Egérkalibrálás', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index eef785fe..f9f4b309 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -62,7 +62,6 @@ const id = { scale: 'Skala', title: 'Layar', video: 'Mode Video', - videoDirectTips: 'Aktifkan HTTPS di "Pengaturan > Perangkat" untuk menggunakan mode ini', resolution: 'Resolusi', controlRegion: { title: 'Kalibrasi Tetikus', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 7983a1c0..a777063d 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -63,8 +63,6 @@ const it = { scale: 'Scala', title: 'Schermo', video: 'Modalità video', - videoDirectTips: - 'Abilita HTTPS in "Impostazioni > Dispositivo" per utilizzare questa modalità', resolution: 'Risoluzione', controlRegion: { title: 'Calibrazione del mouse', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index fbc5ee1f..f4a55b29 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -62,7 +62,6 @@ const ja = { scale: '倍率', title: '画面', video: 'ビデオモード', - videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください', resolution: '解像度', controlRegion: { title: 'マウス位置補正', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 2c9b84b1..8641a02d 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -61,7 +61,6 @@ const ko = { scale: '규모', title: '화면', video: '비디오 모드', - videoDirectTips: '이 모드를 사용하려면 "설정 > 장치"에서 HTTPS를 활성화하세요', resolution: '해상도', controlRegion: { title: '마우스 보정', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 7c8fa2b3..de9df2a3 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -63,7 +63,6 @@ const nb = { scale: 'Skala', title: 'Skjerm', video: 'Video-kodek', - videoDirectTips: 'Aktiver HTTPS i "Innstillinger > Enhet" for å bruke denne modusen', resolution: 'Oppløsning', controlRegion: { title: 'Musekalibrering', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index d0820371..8e7aa057 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -64,7 +64,6 @@ const nl = { scale: 'Schaal', title: 'Scherm', video: 'Videomodus', - videoDirectTips: 'Schakel HTTPS in "Instellingen > Apparaat" in om deze modus te gebruiken', resolution: 'Resolutie', controlRegion: { title: 'Muiskalibratie', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index b8a09dbd..ea5e8974 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -63,7 +63,6 @@ const pl = { scale: 'Skala', title: 'Ekran', video: 'Tryb wideo', - videoDirectTips: 'Włącz HTTPS w „Ustawienia > Urządzenie”, aby korzystać z tego trybu', resolution: 'Rozdzielczość', controlRegion: { title: 'Kalibracja myszy', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 9c07fde3..62fb52cf 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -62,7 +62,6 @@ const pt_br = { scale: 'Escala', title: 'Tela', video: 'Modo de Vídeo', - videoDirectTips: 'Ative HTTPS em "Configurações > Dispositivo" para usar este modo', resolution: 'Resolução', controlRegion: { title: 'Calibração do mouse', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 028055b8..9d5eba92 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -316,7 +316,7 @@ const ru = { 'Смена профиля виртуального монитора недоступна на этом устройстве. Входной сигнал определяется автоматически.', stream: 'Трансляция', streamHint: - 'Разрешение, FPS и битрейт влияют на всех зрителей. Разрешение трансляции не меняет рабочий стол компьютера. Новые клиенты автоматически используют кодек активного потока; способ передачи и масштаб остаются индивидуальными.', + 'Разрешение, частота кадров и битрейт влияют на всех зрителей. Разрешение трансляции не меняет рабочий стол компьютера. Новые клиенты автоматически используют кодек активного потока; способ передачи и масштаб остаются индивидуальными.', advanced: 'Дополнительно и восстановление', gopMode: 'Режим GOP H.265', gopModeHint: @@ -394,7 +394,7 @@ const ru = { failed: 'Операция не удалась, пожалуйста попробуйте снова.', invalidMode: 'Текущий режим не поддерживает настройку сети. Пожалуйста, перейдите на свое устройство и включите режим конфигурации Wi-Fi.', - confirmBtn: 'OK', + confirmBtn: 'ОК', finishBtn: 'Готово', ap: { authTitle: 'Требуется аутентификация', @@ -414,7 +414,6 @@ const ru = { video: 'Видеорежим', codec: 'Кодек', unsupported: 'не поддерживается', - videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим', resolution: 'Разрешение', controlRegion: { title: 'Калибровка мыши', @@ -482,8 +481,8 @@ const ru = { captureStatus: { hdmiError: 'Ошибка изображения HDMI', unsupportedResolution: 'Текущее разрешение не поддерживается', - retrieving: 'Получение изображения...', - changingResolution: 'Переключение разрешения...', + retrieving: 'Получение изображения…', + changingResolution: 'Переключение разрешения…', updateFailed: 'Сейчас невозможно обновить изображение', videoError: 'Ошибка отображения видео', noHdmi: 'Сигнал HDMI не обнаружен', @@ -542,7 +541,7 @@ const ru = { recorder: { rec: 'ЗАП.', activate: 'Активировать клавиши', - input: 'Пожалуйста, нажмите ярлык...' + input: 'Нажмите сочетание клавиш…' } } }, @@ -615,7 +614,7 @@ const ru = { disconnectFailed: 'Не удалось отключить ISO. Повторите попытку.' }, title: 'Образы', - loading: 'Загрузка...', + loading: 'Загрузка…', empty: 'Пусто', mountMode: 'Режим монтирования', massStorage: 'USB-накопитель', @@ -687,7 +686,7 @@ const ru = { }, wol: { title: 'Wake-on-LAN', - sending: 'Отправка команды...', + sending: 'Отправка команды…', sent: 'Magic packet отправлен. Целевой компьютер может оставаться выключенным или недоступным.', input: 'Введите MAC-адрес', interface: 'Интерфейс', @@ -705,7 +704,7 @@ const ru = { finishing: 'Завершение…', title: 'Скачать образ', input: 'Введите URL удаленного образа', - ok: 'OK', + ok: 'ОК', disabled: 'Невозможно скачать образ, раздел /data находится в режиме только для чтения', uploadbox: 'Перетащите сюда файл или нажмите, чтобы выбрать', inputfile: 'Пожалуйста, введите файл изображения', @@ -922,7 +921,7 @@ const ru = { profile: 'Профиль EDID', input: 'Вход', output: 'Выход', - fps: 'Измеренный FPS', + fps: 'Измеренная частота кадров', usb: 'USB-гаджет', binding: 'Привязка контроллера', selected: 'Выбранные функции', @@ -1369,7 +1368,7 @@ const ru = { ssid: 'Имя', password: 'Пароль', joinBtn: 'Подключить', - confirmBtn: 'OK', + confirmBtn: 'ОК', cancelBtn: 'Отмена' }, tls: { @@ -1469,7 +1468,7 @@ const ru = { restart: 'Вы уверены что хотите перезагрузить Tailscale?', stop: 'Вы уверены что хотите остановить Tailscale?', stopDesc: 'Выйти из Tailscale и отключить его автоматический запуск при включении.', - loading: 'Загрузка...', + loading: 'Загрузка…', statusFailed: 'Не удалось получить статус Tailscale', notInstall: 'Tailscale не установлен.', installDescription: 'Установите Tailscale по запросу. Существующие настройки сохранятся.', @@ -1482,7 +1481,7 @@ const ru = { notLogin: 'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.', urlPeriod: 'Этот адрес действителен в течение 10 минут', login: 'Войти', - loginSuccess: 'Вход выполнен', + loginSuccess: 'Я вошёл в аккаунт', enable: 'Включить Tailscale', deviceName: 'Имя устройства', deviceIP: 'IP адрес устройства', @@ -1502,7 +1501,7 @@ const ru = { restart: 'Перезапустить NetBird?', stop: 'Остановить NetBird?', stopDesc: 'Остановить службу NetBird.', - loading: 'Загрузка...', + loading: 'Загрузка…', statusUnknown: 'Статус NetBird неизвестен', statusStale: 'Показан последний подтверждённый статус NetBird', notInstall: 'NetBird не установлен.', @@ -1661,7 +1660,7 @@ const ru = { empty: 'Откройте панель и запустите задачу для начала.', inputPlaceholder: 'Опишите, что вы хотите от PicoClaw.', newConversation: 'Новый разговор', - processing: 'Обработка...', + processing: 'Обработка…', agent: { defaultTitle: 'Универсальный помощник', defaultDescription: 'Общая помощь в чате, поиске и рабочей области.', @@ -1673,7 +1672,7 @@ const ru = { send: 'Отправить', cancel: 'Отмена', status: { - connecting: 'Подключение к шлюзу...', + connecting: 'Подключение к шлюзу…', connected: 'Сеанс PicoClaw подключен', disconnected: 'Сеанс PicoClaw отключен', stopped: 'Запрос на остановку отправлен', @@ -1748,7 +1747,7 @@ const ru = { installing: 'Установка PicoClaw', success: 'PicoClaw успешно установлен', failed: 'Не удалось установить PicoClaw', - uninstalling: 'Удаление runtime...', + uninstalling: 'Удаление runtime…', uninstalled: 'Runtime успешно удален.', uninstallFailed: 'Не удалось удалить.', requiredTitle: 'PicoClaw не установлен', @@ -1795,7 +1794,7 @@ const ru = { }, history: { title: 'История', - loading: 'Загрузка сеансов...', + loading: 'Загрузка сеансов…', emptyTitle: 'Пока нет истории', emptyDescription: 'Здесь появятся предыдущие сеансы PicoClaw.', loadFailed: 'Не удалось загрузить историю сеансов.', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 31a9f34e..396937af 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -60,7 +60,6 @@ const se = { scale: 'Skala', title: 'Skärm', video: 'Videoläge', - videoDirectTips: 'Aktivera HTTPS i "Inställningar > Enhet" för att använda detta läge', resolution: 'Upplösning', controlRegion: { title: 'Muskalibrering', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index 77857023..8af6c721 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -60,7 +60,6 @@ const th = { scale: 'สเกล', title: 'หน้าจอ', video: 'โหมดวีดีโอ', - videoDirectTips: 'เปิดใช้งาน HTTPS ใน "การตั้งค่า > อุปกรณ์" เพื่อใช้โหมดนี้', resolution: 'ความคมชัด', controlRegion: { title: 'ปรับเทียบเมาส์', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 635f75e6..04867b85 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -63,7 +63,6 @@ const tr = { scale: 'Ölçek', title: 'Ekran', video: 'Görüntü modu', - videoDirectTips: 'kullanmak için "Ayarlar > Cihaz" HTTPS aktif edin', resolution: 'Çözünürlük', controlRegion: { title: 'Fare Kalibrasyonu', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index c8b43db7..c1ff1e36 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -63,7 +63,6 @@ const uk = { scale: 'Масштаб', title: 'Екран', video: 'Відеорежим', - videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим', resolution: 'Роздільна здатність', controlRegion: { title: 'Калібрування миші', diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index 28145bae..1e06d57f 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -62,7 +62,6 @@ const vi = { scale: 'Quy mô', title: 'Màn hình', video: 'Chế độ video', - videoDirectTips: 'Bật HTTPS trong "Cài đặt > Thiết bị" để sử dụng chế độ này', resolution: 'Độ phân giải', controlRegion: { title: 'Hiệu chỉnh chuột', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index dc81abfa..946db8df 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -73,7 +73,6 @@ const zh = { scale: '缩放', title: '屏幕', video: '视频模式', - videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启', resolution: '分辨率', controlRegion: { title: '鼠标校准', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index 7f4f2146..e2f097d5 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -59,7 +59,6 @@ const zh_tw = { scale: '缩放', title: '螢幕', video: '編碼格式', - videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟', resolution: '解析度', controlRegion: { title: '滑鼠校正', From da97c4c0d222c47803affc718e9e8fdfcade6aba Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:34:15 +0300 Subject: [PATCH 22/98] web: remove the unreachable change-password notification The desktop mounted a Notification that asked GET /api/auth/password whether the password was still the factory one and then offered to change it (dismissable for three days via the nano-kvm-skip-modify-password key). It can never appear: that state is the account's mustChangePassword flag, and ProtectedRoute redirects such an account to /auth/password before the desktop mounts; while the flag is set the server answers every other request with the "password change required" code, which now also sends the route guard to the password page. Remove the component, its mount, the web isPasswordUpdated() helper, the skip key's getter/setter and the expiry helpers only it used. The server endpoint stays for API compatibility. Co-Authored-By: Claude Opus 5.5 --- web/src/api/auth.ts | 4 --- web/src/lib/localstorage.ts | 43 -------------------------- web/src/pages/desktop/index.tsx | 3 +- web/src/pages/desktop/notification.tsx | 42 +------------------------ 4 files changed, 2 insertions(+), 90 deletions(-) diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index d32290c7..c1010d51 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -34,10 +34,6 @@ export function changePassword(currentPassword: string, password: string) { return http.post('/api/auth/password', { currentPassword, password }); } -export function isPasswordUpdated() { - return http.get('/api/auth/password'); -} - export function getUsers() { return http.get('/api/auth/users'); } diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index 90afa1ce..9de39c8d 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -14,45 +14,12 @@ const MOUSE_SCROLL_DIRECTION_KEY = 'nano-kvm-mouse-scroll-direction'; const MOUSE_SCROLL_INTERVAL_KEY = 'nano-kvm-mouse-scroll-interval'; const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system'; const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language'; -const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password'; const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items'; const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide'; const MOBILE_MENU_PLACEMENT_KEY = 'nano-kvm-mobile-menu-placement'; const KEYBOARD_LED_STATUS_VISIBLE_KEY = 'nano-kvm-keyboard-led-status-visible'; const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm'; -type ItemWithExpiry = { - value: string; - expiry: number; -}; - -// set the value with expiration time (unit: milliseconds) -function setWithExpiry(key: string, value: string, ttl: number) { - const now = new Date(); - - const item: ItemWithExpiry = { - value: value, - expiry: now.getTime() + ttl - }; - - localStorage.setItem(key, JSON.stringify(item)); -} - -// get the value with expiration time -function getWithExpiry(key: string) { - const itemStr = localStorage.getItem(key); - if (!itemStr) return null; - - const item: ItemWithExpiry = JSON.parse(itemStr); - const now = new Date(); - if (now.getTime() > item.expiry) { - localStorage.removeItem(key); - return null; - } - - return item.value; -} - export function getLanguage() { return localStorage.getItem(LANGUAGE_KEY); } @@ -183,16 +150,6 @@ export function getKeyboardLanguage() { return localStorage.getItem(KEYBOARD_LANGUAGE_KEY); } -export function setSkipModifyPassword(skip: boolean) { - const expiry = 3 * 24 * 60 * 60 * 1000; // 3 days - setWithExpiry(SKIP_MODIFY_PASSWORD_KEY, String(skip), expiry); -} - -export function getSkipModifyPassword() { - const skip = getWithExpiry(SKIP_MODIFY_PASSWORD_KEY); - return skip === 'true'; -} - export function setMenuDisabledItems(items: string[]) { const value = JSON.stringify(items); localStorage.setItem(MENU_DISABLED_ITEMS_KEY, value); diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index 68f7cb34..b4ee9337 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -35,7 +35,7 @@ import { ControlNotice } from './control.tsx'; import { Keyboard } from './keyboard'; import { Menu } from './menu'; import { Mouse } from './mouse'; -import { H264ModeNotification, Notification } from './notification.tsx'; +import { H264ModeNotification } from './notification.tsx'; import { ActionOverlay } from './picoclaw/action-overlay.tsx'; import { Screen } from './screen'; import { AutoRegion } from './screen/auto-region.tsx'; @@ -327,7 +327,6 @@ export const Desktop = () => {
- {isBigScreen && } diff --git a/web/src/pages/desktop/notification.tsx b/web/src/pages/desktop/notification.tsx index ebf671be..10b3189a 100644 --- a/web/src/pages/desktop/notification.tsx +++ b/web/src/pages/desktop/notification.tsx @@ -1,10 +1,7 @@ import { useEffect, useRef } from 'react'; -import { Button, notification } from 'antd'; +import { notification } from 'antd'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { isPasswordUpdated } from '@/api/auth.ts'; -import { getSkipModifyPassword, setSkipModifyPassword } from '@/lib/localstorage.ts'; import { client } from '@/lib/websocket.ts'; const H264_MODE_STATUS_EVENT = 'h264-mode-status'; @@ -16,43 +13,6 @@ type H264ModeStatus = { mixed: boolean; }; -export const Notification = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [api, contextHolder] = notification.useNotification(); - - useEffect(() => { - const skip = getSkipModifyPassword(); - if (skip) return; - - isPasswordUpdated().then((rsp) => { - if (rsp.code === 0 && !rsp.data.isUpdated) { - api.warning({ - key: 'no_change_password', - message: t('auth.changePassword'), - description: t('auth.changePasswordDesc'), - placement: 'topRight', - btn: ( - - ), - duration: 0, - onClose: () => setSkipModifyPassword(true) - }); - } - }); - }, [api, navigate, t]); - - return <>{contextHolder}; -}; - export const H264ModeNotification = () => { const { t } = useTranslation(); const [api, contextHolder] = notification.useNotification(); From 96495ddd281b813a8e1649c8fc0e9f738a9bc1c4 Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:34:31 +0300 Subject: [PATCH 23/98] web: load the settings modal body on first open The toolbar Settings button is shown to everyone, but the modal body (navigation, page loaders, VPN/extension icons, the Radix scroll area) was part of the desktop chunk. Move the modal JSX, tab list and titles unchanged into settings/dialog.tsx and load it lazily the first time the modal opens (by click or by a settings request); it stays mounted afterwards so the close animation is kept. All state, the addon inventory polling and the open/close/lock logic stay in the button component, and the chunk is prefetched when the button is hovered or focused. Desktop chunk: 284 kB -> 251 kB (dialog chunk: 23 kB). Co-Authored-By: Claude Opus 5.5 --- .../pages/desktop/menu/settings/dialog.tsx | 466 ++++++++++++++++++ web/src/pages/desktop/menu/settings/index.tsx | 456 ++--------------- 2 files changed, 506 insertions(+), 416 deletions(-) create mode 100644 web/src/pages/desktop/menu/settings/dialog.tsx diff --git a/web/src/pages/desktop/menu/settings/dialog.tsx b/web/src/pages/desktop/menu/settings/dialog.tsx new file mode 100644 index 00000000..8048eccc --- /dev/null +++ b/web/src/pages/desktop/menu/settings/dialog.tsx @@ -0,0 +1,466 @@ +import { Fragment, lazy, Suspense, useEffect, useRef } from 'react'; +import { Alert, Button, Modal, Spin } from 'antd'; +import clsx from 'clsx'; +import { + ArrowLeftIcon, + BotIcon, + ChevronRightIcon, + ClockIcon, + DownloadIcon, + EthernetPortIcon, + InfoIcon, + LayoutDashboardIcon, + MemoryStickIcon, + NetworkIcon, + PackageIcon, + PaletteIcon, + PuzzleIcon, + SettingsIcon, + ShieldIcon, + SmartphoneIcon, + StethoscopeIcon, + UsbIcon, + UserRoundIcon, + VideoIcon, + WifiIcon +} from 'lucide-react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; + +import type { AddonInventory } from '@/api/addons'; +import { Netbird as NetbirdIcon } from '@/components/icons/netbird'; +import { OpenVPNIcon } from '@/components/icons/openvpn'; +import { RustDeskIcon } from '@/components/icons/rustdesk'; +import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale'; +import { WireGuardIcon } from '@/components/icons/wireguard'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +import styles from './sidebar.module.css'; + +// Keep the navigation light; only the selected settings page is fetched. +const About = lazy(() => import('./about').then((module) => ({ default: module.About }))); +const Account = lazy(() => import('./account').then((module) => ({ default: module.Account }))); +const Appearance = lazy(() => + import('./appearance').then((module) => ({ default: module.Appearance })) +); +const Dashboard = lazy(() => + import('./dashboard').then((module) => ({ default: module.Dashboard })) +); +const DateTimeSettings = lazy(() => + import('./date-time').then((module) => ({ default: module.DateTimeSettings })) +); +const Device = lazy(() => import('./device').then((module) => ({ default: module.Device }))); +const MCP = lazy(() => import('./mcp').then((module) => ({ default: module.MCP }))); +const Memory = lazy(() => import('./memory').then((module) => ({ default: module.Memory }))); +const Netbird = lazy(() => import('./netbird').then((module) => ({ default: module.Netbird }))); +const Tailscale = lazy(() => + import('./tailscale').then((module) => ({ default: module.Tailscale })) +); +const Updates = lazy(() => import('./updates').then((module) => ({ default: module.Updates }))); +const Usb = lazy(() => import('./usb').then((module) => ({ default: module.Usb }))); +const VideoSettings = lazy(() => + import('./video').then((module) => ({ default: module.VideoSettings })) +); +const WireGuard = lazy(() => import('./vpn').then((module) => ({ default: module.WireGuard }))); +const OpenVPN = lazy(() => import('./vpn/openvpn').then((module) => ({ default: module.OpenVPN }))); +const System = lazy(() => import('./system').then((module) => ({ default: module.System }))); +const Diagnostics = lazy(() => + import('./system/diagnostics').then((module) => ({ default: module.Diagnostics })) +); +const Addons = lazy(() => + import('./software/addons').then((module) => ({ default: module.Addons })) +); +const RustDeskControls = lazy(() => + import('./extensions/rustdesk').then((module) => ({ default: module.RustDeskControls })) +); +const Software = lazy(() => import('./software').then((module) => ({ default: module.Software }))); +const Network = lazy(() => import('./network').then((module) => ({ default: module.Network }))); +const WifiSettings = lazy(() => + import('./network').then((module) => ({ default: module.WifiSettings })) +); +const EthernetSettings = lazy(() => + import('./network').then((module) => ({ default: module.EthernetSettings })) +); + +const PageLoading = () => ( +
+ +
+); + +export type SettingsDialogProps = { + isModalOpen: boolean; + mobile: boolean; + isAdmin: boolean; + isLocked: boolean; + setIsLocked: (locked: boolean) => void; + currentTab: string; + detailOpen: boolean; + setDetailOpen: (open: boolean) => void; + vpnExpanded: boolean; + networkExpanded: boolean; + systemExpanded: boolean; + softwareExpanded: boolean; + extensionsExpanded: boolean; + inventory: AddonInventory | null; + inventoryError: boolean; + changeTab: (tab: string) => void; + closeModal: () => void; + openPicoclaw: () => void; +}; + +// The settings modal body, loaded the first time the modal is opened. +export const SettingsDialog = ({ + isModalOpen, + mobile, + isAdmin, + isLocked, + setIsLocked, + currentTab, + detailOpen, + setDetailOpen, + vpnExpanded, + networkExpanded, + systemExpanded, + softwareExpanded, + extensionsExpanded, + inventory, + inventoryError, + changeTab, + closeModal, + openPicoclaw +}: SettingsDialogProps) => { + const { t } = useTranslation(); + const { i18n } = useTranslation(); + const extensionsTitle = (i18n.resolvedLanguage || i18n.language).startsWith('ru') + ? 'Расширения' + : 'Extensions'; + const scrollViewportRef = useRef(null); + + const tabs = [ + { + id: 'dashboard', + icon: , + component: + }, + { + id: 'video', + icon: , + component: + }, + ...(isAdmin + ? [ + { id: 'usb', icon: , component: }, + { id: 'device', icon: , component: }, + { id: 'network', icon: , component: null }, + { id: 'network-general', icon: , component: }, + { id: 'network-wifi', icon: , component: }, + { + id: 'network-ethernet', + icon: , + component: + }, + { id: 'system', icon: , component: null }, + { id: 'system-general', icon: , component: }, + { + id: 'system-diagnostics', + icon: , + component: + }, + { id: 'system-memory', icon: , component: }, + { + id: 'system-date-time', + icon: , + component: + }, + { id: 'system-users', icon: , component: }, + { id: 'system-mcp', icon: , component: }, + { id: 'system-updates', icon: , component: }, + { id: 'software', icon: , component: null }, + { + id: 'software-addons', + icon: , + component: ( + changeTab('extensions-rustdesk')} + onOpen={openPicoclaw} + /> + ) + }, + { id: 'software-packages', icon: , component: }, + { id: 'extensions', icon: , component: null }, + ...(inventory?.rustdesk.installed + ? [ + { + id: 'extensions-rustdesk', + icon: , + component: + } + ] + : []), + ...(inventory?.picoclaw.installed + ? [ + { + id: 'extensions-picoclaw', + icon: , + component: null + } + ] + : []), + { + id: 'vpn', + icon: , + component: null + }, + { + id: 'vpn-openvpn', + icon: , + component: + }, + { + id: 'vpn-tailscale', + icon: , + component: + }, + { + id: 'vpn-netbird', + icon: , + component: + }, + { + id: 'vpn-wireguard', + icon: , + component: + } + ] + : []), + { id: 'appearance', icon: , component: }, + ...(!isAdmin + ? [{ id: 'account', icon: , component: }] + : []), + { id: 'about', icon: , component: } + ]; + + useEffect(() => { + scrollViewportRef.current?.scrollTo({ top: 0, left: 0 }); + }, [currentTab]); + + function tabTitle(id: string) { + if (id === 'dashboard') return 'Dashboard'; + if (id === 'system-date-time') return t('dateTime.title'); + if (id === 'video') return t('videoSettings.title'); + if (id === 'network') return t('settings.network.title'); + if (id === 'network-wifi') return t('settings.network.wifi.title'); + if (id === 'network-ethernet') return 'Ethernet'; + if (id === 'network-general') return t('settings.network.general'); + if (id === 'system') return t('settings.system.title'); + if (id === 'system-general') return t('settings.system.general'); + if (id === 'system-diagnostics') return t('settings.system.diagnostics.title'); + if (id === 'system-memory') return t('settings.memory.title'); + if (id === 'system-users') return t('settings.account.title'); + if (id === 'system-mcp') return t('settings.mcp.title'); + if (id === 'system-updates') return t('settings.updates.title'); + if (id === 'extensions') return extensionsTitle; + if (id === 'extensions-rustdesk') return 'RustDesk'; + if (id === 'extensions-picoclaw') return 'PicoClaw'; + if (id === 'software') return t('settings.software.title'); + if (id === 'software-addons') return t('settings.software.addons.title'); + if (id === 'software-packages') return t('settings.software.addons.packages'); + if (id === 'account') return t('settings.account.title'); + if (id === 'vpn') return 'VPN'; + if (id === 'vpn-tailscale') return 'Tailscale'; + if (id === 'vpn-netbird') return 'NetBird'; + if (id === 'vpn-wireguard') return 'WireGuard'; + if (id === 'vpn-openvpn') return 'OpenVPN'; + return t(`settings.${id}.title`); + } + + return ( + +
+ {mobile && ( +
+ {detailOpen && ( +
+ )} +
+
+ {t('settings.title')} +
+
+ {tabs + .filter( + (tab) => + tab.id !== 'about' && + (!tab.id.startsWith('vpn-') || vpnExpanded) && + (!tab.id.startsWith('network-') || networkExpanded) && + (!tab.id.startsWith('system-') || systemExpanded) && + (!tab.id.startsWith('software-') || softwareExpanded) && + (!tab.id.startsWith('extensions-') || extensionsExpanded) + ) + .map((tab) => { + const child = + tab.id.startsWith('vpn-') || + tab.id.startsWith('network-') || + tab.id.startsWith('system-') || + tab.id.startsWith('software-') || + tab.id.startsWith('extensions-'); + const expanded = + tab.id === 'vpn' + ? vpnExpanded + : tab.id === 'network' + ? networkExpanded + : tab.id === 'system' + ? systemExpanded + : tab.id === 'software' + ? softwareExpanded + : tab.id === 'extensions' + ? extensionsExpanded + : undefined; + const label = tabTitle(tab.id); + return ( + + + {tab.id === 'extensions' && + extensionsExpanded && + !inventory?.rustdesk.installed && + !inventory?.picoclaw.installed && ( +
+ {!inventory && !inventoryError ? ( + + ) : inventoryError ? ( + (i18n.resolvedLanguage || i18n.language).startsWith('ru') ? ( + 'Не удалось загрузить расширения' + ) : ( + 'Could not load extensions' + ) + ) : (i18n.resolvedLanguage || i18n.language).startsWith('ru') ? ( + 'Нет установленных расширений' + ) : ( + 'No extensions installed' + )} +
+ )} +
+ ); + })} +
+ +
+
+ + {(!mobile || detailOpen) && ( + +
+
+ } + > + }> + {tabs.find((tab) => tab.id === currentTab)?.component} + + +
+
+
+ )} +
+ + ); +}; diff --git a/web/src/pages/desktop/menu/settings/index.tsx b/web/src/pages/desktop/menu/settings/index.tsx index 6e8452f9..cb6d0110 100644 --- a/web/src/pages/desktop/menu/settings/index.tsx +++ b/web/src/pages/desktop/menu/settings/index.tsx @@ -1,32 +1,8 @@ -import { Fragment, lazy, Suspense, useContext, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useContext, useEffect, useRef, useState } from 'react'; import { useAuth } from '@/contexts/auth.ts'; -import { Alert, Button, Modal, Spin, Tooltip, type TooltipProps } from 'antd'; -import clsx from 'clsx'; +import { Tooltip, type TooltipProps } from 'antd'; import { useAtom, useSetAtom } from 'jotai'; -import { - ArrowLeftIcon, - BotIcon, - ChevronRightIcon, - ClockIcon, - DownloadIcon, - EthernetPortIcon, - InfoIcon, - LayoutDashboardIcon, - MemoryStickIcon, - NetworkIcon, - PackageIcon, - PaletteIcon, - PuzzleIcon, - SettingsIcon, - ShieldIcon, - SmartphoneIcon, - StethoscopeIcon, - UsbIcon, - UserRoundIcon, - VideoIcon, - WifiIcon -} from 'lucide-react'; -import { ErrorBoundary } from 'react-error-boundary'; +import { SettingsIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getAddonInventory, type AddonInventory } from '@/api/addons'; @@ -37,66 +13,14 @@ import { picoclawChatOpenAtom, picoclawRuntimeStatusAtom } from '@/jotai/picocla import { rustDeskStatusAtom } from '@/jotai/rustdesk.ts'; import { settingsRequestAtom, submenuOpenCountAtom } from '@/jotai/settings.ts'; import { useResponsiveDevice } from '@/hooks/useResponsiveDevice.ts'; -import { Netbird as NetbirdIcon } from '@/components/icons/netbird'; -import { OpenVPNIcon } from '@/components/icons/openvpn'; -import { RustDeskIcon } from '@/components/icons/rustdesk'; -import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale'; -import { WireGuardIcon } from '@/components/icons/wireguard'; import { MobileMenuItemContext } from '@/components/mobile-menu-context.ts'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import styles from './sidebar.module.css'; - -// Keep the navigation light; only the selected settings page is fetched. -const About = lazy(() => import('./about').then((module) => ({ default: module.About }))); -const Account = lazy(() => import('./account').then((module) => ({ default: module.Account }))); -const Appearance = lazy(() => - import('./appearance').then((module) => ({ default: module.Appearance })) -); -const Dashboard = lazy(() => - import('./dashboard').then((module) => ({ default: module.Dashboard })) -); -const DateTimeSettings = lazy(() => - import('./date-time').then((module) => ({ default: module.DateTimeSettings })) -); -const Device = lazy(() => import('./device').then((module) => ({ default: module.Device }))); -const MCP = lazy(() => import('./mcp').then((module) => ({ default: module.MCP }))); -const Memory = lazy(() => import('./memory').then((module) => ({ default: module.Memory }))); -const Netbird = lazy(() => import('./netbird').then((module) => ({ default: module.Netbird }))); -const Tailscale = lazy(() => - import('./tailscale').then((module) => ({ default: module.Tailscale })) -); -const Updates = lazy(() => import('./updates').then((module) => ({ default: module.Updates }))); -const Usb = lazy(() => import('./usb').then((module) => ({ default: module.Usb }))); -const VideoSettings = lazy(() => - import('./video').then((module) => ({ default: module.VideoSettings })) -); -const WireGuard = lazy(() => import('./vpn').then((module) => ({ default: module.WireGuard }))); -const OpenVPN = lazy(() => import('./vpn/openvpn').then((module) => ({ default: module.OpenVPN }))); -const System = lazy(() => import('./system').then((module) => ({ default: module.System }))); -const Diagnostics = lazy(() => - import('./system/diagnostics').then((module) => ({ default: module.Diagnostics })) -); -const Addons = lazy(() => - import('./software/addons').then((module) => ({ default: module.Addons })) -); -const RustDeskControls = lazy(() => - import('./extensions/rustdesk').then((module) => ({ default: module.RustDeskControls })) -); -const Software = lazy(() => import('./software').then((module) => ({ default: module.Software }))); -const Network = lazy(() => import('./network').then((module) => ({ default: module.Network }))); -const WifiSettings = lazy(() => - import('./network').then((module) => ({ default: module.WifiSettings })) -); -const EthernetSettings = lazy(() => - import('./network').then((module) => ({ default: module.EthernetSettings })) -); - -const PageLoading = () => ( -
- -
+// The modal body (navigation and page loaders) is only fetched once needed. +const loadDialog = () => import('./dialog.tsx'); +const SettingsDialog = lazy(() => + loadDialog().then((module) => ({ default: module.SettingsDialog })) ); +const preloadDialog = () => void loadDialog(); export const Settings = ({ tooltipPlacement = 'bottom' @@ -124,123 +48,14 @@ export const Settings = ({ const [inventory, setInventory] = useAtom(addonInventoryAtom); const [inventoryError, setInventoryError] = useState(false); const [picoclawStatus] = useAtom(picoclawRuntimeStatusAtom); - const { i18n } = useTranslation(); - const extensionsTitle = (i18n.resolvedLanguage || i18n.language).startsWith('ru') - ? 'Расширения' - : 'Extensions'; const setPicoclawOpen = useSetAtom(picoclawChatOpenAtom); - const scrollViewportRef = useRef(null); + // Mount the dialog on first open and keep it, so closing still animates. + const [dialogWanted, setDialogWanted] = useState(false); + if (isModalOpen && !dialogWanted) setDialogWanted(true); const setKeyboardLock = useSetAtom(keyboardLockAtom); const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom); - const tabs = [ - { - id: 'dashboard', - icon: , - component: - }, - { - id: 'video', - icon: , - component: - }, - ...(isAdmin - ? [ - { id: 'usb', icon: , component: }, - { id: 'device', icon: , component: }, - { id: 'network', icon: , component: null }, - { id: 'network-general', icon: , component: }, - { id: 'network-wifi', icon: , component: }, - { - id: 'network-ethernet', - icon: , - component: - }, - { id: 'system', icon: , component: null }, - { id: 'system-general', icon: , component: }, - { - id: 'system-diagnostics', - icon: , - component: - }, - { id: 'system-memory', icon: , component: }, - { - id: 'system-date-time', - icon: , - component: - }, - { id: 'system-users', icon: , component: }, - { id: 'system-mcp', icon: , component: }, - { id: 'system-updates', icon: , component: }, - { id: 'software', icon: , component: null }, - { - id: 'software-addons', - icon: , - component: ( - changeTab('extensions-rustdesk')} - onOpen={() => { - closeModal(); - setPicoclawOpen(true); - }} - /> - ) - }, - { id: 'software-packages', icon: , component: }, - { id: 'extensions', icon: , component: null }, - ...(inventory?.rustdesk.installed - ? [ - { - id: 'extensions-rustdesk', - icon: , - component: - } - ] - : []), - ...(inventory?.picoclaw.installed - ? [ - { - id: 'extensions-picoclaw', - icon: , - component: null - } - ] - : []), - { - id: 'vpn', - icon: , - component: null - }, - { - id: 'vpn-openvpn', - icon: , - component: - }, - { - id: 'vpn-tailscale', - icon: , - component: - }, - { - id: 'vpn-netbird', - icon: , - component: - }, - { - id: 'vpn-wireguard', - icon: , - component: - } - ] - : []), - { id: 'appearance', icon: , component: }, - ...(!isAdmin - ? [{ id: 'account', icon: , component: }] - : []), - { id: 'about', icon: , component: } - ]; - useEffect(() => { if (!isAdmin) return; let active = true; @@ -301,10 +116,6 @@ export const Settings = ({ } }, [currentTab, inventory?.rustdesk.installed]); - useEffect(() => { - scrollViewportRef.current?.scrollTo({ top: 0, left: 0 }); - }, [currentTab]); - useEffect( () => () => { if (!modalOpenRef.current) return; @@ -412,36 +223,6 @@ export const Settings = ({ setSubmenuOpenCount((count) => Math.max(0, count - 1)); } - function tabTitle(id: string) { - if (id === 'dashboard') return 'Dashboard'; - if (id === 'system-date-time') return t('dateTime.title'); - if (id === 'video') return t('videoSettings.title'); - if (id === 'network') return t('settings.network.title'); - if (id === 'network-wifi') return t('settings.network.wifi.title'); - if (id === 'network-ethernet') return 'Ethernet'; - if (id === 'network-general') return t('settings.network.general'); - if (id === 'system') return t('settings.system.title'); - if (id === 'system-general') return t('settings.system.general'); - if (id === 'system-diagnostics') return t('settings.system.diagnostics.title'); - if (id === 'system-memory') return t('settings.memory.title'); - if (id === 'system-users') return t('settings.account.title'); - if (id === 'system-mcp') return t('settings.mcp.title'); - if (id === 'system-updates') return t('settings.updates.title'); - if (id === 'extensions') return extensionsTitle; - if (id === 'extensions-rustdesk') return 'RustDesk'; - if (id === 'extensions-picoclaw') return 'PicoClaw'; - if (id === 'software') return t('settings.software.title'); - if (id === 'software-addons') return t('settings.software.addons.title'); - if (id === 'software-packages') return t('settings.software.addons.packages'); - if (id === 'account') return t('settings.account.title'); - if (id === 'vpn') return 'VPN'; - if (id === 'vpn-tailscale') return 'Tailscale'; - if (id === 'vpn-netbird') return 'NetBird'; - if (id === 'vpn-wireguard') return 'WireGuard'; - if (id === 'vpn-openvpn') return 'OpenVPN'; - return t(`settings.${id}.title`); - } - return ( <>
@@ -469,192 +252,33 @@ export const Settings = ({
- -
- {mobile && ( -
- {detailOpen && ( -
- )} -
-
- {t('settings.title')} -
-
- {tabs - .filter( - (tab) => - tab.id !== 'about' && - (!tab.id.startsWith('vpn-') || vpnExpanded) && - (!tab.id.startsWith('network-') || networkExpanded) && - (!tab.id.startsWith('system-') || systemExpanded) && - (!tab.id.startsWith('software-') || softwareExpanded) && - (!tab.id.startsWith('extensions-') || extensionsExpanded) - ) - .map((tab) => { - const child = - tab.id.startsWith('vpn-') || - tab.id.startsWith('network-') || - tab.id.startsWith('system-') || - tab.id.startsWith('software-') || - tab.id.startsWith('extensions-'); - const expanded = - tab.id === 'vpn' - ? vpnExpanded - : tab.id === 'network' - ? networkExpanded - : tab.id === 'system' - ? systemExpanded - : tab.id === 'software' - ? softwareExpanded - : tab.id === 'extensions' - ? extensionsExpanded - : undefined; - const label = tabTitle(tab.id); - return ( - - - {tab.id === 'extensions' && - extensionsExpanded && - !inventory?.rustdesk.installed && - !inventory?.picoclaw.installed && ( -
- {!inventory && !inventoryError ? ( - - ) : inventoryError ? ( - (i18n.resolvedLanguage || i18n.language).startsWith('ru') ? ( - 'Не удалось загрузить расширения' - ) : ( - 'Could not load extensions' - ) - ) : (i18n.resolvedLanguage || i18n.language).startsWith('ru') ? ( - 'Нет установленных расширений' - ) : ( - 'No extensions installed' - )} -
- )} -
- ); - })} -
- -
-
- - {(!mobile || detailOpen) && ( - -
-
- } - > - }> - {tabs.find((tab) => tab.id === currentTab)?.component} - - -
-
-
- )} -
- + {dialogWanted && ( + + { + closeModal(); + setPicoclawOpen(true); + }} + /> + + )} ); }; From 41fa43acb1853416940ad73e42d292ad88aeaa0a Mon Sep 17 00:00:00 2001 From: Gleb Pesin Date: Wed, 7 Oct 2026 03:34:31 +0300 Subject: [PATCH 24/98] web: load admin-only toolbar items lazily Capture, USB, Image, Download, Terminal, Script and PicoClaw are only rendered for admins, yet their code (including the USB settings page, download progress UI and script runner) was in the desktop chunk for every user. They now come from one lazily loaded chunk (admin-items, 47 kB) that non-admin sessions never request. Order, keys and grouping are unchanged; while the chunk loads each item keeps its 30x30 slot, and Image (which renders nothing until the virtual-disk state is known) keeps an empty one, so the toolbar does not shift. Desktop chunk: 251 kB -> 202 kB; JS loaded by the desktop page for a non-admin (WebRTC player): 1484 kB -> 1416 kB. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/desktop/menu/admin-items.ts | 9 +++++ web/src/pages/desktop/menu/index.tsx | 47 +++++++++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 web/src/pages/desktop/menu/admin-items.ts diff --git a/web/src/pages/desktop/menu/admin-items.ts b/web/src/pages/desktop/menu/admin-items.ts new file mode 100644 index 00000000..4b93e44a --- /dev/null +++ b/web/src/pages/desktop/menu/admin-items.ts @@ -0,0 +1,9 @@ +// Toolbar items rendered only for admins. The menu loads them lazily as one +// chunk, so non-admin sessions never download them. +export { Capture } from './capture'; +export { DownloadImage } from './download.tsx'; +export { Image } from './image'; +export { Picoclaw } from './picoclaw'; +export { Script } from './script'; +export { Terminal } from './terminal'; +export { UsbMenu } from './usb'; diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index 038372ca..ff744e7f 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { useAuth } from '@/contexts/auth.ts'; import { Button, Divider } from 'antd'; import clsx from 'clsx'; @@ -28,25 +28,42 @@ import { MobileMenuItemProvider } from '@/components/menu-item.tsx'; import { Control } from '../control.tsx'; import { KeyboardLedStatus } from '../keyboard-led-status'; import { AudioMenu } from './audio'; -import { Capture } from './capture'; -import { DownloadImage } from './download.tsx'; import { Fullscreen } from './fullscreen'; -import { Image } from './image'; import { Keyboard } from './keyboard'; import { Logout } from './logout'; import { Mouse } from './mouse'; import { Collapse, Expand } from './operations'; -import { Picoclaw } from './picoclaw'; import { Power } from './power'; import { Recorder } from './recorder'; import { Screen } from './screen'; import { Screenshot } from './screenshot'; -import { Script } from './script'; import { Settings } from './settings'; -import { Terminal } from './terminal'; -import { UsbMenu } from './usb'; import { Wol } from './wol'; +// Admin-only items come from one lazily loaded chunk (see admin-items.ts). +const loadAdminItems = () => import('./admin-items.ts'); +const Capture = lazy(() => loadAdminItems().then((module) => ({ default: module.Capture }))); +const DownloadImage = lazy(() => + loadAdminItems().then((module) => ({ default: module.DownloadImage })) +); +const Image = lazy(() => loadAdminItems().then((module) => ({ default: module.Image }))); +const Picoclaw = lazy(() => loadAdminItems().then((module) => ({ default: module.Picoclaw }))); +const Script = lazy(() => loadAdminItems().then((module) => ({ default: module.Script }))); +const Terminal = lazy(() => loadAdminItems().then((module) => ({ default: module.Terminal }))); +const UsbMenu = lazy(() => loadAdminItems().then((module) => ({ default: module.UsbMenu }))); + +// Keeps a lazy toolbar item's 30x30 slot while its chunk loads, so the bar +// does not shift. Image renders nothing until the virtual disk state is known, +// so its placeholder is empty as well. +const ToolbarSlot = () =>