feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)
Reworks how devices connect to an OpenChamber server, end to end. Pairing v2: - One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links - Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog - Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain) Multi-transport devices: - A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved) - Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch Device management: - Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux) - One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname - Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives Android: - LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
@@ -1108,6 +1108,21 @@ export interface RemoteClientRecord {
|
||||
revokedAt: string | null;
|
||||
expiresAt?: string | null;
|
||||
clientKind?: string | null;
|
||||
authMethod?: string | null;
|
||||
deviceName?: string | null;
|
||||
devicePlatform?: string | null;
|
||||
usesRelay?: boolean;
|
||||
/** Transport that carried the device's most recent authenticated request. */
|
||||
lastTransport?: 'relay' | 'direct' | null;
|
||||
}
|
||||
|
||||
// A pairing link that has been created but not yet redeemed by a device.
|
||||
export interface PendingPairingRecord {
|
||||
id: string;
|
||||
label?: string;
|
||||
fingerprint?: string | null;
|
||||
expiresAt?: string;
|
||||
usesRelay?: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteClientCreateResult {
|
||||
@@ -1124,11 +1139,49 @@ export interface RemoteClientPurgeRevokedResult {
|
||||
purged: number;
|
||||
}
|
||||
|
||||
export interface PairingSessionCreateResult {
|
||||
pairing: {
|
||||
id: string;
|
||||
label?: string;
|
||||
fingerprint?: string | null;
|
||||
expiresAt?: string;
|
||||
secret: string;
|
||||
};
|
||||
server: {
|
||||
label: string;
|
||||
// Transport candidates for the pairing-v2 payload. Shape matches
|
||||
// PairingEndpointCandidate in `@/lib/connectionPayload` (direct lan/tunnel or
|
||||
// relay); left as a structural type here so this contract file stays leaf.
|
||||
candidates: Array<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientAuthAPI {
|
||||
listClients(): Promise<RemoteClientRecord[]>;
|
||||
createClient(input?: { label?: string }): Promise<RemoteClientCreateResult>;
|
||||
// Creates a one-time pairing session (pairing v2). `serverUrl` is the
|
||||
// externally reachable URL to advertise as the direct candidate (the desktop
|
||||
// UI talks to its server over loopback, so it must supply the LAN URL); the
|
||||
// server folds in a relay candidate when its relay host is enabled.
|
||||
createPairingSession(input?: {
|
||||
label?: string;
|
||||
allowedClientKinds?: Array<'mobile' | 'desktop'>;
|
||||
serverUrl?: string;
|
||||
// Per-link transport choice. `includeRelay: true` adds the relay candidate
|
||||
// and enables the relay host on demand; `false` omits it; omitted keeps the
|
||||
// legacy "relay only if already enabled" behavior. `includeDirect: false`
|
||||
// produces a relay-only link (no direct candidate).
|
||||
includeRelay?: boolean;
|
||||
includeDirect?: boolean;
|
||||
}): Promise<PairingSessionCreateResult>;
|
||||
purgeRevokedClients(): Promise<RemoteClientPurgeRevokedResult>;
|
||||
revokeClient(id: string): Promise<RemoteClientRevokeResult>;
|
||||
// Pairing links created but not yet redeemed (the "pending devices" list).
|
||||
listPendingPairings(): Promise<PendingPairingRecord[]>;
|
||||
cancelPairing(id: string): Promise<{ cancelled: boolean }>;
|
||||
// Direct transports the server can be reached on, for the create-device dialog.
|
||||
// LAN reflects the server's actual bind, independent of the UI origin.
|
||||
getPairingTransports(): Promise<{ local: string | null; lan: string | null; relayAvailable: boolean }>;
|
||||
}
|
||||
|
||||
export interface RuntimeAPIs {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildPairingConnectionPayload,
|
||||
encodePairingConnectionPayload,
|
||||
parsePairingConnectionPayload,
|
||||
} from './connectionPayload';
|
||||
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
describe('connection payload helpers', () => {
|
||||
test('round-trips v2 pairing payloads with direct candidates', () => {
|
||||
const payload = buildPairingConnectionPayload({
|
||||
pairingId: 'pair_123',
|
||||
secret: 'one-time-secret',
|
||||
label: 'Desktop',
|
||||
fingerprint: 'ABCD-1234',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z',
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096/', priority: 20 },
|
||||
{ type: 'tunnel', url: 'https://runtime.example/', priority: 10 },
|
||||
],
|
||||
});
|
||||
|
||||
const encoded = encodePairingConnectionPayload(payload);
|
||||
|
||||
expect(encoded.startsWith('openchamber://connect?v=2&p=')).toBe(true);
|
||||
expect(parsePairingConnectionPayload(encoded)).toEqual({
|
||||
...payload,
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 20 },
|
||||
{ type: 'tunnel', url: 'https://runtime.example', priority: 10 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('round-trips a relay candidate (transport, not a URL)', () => {
|
||||
const payload = buildPairingConnectionPayload({
|
||||
pairingId: 'pair_relay',
|
||||
secret: 'one-time-secret',
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
|
||||
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 },
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = parsePairingConnectionPayload(encodePairingConnectionPayload(payload));
|
||||
expect(parsed?.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
|
||||
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('relay candidate keeps its path and rejects non-ws relay URLs / bad JWKs', () => {
|
||||
const withBadRelay = (candidate: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify({ v: 2, pairingId: 'pair_1', secret: 's', candidates: [candidate] })).toString('base64url');
|
||||
|
||||
// https relay URL is not a WebSocket endpoint → candidate dropped → no candidates → null.
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'https://relay.example/ws', serverId: 'srv', hostEncPubJwk })}`)).toBeNull();
|
||||
// Missing serverId.
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'wss://relay.example/ws', hostEncPubJwk })}`)).toBeNull();
|
||||
// Non-P-256 key.
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk: { kty: 'EC', crv: 'P-384', x: 'a', y: 'b' } })}`)).toBeNull();
|
||||
});
|
||||
|
||||
test('drops a private-key member from a relay JWK (keeps only public coordinates)', () => {
|
||||
const withKey = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_1',
|
||||
secret: 's',
|
||||
candidates: [{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk: { ...hostEncPubJwk, d: 'PRIVATE' } }],
|
||||
})).toString('base64url');
|
||||
const parsed = parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withKey}`);
|
||||
expect(parsed?.candidates[0]).toEqual({ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk });
|
||||
});
|
||||
|
||||
test('rejects invalid v2 pairing payloads', () => {
|
||||
expect(parsePairingConnectionPayload('openchamber://connect?v=1&server=https://runtime.example&token=t')).toBeNull();
|
||||
expect(parsePairingConnectionPayload('openchamber://connect?v=2&p=not-json')).toBeNull();
|
||||
|
||||
const missingSecret = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_123',
|
||||
candidates: [{ type: 'lan', url: 'http://runtime.example' }],
|
||||
})).toString('base64url');
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${missingSecret}`)).toBeNull();
|
||||
|
||||
const invalidCandidate = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_123',
|
||||
secret: 'secret',
|
||||
candidates: [{ type: 'lan', url: 'file:///tmp/socket' }],
|
||||
})).toString('base64url');
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${invalidCandidate}`)).toBeNull();
|
||||
|
||||
const expired = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_123',
|
||||
secret: 'secret',
|
||||
expiresAt: '2000-01-01T00:00:00.000Z',
|
||||
candidates: [{ type: 'lan', url: 'http://runtime.example' }],
|
||||
})).toString('base64url');
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,213 @@
|
||||
export type ClientConnectionPayload = {
|
||||
v: 1;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
const MAX_PAIRING_PAYLOAD_LENGTH = 16_384;
|
||||
|
||||
// A pairing candidate is one way to reach the host's HTTP API. `type`
|
||||
// discriminates the transport:
|
||||
// - lan / tunnel: reach `url` directly (health-check, then redeem over fetch).
|
||||
// - relay: no reachable URL — open the E2EE relay tunnel to `serverId` via
|
||||
// `relayUrl`, trusting `hostEncPubJwk`, then redeem over the tunnel.
|
||||
// The one-time pairing `secret` (payload level) is the single auth credential,
|
||||
// redeemed over whichever transport connects first. Relay carries no embedded
|
||||
// bearer token — that is the v1 sin this format replaces.
|
||||
export type PairingDirectCandidate = {
|
||||
type: 'lan' | 'tunnel';
|
||||
url: string;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type PairingRelayCandidate = {
|
||||
type: 'relay';
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
// One-time relay-infrastructure authorization. Reserved: the v1 relay worker
|
||||
// ignores it (E2EE + the pairing secret are the actual gates). Plumbed for
|
||||
// future relay-side per-device/traffic control. Never persisted.
|
||||
grant?: string;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type PairingEndpointCandidate = PairingDirectCandidate | PairingRelayCandidate;
|
||||
|
||||
export type PairingConnectionPayload = {
|
||||
v: 2;
|
||||
pairingId: string;
|
||||
secret: string;
|
||||
label?: string;
|
||||
fingerprint?: string;
|
||||
expiresAt?: string;
|
||||
candidates: PairingEndpointCandidate[];
|
||||
};
|
||||
|
||||
export const buildClientConnectionPayload = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
label?: string | null;
|
||||
}): ClientConnectionPayload => ({
|
||||
v: 1,
|
||||
serverUrl: input.serverUrl.trim().replace(/\/+$/, ''),
|
||||
token: input.token.trim(),
|
||||
...(input.label?.trim() ? { label: input.label.trim() } : {}),
|
||||
});
|
||||
|
||||
export const encodeClientConnectionPayload = (payload: ClientConnectionPayload): string => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', String(payload.v));
|
||||
params.set('server', payload.serverUrl);
|
||||
params.set('token', payload.token);
|
||||
if (payload.label) params.set('label', payload.label);
|
||||
return `openchamber://connect?${params.toString()}`;
|
||||
const globalWithBuffer = globalThis as typeof globalThis & {
|
||||
Buffer?: {
|
||||
from: (value: string, encoding?: string) => { toString: (encoding: string) => string };
|
||||
};
|
||||
};
|
||||
|
||||
export const parseClientConnectionPayload = (value: string): ClientConnectionPayload | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const base64UrlEncode = (value: string): string => {
|
||||
if (globalWithBuffer.Buffer) {
|
||||
return globalWithBuffer.Buffer.from(value, 'utf8').toString('base64url');
|
||||
}
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.slice(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
};
|
||||
|
||||
const base64UrlDecode = (value: string): string | null => {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') {
|
||||
return null;
|
||||
if (globalWithBuffer.Buffer) {
|
||||
return globalWithBuffer.Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
const version = url.searchParams.get('v');
|
||||
const serverUrl = url.searchParams.get('server')?.trim() || '';
|
||||
const token = url.searchParams.get('token')?.trim() || '';
|
||||
const label = url.searchParams.get('label')?.trim() || '';
|
||||
|
||||
if (version !== '1' || !serverUrl || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedServer = new URL(serverUrl);
|
||||
if (parsedServer.protocol !== 'http:' && parsedServer.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildClientConnectionPayload({ serverUrl, token, label });
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHttpUrl = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/g, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Relay endpoints are WebSocket URLs and keep their path (e.g. `/ws`, `/tunnel`),
|
||||
// so only the fragment is stripped — never the trailing path segment.
|
||||
const normalizeWsUrl = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return null;
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||
|
||||
// EC P-256 public JWK (the relay E2EE trust anchor). Strict: only the four
|
||||
// public-key members are retained; a private `d` or any other member is dropped.
|
||||
const normalizeEcPublicJwk = (value: unknown): JsonWebKey | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const jwk = value as Record<string, unknown>;
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null;
|
||||
if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null;
|
||||
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
const normalizePriority = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const normalizePairingCandidate = (value: unknown): PairingEndpointCandidate | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const priority = normalizePriority(record.priority);
|
||||
|
||||
if (record.type === 'lan' || record.type === 'tunnel') {
|
||||
const url = normalizeHttpUrl(record.url);
|
||||
if (!url) return null;
|
||||
return priority === undefined ? { type: record.type, url } : { type: record.type, url, priority };
|
||||
}
|
||||
|
||||
if (record.type === 'relay') {
|
||||
const relayUrl = normalizeWsUrl(record.relayUrl);
|
||||
if (!relayUrl) return null;
|
||||
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : '';
|
||||
if (!serverId) return null;
|
||||
const hostEncPubJwk = normalizeEcPublicJwk(record.hostEncPubJwk);
|
||||
if (!hostEncPubJwk) return null;
|
||||
const grant = typeof record.grant === 'string' && record.grant.trim() ? record.grant.trim() : undefined;
|
||||
return {
|
||||
type: 'relay',
|
||||
relayUrl,
|
||||
serverId,
|
||||
hostEncPubJwk,
|
||||
...(grant ? { grant } : {}),
|
||||
...(priority === undefined ? {} : { priority }),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizePairingPayload = (value: unknown): PairingConnectionPayload | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.v !== 2) return null;
|
||||
const pairingId = typeof record.pairingId === 'string' ? record.pairingId.trim() : '';
|
||||
const secret = typeof record.secret === 'string' ? record.secret.trim() : '';
|
||||
if (!pairingId || !secret) return null;
|
||||
const candidates = Array.isArray(record.candidates)
|
||||
? record.candidates.map(normalizePairingCandidate).filter((candidate): candidate is PairingEndpointCandidate => Boolean(candidate))
|
||||
: [];
|
||||
if (candidates.length === 0) return null;
|
||||
const expiresAt = typeof record.expiresAt === 'string' && record.expiresAt.trim() ? record.expiresAt.trim() : undefined;
|
||||
if (expiresAt) {
|
||||
const expiresTime = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(expiresTime) || expiresTime <= Date.now()) return null;
|
||||
}
|
||||
const label = typeof record.label === 'string' && record.label.trim() ? record.label.trim() : undefined;
|
||||
const fingerprint = typeof record.fingerprint === 'string' && record.fingerprint.trim() ? record.fingerprint.trim() : undefined;
|
||||
return {
|
||||
v: 2,
|
||||
pairingId,
|
||||
secret,
|
||||
...(label ? { label } : {}),
|
||||
...(fingerprint ? { fingerprint } : {}),
|
||||
...(expiresAt ? { expiresAt } : {}),
|
||||
candidates,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPairingConnectionPayload = (input: Omit<PairingConnectionPayload, 'v'>): PairingConnectionPayload => ({
|
||||
v: 2,
|
||||
pairingId: input.pairingId.trim(),
|
||||
secret: input.secret.trim(),
|
||||
...(input.label?.trim() ? { label: input.label.trim() } : {}),
|
||||
...(input.fingerprint?.trim() ? { fingerprint: input.fingerprint.trim() } : {}),
|
||||
...(input.expiresAt?.trim() ? { expiresAt: input.expiresAt.trim() } : {}),
|
||||
candidates: input.candidates,
|
||||
});
|
||||
|
||||
export const encodePairingConnectionPayload = (payload: PairingConnectionPayload): string => {
|
||||
const normalized = normalizePairingPayload(payload);
|
||||
if (!normalized) throw new Error('Invalid pairing connection payload');
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', '2');
|
||||
params.set('p', base64UrlEncode(JSON.stringify(normalized)));
|
||||
return `openchamber://connect?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const parsePairingConnectionPayload = (value: string): PairingConnectionPayload | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') return null;
|
||||
if (url.searchParams.get('v') !== '2') return null;
|
||||
const encoded = url.searchParams.get('p') || '';
|
||||
if (!encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||
const decoded = base64UrlDecode(encoded);
|
||||
if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||
return normalizePairingPayload(JSON.parse(decoded) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,17 +21,44 @@ const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | unde
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Private-relay reachability for a host. When present, the host is reached over
|
||||
* the E2EE relay tunnel (no direct `apiUrl`); `hostEncPubJwk` is the trust anchor
|
||||
* that pins the tunnel to the real server. The relay admission `grant` is a
|
||||
* one-time pairing artifact and is intentionally NOT persisted — steady-state
|
||||
* relay connections route by `serverId` alone (mirrors the mobile app).
|
||||
*/
|
||||
export type DesktopHostRelay = {
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
};
|
||||
|
||||
export type DesktopHost = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Legacy/UI URL. During migration this may equal apiUrl. */
|
||||
/** Legacy/UI URL. During migration this may equal apiUrl. For relay hosts this is a display-only `relay://<serverId>` pseudo-URL. */
|
||||
url: string;
|
||||
/** API endpoint used by packaged Electron UI for this instance. */
|
||||
/** API endpoint used by packaged Electron UI for this instance. Absent for relay-only hosts. */
|
||||
apiUrl?: string;
|
||||
/** Remote client bearer token for packaged-client API access. */
|
||||
clientToken?: string;
|
||||
/** Extra headers for desktop runtime API requests. */
|
||||
requestHeaders?: Record<string, string>;
|
||||
/** When set, this host is reached over the private relay tunnel. */
|
||||
relay?: DesktopHostRelay;
|
||||
};
|
||||
|
||||
/** Display-only pseudo-URL for a relay host (never fetched). */
|
||||
export const relayHostDisplayUrl = (serverId: string): string => `relay://${serverId}`;
|
||||
|
||||
const parseHostRelay = (value: unknown): DesktopHostRelay | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const relayUrl = readString(value, 'relayUrl') || readString(value, 'relay_url');
|
||||
const serverId = readString(value, 'serverId') || readString(value, 'server_id');
|
||||
const jwk = value.hostEncPubJwk ?? value.host_enc_pub_jwk;
|
||||
if (!relayUrl || !serverId || !isRecord(jwk)) return null;
|
||||
return { relayUrl, serverId, hostEncPubJwk: jwk as JsonWebKey };
|
||||
};
|
||||
|
||||
export type DesktopHostsConfig = {
|
||||
@@ -174,6 +201,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url');
|
||||
const clientToken = readString(value, 'clientToken') || readString(value, 'client_token');
|
||||
const requestHeaders = sanitizeRequestHeaders(value.requestHeaders);
|
||||
const relay = parseHostRelay(value.relay);
|
||||
if (!id || !label || !url) return null;
|
||||
return {
|
||||
id,
|
||||
@@ -182,6 +210,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(clientToken ? { clientToken } : {}),
|
||||
...(requestHeaders ? { requestHeaders } : {}),
|
||||
...(relay ? { relay } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -245,6 +274,19 @@ export const desktopLocalClientTokenGet = async (): Promise<string> => {
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable per-install identifier for this desktop. Used as the client dedupe key
|
||||
* so re-pairing or re-authenticating this desktop reuses its single device
|
||||
* record on a server instead of piling up duplicates. Empty string when not in
|
||||
* the desktop shell.
|
||||
*/
|
||||
export const desktopInstallIdGet = async (): Promise<string> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return '';
|
||||
const raw = await invoke('desktop_install_id_get').catch(() => null);
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
|
||||
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isElectronShell } from '@/lib/desktop';
|
||||
import { desktopHostsGet } from '@/lib/desktopHosts';
|
||||
import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* On desktop startup, re-open the E2EE relay tunnel if the default host is a
|
||||
* relay host. Relay hosts have no reachable HTTP base, so the Electron shell
|
||||
* boots the LOCAL UI and defers reconnection to the renderer: here we read the
|
||||
* persisted relay descriptor + client token and activate the tunnel in-process
|
||||
* via switchRuntimeEndpoint({ relay }). Direct hosts don't need this — the shell
|
||||
* injects their apiBaseUrl/token as window globals before render.
|
||||
*
|
||||
* Safe to call unconditionally; it is a no-op outside the Electron shell and when
|
||||
* the default host is local or already active.
|
||||
*/
|
||||
export const restoreDesktopRelayRuntime = async (): Promise<void> => {
|
||||
if (!isElectronShell()) return;
|
||||
const config = await desktopHostsGet().catch(() => null);
|
||||
const defaultHostId = config?.defaultHostId;
|
||||
if (!config || !defaultHostId || defaultHostId === 'local') return;
|
||||
const host = config.hosts.find((entry) => entry.id === defaultHostId);
|
||||
if (!host?.relay) return;
|
||||
// Must match runtimeKeyForHost() in DesktopHostSwitcher so switch/resolve agree.
|
||||
const runtimeKey = `host:${host.id}`;
|
||||
if (getRuntimeKey() === runtimeKey) return;
|
||||
switchRuntimeEndpoint({
|
||||
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
|
||||
clientToken: host.clientToken || null,
|
||||
runtimeKey,
|
||||
relay: host.relay,
|
||||
});
|
||||
};
|
||||
@@ -273,21 +273,43 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.state.empty': 'No other servers added yet.',
|
||||
'settings.remoteInstances.clientAuth.title': 'Connect to this server',
|
||||
'settings.remoteInstances.clientAuth.description': 'Create a secure link or token so OpenChamber Desktop can connect to this server.',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Device name (optional)',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Device name — e.g. My iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': 'Create Token',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': 'Create Link',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': 'Revoke',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Clear revoked',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': 'Enlarge QR code',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': 'Scan this with the OpenChamber app on your other device. It is single-use and expires.',
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': 'Scan to connect',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': 'Add a device',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': 'Copied',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Where will you use this device?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Create a one-time QR code that connects another device to this server.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': 'This computer only',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'For apps running on this same machine.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Home network only',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Connects directly over your Wi-Fi. Does not work away from this network.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Anywhere',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Works at home and away. Away traffic goes through OpenChamber Private Relay — an end-to-end encrypted tunnel. No setup needed.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Also allow the encrypted relay when away from home',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Prefer the direct home connection when available',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': 'Create QR code',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': 'Done',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': 'Connection link',
|
||||
'settings.remoteInstances.clientAuth.createdToken': 'Copy this token now. For security, it will not be shown again.',
|
||||
'settings.remoteInstances.clientAuth.state.loading': 'Loading tokens...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': 'No devices connected yet.',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': 'Revoked',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'This device',
|
||||
'settings.remoteInstances.clientAuth.state.pending': 'Waiting to connect…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': 'Connected · Local network',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': 'Connected · Relay',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Last used {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Never used',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': 'Turns on automatically when you pair a device over the relay.',
|
||||
'settings.remoteInstances.relay.description': 'Let your other devices connect from anywhere without opening ports. Traffic is end-to-end encrypted — the relay cannot read it.',
|
||||
'settings.remoteInstances.relay.enableHint': 'Nothing is shared until you enable the relay on this server.',
|
||||
'settings.remoteInstances.relay.actions.enable': 'Enable Relay',
|
||||
|
||||
@@ -240,21 +240,43 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.direct.state.empty": "Todavía no se han añadido otros servidores.",
|
||||
"settings.remoteInstances.clientAuth.title": "Conectarse a este servidor",
|
||||
"settings.remoteInstances.clientAuth.description": "Crea un enlace o token seguro para que OpenChamber Desktop pueda conectarse a este servidor.",
|
||||
"settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nombre del dispositivo (opcional)",
|
||||
"settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nombre del dispositivo — p. ej. Mi iPhone",
|
||||
"settings.remoteInstances.clientAuth.actions.create": "Crear token",
|
||||
"settings.remoteInstances.clientAuth.actions.pair": "Crear enlace",
|
||||
"settings.remoteInstances.clientAuth.actions.revoke": "Revocar",
|
||||
"settings.remoteInstances.clientAuth.actions.clearRevoked": "Borrar revocados",
|
||||
"settings.remoteInstances.clientAuth.qrAlt": "OpenChamber connection QR code",
|
||||
"settings.remoteInstances.clientAuth.qrEnlarge": "Ampliar código QR",
|
||||
"settings.remoteInstances.clientAuth.qrScanHint": "Escanéalo con la app de OpenChamber en tu otro dispositivo. Es de un solo uso y caduca.",
|
||||
"settings.remoteInstances.clientAuth.qrDialogTitle": "Escanear para conectar",
|
||||
"settings.remoteInstances.clientAuth.actions.addDevice": "Añadir un dispositivo",
|
||||
"settings.remoteInstances.clientAuth.actions.copied": "Copiado",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transportLabel": "¿Dónde usarás este dispositivo?",
|
||||
"settings.remoteInstances.clientAuth.addDevice.subtitle": "Crea un código QR de un solo uso que conecta otro dispositivo a este servidor.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.local": "Solo este equipo",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.localHint": "Para aplicaciones en esta misma máquina.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.lan": "Solo red doméstica",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.lanHint": "Se conecta directamente por tu Wi-Fi. No funciona fuera de esta red.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.relay": "En cualquier lugar",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.relayHint": "Funciona en casa y fuera. Fuera de casa el tráfico pasa por OpenChamber Private Relay, un túnel cifrado de extremo a extremo. Sin configuración.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.fallback.relay": "Permitir también el relay cifrado fuera de casa",
|
||||
"settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal": "Preferir la conexión doméstica directa cuando esté disponible",
|
||||
"settings.remoteInstances.clientAuth.addDevice.create": "Crear código QR",
|
||||
"settings.remoteInstances.clientAuth.addDevice.done": "Listo",
|
||||
"settings.remoteInstances.clientAuth.pairingUrl": "Enlace de conexión",
|
||||
"settings.remoteInstances.clientAuth.createdToken": "Copia este token ahora. Por seguridad, no se volverá a mostrar.",
|
||||
"settings.remoteInstances.clientAuth.state.loading": "Cargando tokens...",
|
||||
"settings.remoteInstances.clientAuth.state.empty": "Todavía no hay dispositivos conectados.",
|
||||
"settings.remoteInstances.clientAuth.state.revoked": "Revocado",
|
||||
"settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo",
|
||||
"settings.remoteInstances.clientAuth.state.pending": "Esperando conexión…",
|
||||
"settings.remoteInstances.clientAuth.state.viaRelay": "Relay",
|
||||
"settings.remoteInstances.clientAuth.state.connectedDirect": "Conectado · Red local",
|
||||
"settings.remoteInstances.clientAuth.state.connectedRelay": "Conectado · Relay",
|
||||
"settings.remoteInstances.clientAuth.lastUsed": "Último uso {date}",
|
||||
"settings.remoteInstances.clientAuth.neverUsed": "Nunca usado",
|
||||
"settings.remoteInstances.relay.title": "OpenChamber Relay",
|
||||
"settings.remoteInstances.relay.autoHint": "Se activa automáticamente al vincular un dispositivo por relay.",
|
||||
"settings.remoteInstances.relay.description": "Permite que tus otros dispositivos se conecten desde cualquier lugar sin abrir puertos. El tráfico está cifrado de extremo a extremo: el relay no puede leerlo.",
|
||||
"settings.remoteInstances.relay.enableHint": "No se comparte nada hasta que actives el relay en este servidor.",
|
||||
"settings.remoteInstances.relay.actions.enable": "Activar Relay",
|
||||
|
||||
@@ -1781,21 +1781,43 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.state.empty': 'Aucun autre serveur ajouté pour le moment.',
|
||||
'settings.remoteInstances.clientAuth.title': 'Se connecter à ce serveur',
|
||||
'settings.remoteInstances.clientAuth.description': 'Créez un lien ou un token sécurisé pour permettre à OpenChamber Desktop de se connecter à ce serveur.',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nom de l’appareil (facultatif)',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nom du nouvel appareil — ex. Mon iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': 'Créer un token',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': 'Créer un lien',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': 'Révoquer',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Effacer les révocations',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'QR code de connexion OpenChamber',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': 'Agrandir le QR code',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': "Scannez-le avec l'application OpenChamber sur votre autre appareil. À usage unique et expire.",
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': 'Scanner pour se connecter',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': 'Ajouter un appareil',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': 'Copié',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Où utiliserez-vous cet appareil ?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Créez un code QR à usage unique qui connecte un autre appareil à ce serveur.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': 'Cet ordinateur uniquement',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'Pour les applications sur cette même machine.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Réseau domestique uniquement',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Connexion directe via votre Wi-Fi. Ne fonctionne pas hors de ce réseau.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Partout',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Fonctionne à la maison et en déplacement. En déplacement, le trafic passe par OpenChamber Private Relay — un tunnel chiffré de bout en bout. Aucune configuration.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Autoriser aussi le relais chiffré en déplacement',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Préférer la connexion domestique directe quand elle est disponible',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': 'Créer le code QR',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': 'Terminé',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': 'Lien de connexion',
|
||||
'settings.remoteInstances.clientAuth.createdToken': 'Copiez ce token maintenant. Pour des raisons de sécurité, il ne sera plus affiché.',
|
||||
'settings.remoteInstances.clientAuth.state.loading': 'Chargement des tokens...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': 'Aucun appareil connecté pour le moment.',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': 'Révoqué',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'Cet appareil',
|
||||
'settings.remoteInstances.clientAuth.state.pending': 'En attente de connexion…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': 'Connecté · Réseau local',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': 'Connecté · Relais',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Dernière utilisation le {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Jamais utilisé',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': 'Activé automatiquement lorsque vous associez un appareil via le relais.',
|
||||
'settings.remoteInstances.relay.description': 'Permettez à vos autres appareils de se connecter depuis n’importe où sans ouvrir de ports. Le trafic est chiffré de bout en bout — le relais ne peut pas le lire.',
|
||||
'settings.remoteInstances.relay.enableHint': 'Rien n’est partagé tant que vous n’activez pas le relais sur ce serveur.',
|
||||
'settings.remoteInstances.relay.actions.enable': 'Activer le relais',
|
||||
|
||||
@@ -273,21 +273,43 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.state.empty': 'まだ他のサーバーが追加されていません。',
|
||||
'settings.remoteInstances.clientAuth.title': 'このサーバーに接続',
|
||||
'settings.remoteInstances.clientAuth.description': 'OpenChamber Desktop がこのサーバーに接続できるように、安全なリンクまたは Token を作成します。',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'デバイス名(任意)',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'デバイス名 — 例: My iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': 'Token を作成',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': 'リンクを作成',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': '無効化',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': '無効化済みをクリア',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber 接続 QR コード',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': 'QR コードを拡大',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': '別のデバイスの OpenChamber アプリでスキャンしてください。1 回限りで期限切れになります。',
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': 'スキャンして接続',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': 'デバイスを追加',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': 'コピーしました',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'このデバイスをどこで使いますか?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': 'このサーバーに別のデバイスを接続する使い捨てQRコードを作成します。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': 'このコンピュータのみ',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '同じマシン上のアプリ用です。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': '自宅ネットワークのみ',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Wi-Fi経由で直接接続します。このネットワークの外では使えません。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'どこでも',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '自宅でも外出先でも使えます。外出先の通信は、エンドツーエンド暗号化トンネルのOpenChamber Private Relayを経由します。設定は不要です。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '外出先では暗号化リレー経由の接続も許可',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '可能なときは自宅の直接接続を優先',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': 'QRコードを作成',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': '完了',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': '接続リンク',
|
||||
'settings.remoteInstances.clientAuth.createdToken': 'この Token を今すぐコピーしてください。セキュリティのため、再表示されません。',
|
||||
'settings.remoteInstances.clientAuth.state.loading': 'Token を読み込み中...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': 'まだデバイスが接続されていません。',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': '無効化済み',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'このデバイス',
|
||||
'settings.remoteInstances.clientAuth.state.pending': '接続を待機中…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': '接続中 · ローカルネットワーク',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': '接続中 · リレー',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '最終使用 {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '未使用',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': 'リレー経由でデバイスをペアリングすると自動的に有効になります。',
|
||||
'settings.remoteInstances.relay.description': 'ポートを開放せずに、他のデバイスからどこからでも接続できます。通信はエンドツーエンドで暗号化され、リレーは内容を読めません。',
|
||||
'settings.remoteInstances.relay.enableHint': 'このサーバーでリレーを有効にするまで、何も共有されません。',
|
||||
'settings.remoteInstances.relay.actions.enable': 'リレーを有効にする',
|
||||
|
||||
@@ -240,21 +240,43 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.state.empty': '아직 추가된 다른 서버가 없습니다.',
|
||||
'settings.remoteInstances.clientAuth.title': '이 서버에 연결',
|
||||
'settings.remoteInstances.clientAuth.description': 'OpenChamber Desktop이 이 서버에 연결할 수 있도록 안전한 링크나 토큰을 만듭니다.',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': '기기 이름(선택 사항)',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': '기기 이름 — 예: My iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': '토큰 만들기',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': '링크 만들기',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': '해지',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': '해지된 항목 지우기',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': 'QR 코드 확대',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': '다른 기기의 OpenChamber 앱으로 스캔하세요. 일회용이며 만료됩니다.',
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': '스캔하여 연결',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': '기기 추가',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': '복사됨',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': '이 기기를 어디에서 사용하나요?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': '다른 기기를 이 서버에 연결하는 일회용 QR 코드를 만듭니다.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': '이 컴퓨터 전용',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '같은 컴퓨터의 앱을 위한 옵션입니다.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': '집 네트워크 전용',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Wi-Fi로 직접 연결합니다. 이 네트워크 밖에서는 작동하지 않습니다.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': '어디서나',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '집과 밖 어디서나 작동합니다. 밖에서는 종단간 암호화 터널인 OpenChamber Private Relay를 통해 연결됩니다. 설정이 필요 없습니다.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '밖에서는 암호화 릴레이 연결도 허용',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '가능하면 집에서는 직접 연결 우선',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': 'QR 코드 만들기',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': '완료',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': '연결 링크',
|
||||
'settings.remoteInstances.clientAuth.createdToken': '지금 이 토큰을 복사하세요. 보안을 위해 다시 표시되지 않습니다.',
|
||||
'settings.remoteInstances.clientAuth.state.loading': '토큰을 불러오는 중...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': '아직 연결된 기기가 없습니다.',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': '해지됨',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': '이 기기',
|
||||
'settings.remoteInstances.clientAuth.state.pending': '연결 대기 중…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': '연결됨 · 로컬 네트워크',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': '연결됨 · 릴레이',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '마지막 사용 {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '사용한 적 없음',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': '릴레이로 기기를 페어링하면 자동으로 켜집니다.',
|
||||
'settings.remoteInstances.relay.description': '포트를 열지 않고도 다른 기기가 어디서든 연결할 수 있습니다. 트래픽은 종단 간 암호화되어 릴레이는 내용을 읽을 수 없습니다.',
|
||||
'settings.remoteInstances.relay.enableHint': '이 서버에서 릴레이를 켜기 전까지는 아무것도 공유되지 않습니다.',
|
||||
'settings.remoteInstances.relay.actions.enable': '릴레이 켜기',
|
||||
|
||||
@@ -1469,21 +1469,43 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.state.empty': 'Nie dodano jeszcze innych serwerów.',
|
||||
'settings.remoteInstances.clientAuth.title': 'Połącz z tym serwerem',
|
||||
'settings.remoteInstances.clientAuth.description': 'Utwórz bezpieczny link lub token, aby OpenChamber Desktop mógł połączyć się z tym serwerem.',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nazwa urządzenia (opcjonalnie)',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Nazwa urządzenia — np. Mój iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': 'Utwórz token',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': 'Utwórz link',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': 'Unieważnij',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Wyczyść unieważnione',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'Kod QR połączenia OpenChamber',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': 'Powiększ kod QR',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': 'Zeskanuj to aplikacją OpenChamber na drugim urządzeniu. Jednorazowy i wygasa.',
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': 'Zeskanuj, aby połączyć',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': 'Dodaj urządzenie',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': 'Skopiowano',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Gdzie będziesz używać tego urządzenia?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Utwórz jednorazowy kod QR, który połączy inne urządzenie z tym serwerem.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': 'Tylko ten komputer',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'Dla aplikacji na tej samej maszynie.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Tylko sieć domowa',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Łączy się bezpośrednio przez Wi-Fi. Nie działa poza tą siecią.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Wszędzie',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Działa w domu i poza nim. Poza domem ruch przechodzi przez OpenChamber Private Relay — szyfrowany end-to-end tunel. Bez konfiguracji.',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Zezwól też na szyfrowany relay poza domem',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Preferuj bezpośrednie połączenie domowe, gdy dostępne',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': 'Utwórz kod QR',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': 'Gotowe',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': 'Link połączenia',
|
||||
'settings.remoteInstances.clientAuth.createdToken': 'Skopiuj ten token teraz. Ze względów bezpieczeństwa nie zostanie pokazany ponownie.',
|
||||
'settings.remoteInstances.clientAuth.state.loading': 'Ładowanie tokenów...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': 'Nie podłączono jeszcze żadnych urządzeń.',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': 'Unieważniony',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'To urządzenie',
|
||||
'settings.remoteInstances.clientAuth.state.pending': 'Oczekiwanie na połączenie…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': 'Połączono · Sieć lokalna',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': 'Połączono · Relay',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Ostatnio użyto {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Nigdy nie użyto',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': 'Włącza się automatycznie po sparowaniu urządzenia przez relay.',
|
||||
'settings.remoteInstances.relay.description': 'Pozwól swoim innym urządzeniom łączyć się z dowolnego miejsca bez otwierania portów. Ruch jest szyfrowany od końca do końca — relay nie może go odczytać.',
|
||||
'settings.remoteInstances.relay.enableHint': 'Nic nie jest udostępniane, dopóki nie włączysz relay na tym serwerze.',
|
||||
'settings.remoteInstances.relay.actions.enable': 'Włącz Relay',
|
||||
|
||||
@@ -240,21 +240,43 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.direct.state.empty": "Nenhum outro servidor adicionado ainda.",
|
||||
"settings.remoteInstances.clientAuth.title": "Conectar a este servidor",
|
||||
"settings.remoteInstances.clientAuth.description": "Crie um link ou token seguro para que o OpenChamber Desktop possa se conectar a este servidor.",
|
||||
"settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nome do dispositivo (opcional)",
|
||||
"settings.remoteInstances.clientAuth.field.labelPlaceholder": "Nome do dispositivo — ex.: Meu iPhone",
|
||||
"settings.remoteInstances.clientAuth.actions.create": "Criar token",
|
||||
"settings.remoteInstances.clientAuth.actions.pair": "Criar link",
|
||||
"settings.remoteInstances.clientAuth.actions.revoke": "Revogar",
|
||||
"settings.remoteInstances.clientAuth.actions.clearRevoked": "Limpar revogados",
|
||||
"settings.remoteInstances.clientAuth.qrAlt": "OpenChamber connection QR code",
|
||||
"settings.remoteInstances.clientAuth.qrEnlarge": "Ampliar código QR",
|
||||
"settings.remoteInstances.clientAuth.qrScanHint": "Escaneie com o app OpenChamber no seu outro dispositivo. É de uso único e expira.",
|
||||
"settings.remoteInstances.clientAuth.qrDialogTitle": "Escanear para conectar",
|
||||
"settings.remoteInstances.clientAuth.actions.addDevice": "Adicionar um dispositivo",
|
||||
"settings.remoteInstances.clientAuth.actions.copied": "Copiado",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transportLabel": "Onde você vai usar este dispositivo?",
|
||||
"settings.remoteInstances.clientAuth.addDevice.subtitle": "Crie um código QR de uso único que conecta outro dispositivo a este servidor.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.local": "Somente este computador",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.localHint": "Para aplicativos nesta mesma máquina.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.lan": "Somente rede doméstica",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.lanHint": "Conecta diretamente pela sua rede Wi-Fi. Não funciona fora desta rede.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.relay": "Em qualquer lugar",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.relayHint": "Funciona em casa e fora. Fora de casa o tráfego passa pelo OpenChamber Private Relay, um túnel criptografado de ponta a ponta. Sem configuração.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.fallback.relay": "Também permitir o relay criptografado fora de casa",
|
||||
"settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal": "Preferir a conexão doméstica direta quando disponível",
|
||||
"settings.remoteInstances.clientAuth.addDevice.create": "Criar código QR",
|
||||
"settings.remoteInstances.clientAuth.addDevice.done": "Concluído",
|
||||
"settings.remoteInstances.clientAuth.pairingUrl": "Link de conexão",
|
||||
"settings.remoteInstances.clientAuth.createdToken": "Copie este token agora. Por segurança, ele não será mostrado novamente.",
|
||||
"settings.remoteInstances.clientAuth.state.loading": "Carregando tokens...",
|
||||
"settings.remoteInstances.clientAuth.state.empty": "Nenhum dispositivo conectado ainda.",
|
||||
"settings.remoteInstances.clientAuth.state.revoked": "Revogado",
|
||||
"settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo",
|
||||
"settings.remoteInstances.clientAuth.state.pending": "Aguardando conexão…",
|
||||
"settings.remoteInstances.clientAuth.state.viaRelay": "Relay",
|
||||
"settings.remoteInstances.clientAuth.state.connectedDirect": "Conectado · Rede local",
|
||||
"settings.remoteInstances.clientAuth.state.connectedRelay": "Conectado · Relay",
|
||||
"settings.remoteInstances.clientAuth.lastUsed": "Último uso em {date}",
|
||||
"settings.remoteInstances.clientAuth.neverUsed": "Nunca usado",
|
||||
"settings.remoteInstances.relay.title": "OpenChamber Relay",
|
||||
"settings.remoteInstances.relay.autoHint": "Liga automaticamente ao parear um dispositivo pelo relay.",
|
||||
"settings.remoteInstances.relay.description": "Permita que seus outros dispositivos se conectem de qualquer lugar sem abrir portas. O tráfego é criptografado de ponta a ponta — o relay não consegue lê-lo.",
|
||||
"settings.remoteInstances.relay.enableHint": "Nada é compartilhado até você ativar o relay neste servidor.",
|
||||
"settings.remoteInstances.relay.actions.enable": "Ativar Relay",
|
||||
|
||||
@@ -240,21 +240,43 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.direct.state.empty": "Інших серверів ще не додано.",
|
||||
"settings.remoteInstances.clientAuth.title": "Підключення до цього сервера",
|
||||
"settings.remoteInstances.clientAuth.description": "Створіть безпечне посилання або токен, щоб OpenChamber Desktop міг підключитися до цього сервера.",
|
||||
"settings.remoteInstances.clientAuth.field.labelPlaceholder": "Назва пристрою (необов’язково)",
|
||||
"settings.remoteInstances.clientAuth.field.labelPlaceholder": "Назва пристрою — напр. Мій iPhone",
|
||||
"settings.remoteInstances.clientAuth.actions.create": "Створити токен",
|
||||
"settings.remoteInstances.clientAuth.actions.pair": "Створити посилання",
|
||||
"settings.remoteInstances.clientAuth.actions.revoke": "Відкликати",
|
||||
"settings.remoteInstances.clientAuth.actions.clearRevoked": "Очистити відкликані",
|
||||
"settings.remoteInstances.clientAuth.qrAlt": "QR-код підключення OpenChamber",
|
||||
"settings.remoteInstances.clientAuth.qrEnlarge": "Збільшити QR-код",
|
||||
"settings.remoteInstances.clientAuth.qrScanHint": "Скануй це застосунком OpenChamber на іншому пристрої. Одноразовий і має термін дії.",
|
||||
"settings.remoteInstances.clientAuth.qrDialogTitle": "Сканувати для підключення",
|
||||
"settings.remoteInstances.clientAuth.actions.addDevice": "Додати пристрій",
|
||||
"settings.remoteInstances.clientAuth.actions.copied": "Скопійовано",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transportLabel": "Де ви будете користуватись цим пристроєм?",
|
||||
"settings.remoteInstances.clientAuth.addDevice.subtitle": "Створіть одноразовий QR-код, який підключить інший пристрій до цього сервера.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.local": "Лише цей компʼютер",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.localHint": "Для застосунків на цій самій машині.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.lan": "Лише домашня мережа",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.lanHint": "Підключається напряму через ваш Wi-Fi. Поза цією мережею не працює.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.relay": "Будь-де",
|
||||
"settings.remoteInstances.clientAuth.addDevice.transport.relayHint": "Працює вдома і поза домом. Поза домом трафік іде через OpenChamber Private Relay — наскрізно зашифрований тунель. Нічого налаштовувати не треба.",
|
||||
"settings.remoteInstances.clientAuth.addDevice.fallback.relay": "Також дозволити зашифрований relay поза домом",
|
||||
"settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal": "Віддавати перевагу прямому домашньому підключенню, коли доступне",
|
||||
"settings.remoteInstances.clientAuth.addDevice.create": "Створити QR-код",
|
||||
"settings.remoteInstances.clientAuth.addDevice.done": "Готово",
|
||||
"settings.remoteInstances.clientAuth.pairingUrl": "Посилання для підключення",
|
||||
"settings.remoteInstances.clientAuth.createdToken": "Скопіюйте цей токен зараз. З міркувань безпеки він більше не показуватиметься.",
|
||||
"settings.remoteInstances.clientAuth.state.loading": "Завантаження токенів...",
|
||||
"settings.remoteInstances.clientAuth.state.empty": "Жоден пристрій ще не підключено.",
|
||||
"settings.remoteInstances.clientAuth.state.revoked": "Відкликано",
|
||||
"settings.remoteInstances.clientAuth.state.thisDevice": "Цей пристрій",
|
||||
"settings.remoteInstances.clientAuth.state.pending": "Очікує підключення…",
|
||||
"settings.remoteInstances.clientAuth.state.viaRelay": "Relay",
|
||||
"settings.remoteInstances.clientAuth.state.connectedDirect": "Підключено · Локальна мережа",
|
||||
"settings.remoteInstances.clientAuth.state.connectedRelay": "Підключено · Relay",
|
||||
"settings.remoteInstances.clientAuth.lastUsed": "Останнє використання {date}",
|
||||
"settings.remoteInstances.clientAuth.neverUsed": "Ще не використовувався",
|
||||
"settings.remoteInstances.relay.title": "OpenChamber Relay",
|
||||
"settings.remoteInstances.relay.autoHint": "Вмикається автоматично, коли ти паруєш пристрій через relay.",
|
||||
"settings.remoteInstances.relay.description": "Дозволяє вашим іншим пристроям підключатися звідки завгодно без відкриття портів. Трафік шифрується наскрізно — релей не може його прочитати.",
|
||||
"settings.remoteInstances.relay.enableHint": "Нічого не передається, доки ви не увімкнете релей на цьому сервері.",
|
||||
"settings.remoteInstances.relay.actions.enable": "Увімкнути Relay",
|
||||
|
||||
@@ -240,21 +240,43 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.state.empty': '尚未添加其他服务器。',
|
||||
'settings.remoteInstances.clientAuth.title': '连接到此服务器',
|
||||
'settings.remoteInstances.clientAuth.description': '创建安全链接或令牌,让 OpenChamber Desktop 可以连接到此服务器。',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': '设备名称(可选)',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': '设备名称 — 例如 My iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': '创建令牌',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': '创建链接',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': '撤销',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': '清除已撤销',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': '放大二维码',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': '用另一台设备上的 OpenChamber 应用扫描。一次性使用且会过期。',
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': '扫码连接',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': '添加设备',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': '已复制',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': '你会在哪里使用这台设备?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': '创建一次性二维码,把另一台设备连接到此服务器。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': '仅本机',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '供同一台电脑上的应用使用。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': '仅家庭网络',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': '通过 Wi-Fi 直接连接。离开此网络后无法使用。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': '任何地方',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '在家和外出都可用。外出时流量经由 OpenChamber Private Relay(端到端加密隧道)传输,无需配置。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '外出时也允许通过加密中继连接',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '在家时优先使用直接连接',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': '创建二维码',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': '完成',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': '连接链接',
|
||||
'settings.remoteInstances.clientAuth.createdToken': '请立即复制此令牌。出于安全考虑,它不会再次显示。',
|
||||
'settings.remoteInstances.clientAuth.state.loading': '正在加载令牌...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': '尚无已连接设备。',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': '已撤销',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': '此设备',
|
||||
'settings.remoteInstances.clientAuth.state.pending': '等待连接…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': '已连接 · 局域网',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': '已连接 · 中继',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '上次使用 {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '从未使用',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': '通过中继配对设备时自动开启。',
|
||||
'settings.remoteInstances.relay.description': '无需开放端口,即可让你的其他设备从任何地方连接。流量端到端加密,中继无法读取内容。',
|
||||
'settings.remoteInstances.relay.enableHint': '在此服务器上启用中继之前,不会共享任何内容。',
|
||||
'settings.remoteInstances.relay.actions.enable': '启用中继',
|
||||
|
||||
@@ -246,21 +246,43 @@
|
||||
'settings.remoteInstances.direct.state.empty': '尚無直接連線。',
|
||||
'settings.remoteInstances.clientAuth.title': '用戶端存取 token',
|
||||
'settings.remoteInstances.clientAuth.description': '建立與管理可讓桌面或遠端用戶端連線的 token。',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': '裝置或用戶端名稱',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': '裝置名稱 — 例如 My iPhone',
|
||||
'settings.remoteInstances.clientAuth.actions.create': '建立 token',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': '配對裝置',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': '撤銷',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': '清除已撤銷',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': '配對 QR code',
|
||||
'settings.remoteInstances.clientAuth.qrEnlarge': '放大 QR code',
|
||||
'settings.remoteInstances.clientAuth.qrScanHint': '用另一台裝置上的 OpenChamber 應用程式掃描。一次性使用且會過期。',
|
||||
'settings.remoteInstances.clientAuth.qrDialogTitle': '掃碼連線',
|
||||
'settings.remoteInstances.clientAuth.actions.addDevice': '新增裝置',
|
||||
'settings.remoteInstances.clientAuth.actions.copied': '已複製',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transportLabel': '你會在哪裡使用這台裝置?',
|
||||
'settings.remoteInstances.clientAuth.addDevice.subtitle': '建立一次性 QR 代碼,將另一台裝置連線到此伺服器。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.local': '僅本機',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.localHint': '供同一台電腦上的應用程式使用。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lan': '僅家用網路',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': '透過 Wi-Fi 直接連線。離開此網路後無法使用。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relay': '任何地方',
|
||||
'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': '在家與外出都可用。外出時流量經由 OpenChamber Private Relay(端對端加密隧道)傳輸,無需設定。',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.relay': '外出時也允許透過加密中繼連線',
|
||||
'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': '在家時優先使用直接連線',
|
||||
'settings.remoteInstances.clientAuth.addDevice.create': '建立 QR 代碼',
|
||||
'settings.remoteInstances.clientAuth.addDevice.done': '完成',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': '配對 URL',
|
||||
'settings.remoteInstances.clientAuth.createdToken': '已建立 token',
|
||||
'settings.remoteInstances.clientAuth.state.loading': '正在載入用戶端 token...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': '尚無用戶端 token。',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': '已撤銷',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': '此裝置',
|
||||
'settings.remoteInstances.clientAuth.state.pending': '等待連線…',
|
||||
'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay',
|
||||
'settings.remoteInstances.clientAuth.state.connectedDirect': '已連線 · 區域網路',
|
||||
'settings.remoteInstances.clientAuth.state.connectedRelay': '已連線 · 中繼',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '上次使用:{date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '從未使用',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.autoHint': '透過中繼配對裝置時自動開啟。',
|
||||
'settings.remoteInstances.relay.description': '無需開放連接埠,即可讓你的其他裝置從任何地方連線。流量端對端加密,中繼無法讀取內容。',
|
||||
'settings.remoteInstances.relay.enableHint': '在此伺服器上啟用中繼之前,不會共享任何內容。',
|
||||
'settings.remoteInstances.relay.actions.enable': '啟用中繼',
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// openchamber_relay_gate
|
||||
//
|
||||
// Feature gate for the private-relay UI — the surfaces for enabling the relay and
|
||||
// pairing devices through it (Settings → Remote Instances "Relay" section and its
|
||||
// settings-search entry). The relay transport itself is fully implemented and
|
||||
// tested; this flag only hides the UI entry points until the feature is ready for
|
||||
// public release (the connect flow is being unified across LAN / tunnels / relay).
|
||||
//
|
||||
// TO UNBLOCK FOR PUBLIC RELEASE: set RELAY_UI_ENABLED to true. Grep this token —
|
||||
// `openchamber_relay_gate` — to find this file. Nothing else needs to change; the
|
||||
// gated surfaces read this one constant. Also add a CHANGELOG entry then — the
|
||||
// relay's changelog note is intentionally held back while this is off.
|
||||
//
|
||||
// Note: existing saved relay connections keep working regardless (this gates the
|
||||
// UI for ADDING/pairing, not the runtime transport). If you also want to hide the
|
||||
// mobile side of importing a relay link, gate the relay branch in
|
||||
// packages/ui/src/apps/mobileQrScan.ts / mobileConnections.ts on this same flag.
|
||||
// Typed as boolean (not the literal `false`) so gated call sites don't trip
|
||||
// "condition always false" / unreachable-code checks — flipping to true is a
|
||||
// one-word change with no other edits.
|
||||
export const RELAY_UI_ENABLED: boolean = false;
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildRelayOfferUrl, parseRelayOfferUrl, redactOffer } from './offer';
|
||||
import type { RelayOfferV1 } from './protocol';
|
||||
|
||||
const baseOffer: RelayOfferV1 = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: 'wss://relay.example.com/host',
|
||||
serverId: 'srv_0123456789abcdef',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x-coordinate-b64u', y: 'y-coordinate-b64u' },
|
||||
};
|
||||
|
||||
const fullOffer: RelayOfferV1 = {
|
||||
...baseOffer,
|
||||
label: 'My Mac',
|
||||
token: 'oc_client_secret_token_value',
|
||||
grant: 'grant-value',
|
||||
};
|
||||
|
||||
describe('buildRelayOfferUrl / parseRelayOfferUrl', () => {
|
||||
test('round-trips a minimal offer', () => {
|
||||
expect(parseRelayOfferUrl(buildRelayOfferUrl(baseOffer))).toEqual(baseOffer);
|
||||
});
|
||||
|
||||
test('round-trips a full offer with optional fields', () => {
|
||||
expect(parseRelayOfferUrl(buildRelayOfferUrl(fullOffer))).toEqual(fullOffer);
|
||||
});
|
||||
|
||||
test('URL has the expected shape', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
expect(url.startsWith('openchamber://connect?v=1&mode=relay#offer=')).toBe(true);
|
||||
});
|
||||
|
||||
test('token appears only in the fragment, never in the query string', () => {
|
||||
const url = buildRelayOfferUrl(fullOffer);
|
||||
const [beforeFragment, fragment] = url.split('#');
|
||||
expect(beforeFragment).toBe('openchamber://connect?v=1&mode=relay');
|
||||
expect(beforeFragment.includes(fullOffer.token as string)).toBe(false);
|
||||
expect(fragment.startsWith('offer=')).toBe(true);
|
||||
// Token round-trips through the fragment payload.
|
||||
expect(parseRelayOfferUrl(url)?.token).toBe(fullOffer.token as string);
|
||||
});
|
||||
|
||||
const encodeOffer = (value: unknown): string => {
|
||||
const json = JSON.stringify(value);
|
||||
const b64 = Buffer.from(json, 'utf8').toString('base64url');
|
||||
return `openchamber://connect?v=1&mode=relay#offer=${b64}`;
|
||||
};
|
||||
|
||||
test('rejects wrong scheme, host, version, and mode', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
expect(parseRelayOfferUrl(url.replace('openchamber://', 'https://'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('//connect', '//pair'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('v=1', 'v=2'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('mode=relay', 'mode=lan'))).toBeNull();
|
||||
expect(parseRelayOfferUrl('not a url')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=!!not-b64url!!')).toBeNull();
|
||||
});
|
||||
|
||||
const without = (key: keyof RelayOfferV1): Record<string, unknown> => {
|
||||
const clone: Record<string, unknown> = { ...fullOffer };
|
||||
delete clone[key];
|
||||
return clone;
|
||||
};
|
||||
|
||||
test('rejects wholly when any required field is missing or malformed', () => {
|
||||
const cases: unknown[] = [
|
||||
{ ...fullOffer, v: 2 },
|
||||
without('v'),
|
||||
{ ...fullOffer, mode: 'direct' },
|
||||
without('mode'),
|
||||
without('relayUrl'),
|
||||
{ ...fullOffer, relayUrl: '' },
|
||||
{ ...fullOffer, relayUrl: 'not-a-url' },
|
||||
{ ...fullOffer, relayUrl: 'ftp://relay.example.com' },
|
||||
without('serverId'),
|
||||
{ ...fullOffer, serverId: '' },
|
||||
{ ...fullOffer, serverId: 42 },
|
||||
without('hostEncPubJwk'),
|
||||
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, kty: 'RSA' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, crv: 'P-384' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', y: 'y' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x' } },
|
||||
{ ...fullOffer, hostEncPubJwk: 'jwk' },
|
||||
{ ...fullOffer, label: '' },
|
||||
{ ...fullOffer, token: '' },
|
||||
{ ...fullOffer, token: 123 },
|
||||
{ ...fullOffer, grant: '' },
|
||||
['array'],
|
||||
];
|
||||
for (const payload of cases) {
|
||||
expect(parseRelayOfferUrl(encodeOffer(payload))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('parse strips unknown fields', () => {
|
||||
const parsed = parseRelayOfferUrl(encodeOffer({ ...baseOffer, extra: 'field' }));
|
||||
expect(parsed).toEqual(baseOffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactOffer', () => {
|
||||
test('masks token, grant, and host public key coordinates', () => {
|
||||
const redacted = redactOffer(fullOffer);
|
||||
expect(redacted.token).toBe('[redacted]');
|
||||
expect(redacted.grant).toBe('[redacted]');
|
||||
expect(redacted.hostEncPubJwk.x).toBe('[redacted]');
|
||||
expect(redacted.hostEncPubJwk.y).toBe('[redacted]');
|
||||
const serialized = JSON.stringify(redacted);
|
||||
expect(serialized.includes(fullOffer.token as string)).toBe(false);
|
||||
expect(serialized.includes(baseOffer.hostEncPubJwk.x as string)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps non-secret fields and omits absent optionals', () => {
|
||||
const redacted = redactOffer(baseOffer);
|
||||
expect(redacted.relayUrl).toBe(baseOffer.relayUrl);
|
||||
expect(redacted.serverId).toBe(baseOffer.serverId);
|
||||
expect('token' in redacted).toBe(false);
|
||||
expect('grant' in redacted).toBe(false);
|
||||
});
|
||||
|
||||
test('does not mutate the input offer', () => {
|
||||
const copy = structuredClone(fullOffer);
|
||||
redactOffer(fullOffer);
|
||||
expect(fullOffer).toEqual(copy);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
// Relay pairing offer URL codec (spec §Pairing payload).
|
||||
// The offer JSON travels ONLY in the URL fragment so secrets (token) never
|
||||
// reach servers, logs, or referrer headers via the query string.
|
||||
// Shared by: settings UI (build), mobile scan (parse), desktop host import
|
||||
// (parse), CLI (build).
|
||||
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './crypto';
|
||||
import type { RelayOfferV1 } from './protocol';
|
||||
|
||||
const OFFER_SCHEME = 'openchamber:';
|
||||
const OFFER_HOST = 'connect';
|
||||
const OFFER_FRAGMENT_KEY = 'offer=';
|
||||
|
||||
const REDACTED = '[redacted]';
|
||||
|
||||
export const buildRelayOfferUrl = (offer: RelayOfferV1): string => {
|
||||
const json = JSON.stringify(offer);
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(json));
|
||||
return `openchamber://connect?v=1&mode=relay#${OFFER_FRAGMENT_KEY}${encoded}`;
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0;
|
||||
|
||||
const isValidHttpOrWsUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'wss:' || parsed.protocol === 'ws:' || parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const parsePublicKeyJwk = (value: unknown): JsonWebKey | null => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
const jwk = value as Record<string, unknown>;
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null;
|
||||
if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null;
|
||||
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
// Strict parse: every required field is validated; any malformed or missing
|
||||
// field rejects the whole offer (returns null, never a partial object).
|
||||
export const parseRelayOfferUrl = (url: string): RelayOfferV1 | null => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== OFFER_SCHEME) return null;
|
||||
// Custom-scheme URLs may surface the authority as hostname or pathname
|
||||
// depending on the runtime's parser.
|
||||
const authority = parsed.hostname || parsed.pathname.replace(/^\/*/, '').split(/[/?#]/)[0];
|
||||
if (authority !== OFFER_HOST) return null;
|
||||
if (parsed.searchParams.get('v') !== '1') return null;
|
||||
if (parsed.searchParams.get('mode') !== 'relay') return null;
|
||||
|
||||
const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
|
||||
if (!fragment.startsWith(OFFER_FRAGMENT_KEY)) return null;
|
||||
const encoded = fragment.slice(OFFER_FRAGMENT_KEY.length);
|
||||
if (!encoded) return null;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(encoded)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
|
||||
if (candidate.v !== 1) return null;
|
||||
if (candidate.mode !== 'relay') return null;
|
||||
if (!isNonEmptyString(candidate.relayUrl) || !isValidHttpOrWsUrl(candidate.relayUrl)) return null;
|
||||
if (!isNonEmptyString(candidate.serverId)) return null;
|
||||
const hostEncPubJwk = parsePublicKeyJwk(candidate.hostEncPubJwk);
|
||||
if (!hostEncPubJwk) return null;
|
||||
if (candidate.label !== undefined && !isNonEmptyString(candidate.label)) return null;
|
||||
if (candidate.token !== undefined && !isNonEmptyString(candidate.token)) return null;
|
||||
if (candidate.grant !== undefined && !isNonEmptyString(candidate.grant)) return null;
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: candidate.relayUrl,
|
||||
serverId: candidate.serverId,
|
||||
hostEncPubJwk,
|
||||
...(candidate.label !== undefined ? { label: candidate.label } : {}),
|
||||
...(candidate.token !== undefined ? { token: candidate.token } : {}),
|
||||
...(candidate.grant !== undefined ? { grant: candidate.grant } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// Safe-for-logging copy: masks the access token and the host public key
|
||||
// coordinates. Never log a raw offer.
|
||||
export const redactOffer = (offer: RelayOfferV1): RelayOfferV1 => ({
|
||||
...offer,
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: REDACTED, y: REDACTED },
|
||||
...(offer.token !== undefined ? { token: REDACTED } : {}),
|
||||
...(offer.grant !== undefined ? { grant: REDACTED } : {}),
|
||||
});
|
||||
@@ -126,14 +126,3 @@ export const RelayCloseCode = {
|
||||
ChannelFailure: 1011,
|
||||
} as const;
|
||||
|
||||
// Pairing payload carried in QR / deep-link URL fragments only.
|
||||
export interface RelayOfferV1 {
|
||||
v: 1;
|
||||
mode: 'relay';
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
label?: string;
|
||||
token?: string;
|
||||
grant?: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata';
|
||||
import { getSettingsPageMeta } from './metadata';
|
||||
import { RELAY_UI_ENABLED } from '@/lib/relay/gate';
|
||||
|
||||
interface SettingsSearchItem {
|
||||
id: string;
|
||||
@@ -430,18 +429,9 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
page: 'remote-instances',
|
||||
titleKey: 'settings.remoteInstances.clientAuth.title',
|
||||
descriptionKey: 'settings.remoteInstances.clientAuth.description',
|
||||
keywords: ['pairing link', 'client token', 'connect desktop', 'remote access'],
|
||||
keywords: ['pairing link', 'client token', 'connect desktop', 'remote access', 'relay', 'devices', 'connect from anywhere'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'remote-instances.relay',
|
||||
page: 'remote-instances',
|
||||
titleKey: 'settings.remoteInstances.relay.title',
|
||||
descriptionKey: 'settings.remoteInstances.relay.description',
|
||||
keywords: ['relay', 'pairing', 'no ports', 'end-to-end encrypted', 'remote access', 'connect from anywhere'],
|
||||
// Gated by openchamber_relay_gate until the relay UI ships publicly.
|
||||
isAvailable: (ctx) => !ctx.isVSCode && RELAY_UI_ENABLED,
|
||||
},
|
||||
{
|
||||
id: 'remote-instances.direct-hosts',
|
||||
page: 'remote-instances',
|
||||
|
||||
Reference in New Issue
Block a user