feat(desktop): live status in the servers list and a cleaner section header
- Each saved server row shows live reachability (Connected · Nms ping / Unreachable / Auth required) with a status dot, probed once per list change through the shared HTTP/relay probe (relay probing moved to desktopHosts as probeRelayDesktopHost, reused by the host switcher) - Section header: one short description, Import Link promoted to the primary action; the token-storage note moved into the Add Server dialog next to the token field it describes, and the dialog got its own description
This commit is contained in:
@@ -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<boolean> => {
|
||||
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) }));
|
||||
|
||||
@@ -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<DesktopHost[]>([]);
|
||||
// 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<Record<string, HostProbeResult>>({});
|
||||
const [directDefaultHostId, setDirectDefaultHostId] = React.useState<string | null>('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 ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
|
||||
<div className="mb-1 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="flex shrink-0 items-center gap-2 pt-0.5">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(true)} disabled={directSaving}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.direct.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.direct.note')}</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(true)} disabled={directSaving}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.direct.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{directLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
|
||||
) : directHosts.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.empty')}</p>
|
||||
) : 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 (
|
||||
<div key={host.id} className="py-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
!probe ? 'bg-muted-foreground/30 animate-pulse' : isOnline ? 'bg-[var(--status-success)]' : 'bg-[var(--status-error)]',
|
||||
)} />
|
||||
<p className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(host.label)}</p>
|
||||
{directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.default')}</span> : null}
|
||||
{directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground shrink-0">{t('desktopHostSwitcher.header.default')}</span> : null}
|
||||
<span className={cn('typography-micro shrink-0', isOnline ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>
|
||||
{t(statusKey)}
|
||||
{isOnline && typeof probe?.latencyMs === 'number'
|
||||
? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(probe.latencyMs)) })
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className={cn('typography-micro text-muted-foreground truncate', !host.relay && 'font-mono')}>
|
||||
{host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)}
|
||||
@@ -1483,7 +1542,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
|
||||
@@ -1494,12 +1554,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.direct.actions.add')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
|
||||
<DialogDescription>{t('settings.remoteInstances.direct.addDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void handleAddDirectHost(); }}>
|
||||
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
|
||||
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
|
||||
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
|
||||
<div className="space-y-1">
|
||||
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
|
||||
<p className="px-1 typography-micro text-muted-foreground">{t('settings.remoteInstances.direct.note')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||
|
||||
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
@@ -287,6 +288,27 @@ export const desktopInstallIdGet = async (): Promise<string> => {
|
||||
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<HostProbeResult> => {
|
||||
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<string, string> | null }): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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(信頼できるローカルサーバーでは任意)',
|
||||
|
||||
@@ -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': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "Токен підключення (необов’язково для довірених локальних серверів)",
|
||||
|
||||
@@ -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': '连接令牌(受信任的本地服务器可选)',
|
||||
|
||||
@@ -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(可選)',
|
||||
|
||||
Reference in New Issue
Block a user