diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index d444379b..a91ad56e 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -24,13 +24,12 @@ import { getDesktopHostApiUrl, locationMatchesHost, normalizeHostUrl, + probeRelayDesktopHost, redactSensitiveUrl, resolveDesktopHostUrl, type DesktopHost, - type DesktopHostRelay, type HostProbeResult, } from '@/lib/desktopHosts'; -import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopSshConnect, @@ -49,26 +48,6 @@ const runtimeKeyForHost = (host: DesktopHost): string => { return `host:${host.id}`; }; -// Quick reachability check for a relay host: open a throwaway E2EE tunnel and -// hit /health. Confirms the relay routes to the (still-online) host before we -// commit the runtime switch, so an offline host surfaces as an error instead of -// a broken runtime. The steady-state tunnel is opened by switchRuntimeEndpoint. -const probeRelayHost = async (relay: DesktopHostRelay): Promise => { - const tunnel = createRelayTunnelClient({ - relayUrl: relay.relayUrl, - serverId: relay.serverId, - hostEncPubJwk: relay.hostEncPubJwk, - }); - try { - const response = await tunnel.fetch('/health'); - return response.ok; - } catch { - return false; - } finally { - tunnel.close(); - } -}; - type HostStatus = { status: HostProbeResult['status']; latencyMs: number; @@ -453,9 +432,8 @@ export function DesktopHostSwitcherDialog({ // Relay hosts have no HTTP address to probe — check reachability // through a throwaway E2EE tunnel instead. if (h.relay) { - const startedAt = performance.now(); - const ok = await probeRelayHost(h.relay).catch(() => false); - return [h.id, { status: ok ? ('ok' as const) : ('unreachable' as const), latencyMs: Math.round(performance.now() - startedAt) } satisfies HostStatus] as const; + const res = await probeRelayDesktopHost(h.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const; } const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url); if (!url) { @@ -527,10 +505,11 @@ export function DesktopHostSwitcherDialog({ // fetch/socket layers route through the tunnel from the singleton registry. if (host.relay) { setSwitchingHostId(host.id); - const reachable = await probeRelayHost(host.relay).catch(() => false); + const probe = await probeRelayDesktopHost(host.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const reachable = probe.status === 'ok'; setStatusById((prev) => ({ ...prev, - [host.id]: { status: reachable ? 'ok' : 'unreachable', latencyMs: 0 }, + [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, })); if (!reachable) { toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) })); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 0d81fc54..e24baf70 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -42,15 +42,19 @@ import { type DesktopSshPortForwardType, } from '@/lib/desktopSsh'; import { + desktopHostProbe, desktopHostsGet, desktopHostsSet, desktopInstallIdGet, + getDesktopHostApiUrl, normalizeHostUrl, + probeRelayDesktopHost, redactSensitiveUrl, resolveDesktopHostUrl, relayHostDisplayUrl, type DesktopHost, type DesktopHostRelay, + type HostProbeResult, } from '@/lib/desktopHosts'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; @@ -416,6 +420,9 @@ export const RemoteInstancesPage: React.FC = () => { const [isRetryPending, setIsRetryPending] = React.useState(false); const [clockMs, setClockMs] = React.useState(() => Date.now()); const [directHosts, setDirectHosts] = React.useState([]); + // Live reachability per saved host (undefined = probe in flight), mirroring + // the host switcher's status line so this list is not just dead text. + const [directHostStatus, setDirectHostStatus] = React.useState>({}); const [directDefaultHostId, setDirectDefaultHostId] = React.useState('local'); const [directLoading, setDirectLoading] = React.useState(false); const [directSaving, setDirectSaving] = React.useState(false); @@ -719,6 +726,31 @@ export const RemoteInstancesPage: React.FC = () => { await persistDirectHosts(directHosts, id); }, [directHosts, persistDirectHosts]); + // Probe saved hosts whenever the list changes so each row shows a live + // Connected/Unreachable status like the host switcher does. One pass per + // list identity — no polling; the row set changes rarely. + React.useEffect(() => { + if (!showInstanceManagement || directHosts.length === 0) return; + let cancelled = false; + void Promise.all(directHosts.map(async (host) => { + const result = host.relay + ? await probeRelayDesktopHost(host.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })) + : await (async () => { + const url = normalizeHostUrl(getDesktopHostApiUrl(host)); + if (!url) return { status: 'unreachable', latencyMs: 0 } as HostProbeResult; + return desktopHostProbe(url, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }) + .catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + })(); + return [host.id, result] as const; + })).then((entries) => { + if (cancelled) return; + setDirectHostStatus(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [directHosts, showInstanceManagement]); + const loadRemoteClients = React.useCallback(async (options?: { silent?: boolean }) => { if (!clientAuth) return; if (!options?.silent) setRemoteClientsLoading(true); @@ -1428,36 +1460,63 @@ export const RemoteInstancesPage: React.FC = () => { ) : null} {showInstanceManagement ?
-
-

{t('settings.remoteInstances.direct.title')}

-

{t('settings.remoteInstances.direct.description')}

+
+
+

{t('settings.remoteInstances.direct.title')}

+

{t('settings.remoteInstances.direct.description')}

+
+ {/* Importing a pairing link is the flagship path; add-by-address is + the manual fallback. The token-storage note lives in the add + dialog next to the token field it describes. */} +
+ + +
-
-

{t('settings.remoteInstances.direct.note')}

-
- - -
-
-
{directLoading ? (

{t('settings.remoteInstances.direct.state.loading')}

) : directHosts.length === 0 ? (

{t('settings.remoteInstances.direct.state.empty')}

- ) : directHosts.map((host) => ( + ) : directHosts.map((host) => { + const probe = directHostStatus[host.id]; + const statusKey: I18nKey = !probe + ? 'desktopHostSwitcher.status.checking' + : probe.status === 'ok' + ? 'desktopHostSwitcher.status.connected' + : probe.status === 'auth' + ? 'desktopHostSwitcher.status.authRequired' + : probe.status === 'update-recommended' + ? 'desktopHostSwitcher.status.updateRecommended' + : probe.status === 'incompatible' + ? 'desktopHostSwitcher.status.incompatible' + : probe.status === 'wrong-service' + ? 'desktopHostSwitcher.status.wrongService' + : 'desktopHostSwitcher.status.unreachable'; + const isOnline = probe?.status === 'ok'; + return (
+

{redactSensitiveUrl(host.label)}

- {directDefaultHostId === host.id ? {t('desktopHostSwitcher.header.default')} : null} + {directDefaultHostId === host.id ? {t('desktopHostSwitcher.header.default')} : null} + + {t(statusKey)} + {isOnline && typeof probe?.latencyMs === 'number' + ? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(probe.latencyMs)) }) + : ''} +

{host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)} @@ -1483,7 +1542,8 @@ export const RemoteInstancesPage: React.FC = () => {

- ))} + ); + })}
{directError ?

{directError}

: null} @@ -1494,12 +1554,15 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.direct.actions.add')} - {t('settings.remoteInstances.direct.description')} + {t('settings.remoteInstances.direct.addDialog.description')}
{ event.preventDefault(); void handleAddDirectHost(); }}> setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> - setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+ setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +

{t('settings.remoteInstances.direct.note')}

+

{t('settings.remoteInstances.direct.headers.title')}

diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 44916fff..f61a73f3 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -1,4 +1,5 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop'; +import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; type DesktopInvoke = (cmd: string, args?: Record) => Promise; @@ -287,6 +288,27 @@ export const desktopInstallIdGet = async (): Promise => { return typeof raw === 'string' ? raw.trim() : ''; }; +/** + * Reachability check for a relay host: open a throwaway E2EE tunnel and hit + * /health. Relay hosts have no HTTP address for `desktopHostProbe`. + */ +export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise => { + const tunnel = createRelayTunnelClient({ + relayUrl: relay.relayUrl, + serverId: relay.serverId, + hostEncPubJwk: relay.hostEncPubJwk, + }); + const startedAt = Date.now(); + try { + const response = await tunnel.fetch('/health'); + return { status: response.ok ? 'ok' : 'unreachable', latencyMs: Math.max(0, Date.now() - startedAt) }; + } catch { + return { status: 'unreachable', latencyMs: 0 }; + } finally { + tunnel.close(); + } +}; + export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) { diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 9bd2acac..6e33123a 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -253,7 +253,8 @@ export const settingsDict = { 'settings.remoteInstances.direct.sidebarTitle': 'Server links', 'settings.remoteInstances.direct.sidebarDescription': 'Connect with a link or token', 'settings.remoteInstances.direct.title': 'Other OpenChamber servers', - 'settings.remoteInstances.direct.description': 'Add another OpenChamber server by URL. Use this when the server is already running and you have a connection token.', + 'settings.remoteInstances.direct.description': 'Servers this app can switch to. Import a pairing link from the other server, or add one by address.', + 'settings.remoteInstances.direct.addDialog.description': 'Add another OpenChamber server by URL. Use this when the server is already running and you have a connection token.', 'settings.remoteInstances.direct.field.labelPlaceholder': 'Label (optional)', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index a3c2c722..b05ce65e 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -220,7 +220,8 @@ export const settingsDict = { "settings.remoteInstances.direct.sidebarTitle": "Enlaces a servidores", "settings.remoteInstances.direct.sidebarDescription": "Conecta con un enlace o token", "settings.remoteInstances.direct.title": "Otros servidores de OpenChamber", - "settings.remoteInstances.direct.description": "Añade otro servidor de OpenChamber por URL. Úsalo cuando el servidor ya esté en marcha y tengas un token de conexión.", + "settings.remoteInstances.direct.description": "Servidores a los que esta aplicación puede cambiar. Importa un enlace de conexión del otro servidor o añádelo por dirección.", + "settings.remoteInstances.direct.addDialog.description": "Añade otro servidor de OpenChamber por URL. Úsalo cuando el servidor ya esté en marcha y tengas un token de conexión.", "settings.remoteInstances.direct.field.labelPlaceholder": "Etiqueta (opcional)", "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 8e8956ce..56ca828f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1761,7 +1761,8 @@ export const settingsDict = { 'settings.remoteInstances.direct.sidebarTitle': 'Liens de serveur', 'settings.remoteInstances.direct.sidebarDescription': 'Se connecter avec un lien ou un token', 'settings.remoteInstances.direct.title': 'Autres serveurs OpenChamber', - 'settings.remoteInstances.direct.description': 'Ajoutez un autre serveur OpenChamber par URL. Utilisez ceci lorsque le serveur est déjà lancé et que vous disposez d’un token de connexion.', + 'settings.remoteInstances.direct.description': 'Serveurs vers lesquels cette application peut basculer. Importez un lien de connexion depuis un autre serveur ou ajoutez-le par adresse.', + 'settings.remoteInstances.direct.addDialog.description': 'Ajoutez un autre serveur OpenChamber par URL. Utilisez ceci lorsque le serveur est déjà lancé et que vous disposez d’un token de connexion.', 'settings.remoteInstances.direct.field.labelPlaceholder': 'Libellé (facultatif)', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://hôte:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token de connexion (facultatif pour les serveurs locaux de confiance)', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 7ad16683..701768f5 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -253,7 +253,8 @@ export const settingsDict = { 'settings.remoteInstances.direct.sidebarTitle': 'サーバーリンク', 'settings.remoteInstances.direct.sidebarDescription': 'リンクまたは Token で接続', 'settings.remoteInstances.direct.title': 'その他の OpenChamber サーバー', - 'settings.remoteInstances.direct.description': 'URL で別の OpenChamber サーバーを追加します。サーバーが既に実行中で接続 Token がある場合に使用します。', + 'settings.remoteInstances.direct.description': 'このアプリが切り替えられるサーバーです。相手サーバーからペアリングリンクを取り込むか、アドレスで追加します。', + 'settings.remoteInstances.direct.addDialog.description': 'URL で別の OpenChamber サーバーを追加します。サーバーが既に実行中で接続 Token がある場合に使用します。', 'settings.remoteInstances.direct.field.labelPlaceholder': 'ラベル(任意)', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 0cb93be3..f2652802 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -220,7 +220,8 @@ export const settingsDict = { 'settings.remoteInstances.direct.sidebarTitle': '서버 링크', 'settings.remoteInstances.direct.sidebarDescription': '링크나 토큰으로 연결', 'settings.remoteInstances.direct.title': '다른 OpenChamber 서버', - 'settings.remoteInstances.direct.description': 'URL로 다른 OpenChamber 서버를 추가합니다. 서버가 이미 실행 중이고 연결 토큰이 있을 때 사용하세요.', + 'settings.remoteInstances.direct.description': '이 앱이 전환할 수 있는 서버입니다. 다른 서버의 페어링 링크를 가져오거나 주소로 추가하세요.', + 'settings.remoteInstances.direct.addDialog.description': 'URL로 다른 OpenChamber 서버를 추가합니다. 서버가 이미 실행 중이고 연결 토큰이 있을 때 사용하세요.', 'settings.remoteInstances.direct.field.labelPlaceholder': '라벨(선택 사항)', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 8dc34711..0e566a08 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1449,7 +1449,8 @@ export const settingsDict = { 'settings.remoteInstances.direct.sidebarTitle': 'Linki do serwerów', 'settings.remoteInstances.direct.sidebarDescription': 'Połącz przez link lub token', 'settings.remoteInstances.direct.title': 'Inne serwery OpenChamber', - 'settings.remoteInstances.direct.description': 'Dodaj inny serwer OpenChamber przez URL. Użyj tego, gdy serwer już działa i masz token połączenia.', + 'settings.remoteInstances.direct.description': 'Serwery, na które ta aplikacja może się przełączać. Zaimportuj link parowania z innego serwera lub dodaj przez adres.', + 'settings.remoteInstances.direct.addDialog.description': 'Dodaj inny serwer OpenChamber przez URL. Użyj tego, gdy serwer już działa i masz token połączenia.', 'settings.remoteInstances.direct.field.labelPlaceholder': 'Etykieta (opcjonalnie)', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 7cd29130..db017d39 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -220,7 +220,8 @@ export const settingsDict = { "settings.remoteInstances.direct.sidebarTitle": "Links de servidores", "settings.remoteInstances.direct.sidebarDescription": "Conecte com um link ou token", "settings.remoteInstances.direct.title": "Outros servidores OpenChamber", - "settings.remoteInstances.direct.description": "Adicione outro servidor OpenChamber por URL. Use isto quando o servidor já estiver em execução e você tiver um token de conexão.", + "settings.remoteInstances.direct.description": "Servidores para os quais este aplicativo pode alternar. Importe um link de pareamento do outro servidor ou adicione pelo endereço.", + "settings.remoteInstances.direct.addDialog.description": "Adicione outro servidor OpenChamber por URL. Use isto quando o servidor já estiver em execução e você tiver um token de conexão.", "settings.remoteInstances.direct.field.labelPlaceholder": "Rótulo (opcional)", "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index e5179e7d..5b01f330 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -220,7 +220,8 @@ export const settingsDict = { "settings.remoteInstances.direct.sidebarTitle": "Посилання на сервери", "settings.remoteInstances.direct.sidebarDescription": "Підключення через посилання або токен", "settings.remoteInstances.direct.title": "Інші сервери OpenChamber", - "settings.remoteInstances.direct.description": "Додайте інший сервер OpenChamber за URL. Використовуйте це, коли сервер уже запущений і у вас є токен підключення.", + "settings.remoteInstances.direct.description": "Сервери, на які може перемикатися цей застосунок. Імпортуйте лінк підключення з іншого сервера або додайте за адресою.", + "settings.remoteInstances.direct.addDialog.description": "Додайте інший сервер OpenChamber за URL. Використовуйте це, коли сервер уже запущений і у вас є токен підключення.", "settings.remoteInstances.direct.field.labelPlaceholder": "Назва (необов’язково)", "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f07da181..86945ca1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -220,7 +220,8 @@ export const settingsDict = { 'settings.remoteInstances.direct.sidebarTitle': '服务器链接', 'settings.remoteInstances.direct.sidebarDescription': '使用链接或令牌连接', 'settings.remoteInstances.direct.title': '其他 OpenChamber 服务器', - 'settings.remoteInstances.direct.description': '通过 URL 添加另一个 OpenChamber 服务器。适用于服务器已在运行且你拥有连接令牌的情况。', + 'settings.remoteInstances.direct.description': '此应用可切换到的服务器。从另一台服务器导入配对链接,或通过地址添加。', + 'settings.remoteInstances.direct.addDialog.description': '通过 URL 添加另一个 OpenChamber 服务器。适用于服务器已在运行且你拥有连接令牌的情况。', 'settings.remoteInstances.direct.field.labelPlaceholder': '标签(可选)', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index d2eac984..b58047db 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -226,7 +226,8 @@ 'settings.remoteInstances.direct.sidebarTitle': '直接連線', 'settings.remoteInstances.direct.sidebarDescription': '連線到已在執行的 OpenChamber 伺服器。', 'settings.remoteInstances.direct.title': '直接遠端執行個體', - 'settings.remoteInstances.direct.description': '儲存可從桌面切換器使用的遠端伺服器 URL。', + 'settings.remoteInstances.direct.description': '此應用可切換到的伺服器。從另一台伺服器匯入配對連結,或透過位址新增。', + 'settings.remoteInstances.direct.addDialog.description': '儲存可從桌面切換器使用的遠端伺服器 URL。', 'settings.remoteInstances.direct.field.labelPlaceholder': '我的遠端伺服器', 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com', 'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)',