diff --git a/app/src/components/settings/panels/CoreConnectionPanel.tsx b/app/src/components/settings/panels/CoreConnectionPanel.tsx new file mode 100644 index 0000000000..03eebf348a --- /dev/null +++ b/app/src/components/settings/panels/CoreConnectionPanel.tsx @@ -0,0 +1,443 @@ +/** + * CoreConnectionPanel — Settings → Core connection. + * + * Promotes the pre-existing "cloud" core mode (a persisted remote-core RPC + * URL + bearer token, previously reachable only from the pre-router + * BootCheckGate picker) into a first-class, in-app setting, and adds a live + * connect/failure status indicator. + * + * This deliberately reuses the existing cloud-mode plumbing — the `coreMode` + * Redux slice, `configPersistence` storage keys, and + * `testCoreRpcConnection` — rather than introducing a second remote-core + * mechanism. The shell-level env-var attach path (`OPENHUMAN_CORE_REUSE_EXISTING`, + * `OPENHUMAN_CORE_TOKEN`) is intentionally left as a documented dev-only + * override and is not surfaced here (GH-4396). + * + * Boot-gate hard-fail/fallback semantics are unchanged: switching mode here + * persists the choice and restarts the app so the normal BootCheckGate flow + * re-runs against the new mode. This panel only *surfaces* connection state; + * it does not change what happens when a configured core is unreachable. + */ +import { invoke } from '@tauri-apps/api/core'; +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + clearCoreRpcTokenCache, + clearCoreRpcUrlCache, + testCoreRpcConnection, +} from '../../../services/coreRpcClient'; +import { type CoreMode, setCoreMode } from '../../../store/coreModeSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { CORE_RPC_URL } from '../../../utils/config'; +import { + clearStoredCoreToken, + isLocalOrPrivateNetworkHost, + isTauriEnvironment, + normalizeRpcUrl, + storeCoreMode, + storeCoreToken, + storeRpcUrl, +} from '../../../utils/configPersistence'; +import { restartApp } from '../../../utils/tauriCommands/core'; +import Button from '../../ui/Button'; +import { SettingsRow, SettingsSection, SettingsSwitch, SettingsTextField } from '../controls'; +import SettingsPanel from '../layout/SettingsPanel'; + +const log = debug('settings:core'); + +/** Live reachability of the currently-active core. */ +type LiveStatus = + | { kind: 'checking' } + | { kind: 'connected' } + | { kind: 'authFailed' } + | { kind: 'unreachable'; reason: string }; + +/** Result of a one-shot "Test connection" against the typed remote inputs. */ +type TestStatus = + | { kind: 'idle' } + | { kind: 'testing' } + | { kind: 'ok' } + | { kind: 'auth' } + | { kind: 'unreachable'; reason: string }; + +/** + * Resolve the URL the active core is actually reachable at. Cloud mode stores + * the user's chosen URL in Redux; local mode picks a dynamic port at launch, + * so the authoritative value lives in the Tauri shell (`core_rpc_url`). + */ +async function resolveActiveCoreUrl(coreMode: CoreMode): Promise { + if (coreMode.kind === 'cloud') return coreMode.url; + if (!isTauriEnvironment()) return CORE_RPC_URL; + try { + return await invoke('core_rpc_url'); + } catch (err) { + log('resolveActiveCoreUrl: core_rpc_url invoke failed: %o', err); + return null; + } +} + +const CoreConnectionPanel = () => { + const { t } = useT(); + const dispatch = useAppDispatch(); + const coreMode = useAppSelector(state => state.coreMode.mode); + + // ── Editable form state ──────────────────────────────────────────────── + // Seeded from the persisted cloud-mode config so the panel reflects the + // current setting on open. + const [useRemote, setUseRemote] = useState(coreMode.kind === 'cloud'); + const [url, setUrl] = useState(coreMode.kind === 'cloud' ? coreMode.url : ''); + const [token, setToken] = useState(coreMode.kind === 'cloud' ? (coreMode.token ?? '') : ''); + const [formError, setFormError] = useState(null); + const [testStatus, setTestStatus] = useState({ kind: 'idle' }); + const [saving, setSaving] = useState(false); + + // ── Live status indicator (against the currently-active core) ─────────── + const [liveStatus, setLiveStatus] = useState({ kind: 'checking' }); + const [activeUrl, setActiveUrl] = useState(null); + const checkSeq = useRef(0); + + const runLiveCheck = useCallback(async () => { + const seq = ++checkSeq.current; + setLiveStatus({ kind: 'checking' }); + log('runLiveCheck: mode=%s', coreMode.kind); + const resolved = await resolveActiveCoreUrl(coreMode); + if (seq !== checkSeq.current) return; // superseded by a newer check + setActiveUrl(resolved); + if (!resolved) { + setLiveStatus({ kind: 'unreachable', reason: t('settings.about.serverUrlUnavailable') }); + return; + } + try { + const response = await testCoreRpcConnection(resolved); + if (seq !== checkSeq.current) return; + if (response.status === 401 || response.status === 403) { + log('runLiveCheck: auth failed (status=%d)', response.status); + setLiveStatus({ kind: 'authFailed' }); + return; + } + if (!response.ok) { + log('runLiveCheck: HTTP %d', response.status); + setLiveStatus({ kind: 'unreachable', reason: `HTTP ${response.status}` }); + return; + } + // Drain the body so the connection can be reused; a JSON-RPC error body + // on a 200 does not disprove reachability. + try { + await response.json(); + } catch { + /* non-JSON body is unusual but still reachable */ + } + log('runLiveCheck: connected'); + setLiveStatus({ kind: 'connected' }); + } catch (err) { + if (seq !== checkSeq.current) return; + const reason = err instanceof Error ? err.message : 'Connection failed'; + log('runLiveCheck: errored: %o', err); + setLiveStatus({ kind: 'unreachable', reason }); + } + }, [coreMode, t]); + + useEffect(() => { + // runLiveCheck flips the status to `checking` synchronously; that is the + // intended entry transition for the live probe (also used by Recheck), not + // a cascading render. + // eslint-disable-next-line react-hooks/set-state-in-effect + void runLiveCheck(); + }, [runLiveCheck]); + + // ── Validation (mirrors the BootCheckGate cloud picker) ───────────────── + const validate = (): { url: string; token: string } | null => { + const rawUrl = url.trim(); + if (!rawUrl) { + setFormError(t('bootCheck.invalidUrl')); + return null; + } + const normalized = normalizeRpcUrl(rawUrl); + try { + const parsed = new URL(normalized); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + setFormError(t('bootCheck.urlMustStartWith')); + return null; + } + } catch { + setFormError(t('bootCheck.validUrlRequired')); + return null; + } + const trimmedToken = token.trim(); + if (!trimmedToken) { + setFormError(t('bootCheck.tokenRequired')); + return null; + } + setFormError(null); + return { url: normalized, token: trimmedToken }; + }; + + const httpWarning = (() => { + if (!useRemote) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(normalizeRpcUrl(trimmed)); + if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) { + return t('bootCheck.httpPublicWarning'); + } + } catch { + /* validate() surfaces parse errors on save */ + } + return null; + })(); + + const handleTest = async () => { + const validated = validate(); + if (!validated) return; + setTestStatus({ kind: 'testing' }); + log('handleTest: url=%s tokenLen=%d', validated.url, validated.token.length); + try { + const response = await testCoreRpcConnection(validated.url, validated.token); + if (response.status === 401 || response.status === 403) { + setTestStatus({ kind: 'auth' }); + return; + } + if (!response.ok) { + setTestStatus({ kind: 'unreachable', reason: `HTTP ${response.status}` }); + return; + } + try { + await response.json(); + } catch { + /* reachable regardless of body shape */ + } + setTestStatus({ kind: 'ok' }); + } catch (err) { + const reason = err instanceof Error ? err.message : 'Connection failed'; + setTestStatus({ kind: 'unreachable', reason }); + } + }; + + // ── Dirty detection ───────────────────────────────────────────────────── + // Enable Save only when the desired mode differs from the persisted one. + const isDirty = (() => { + if (!useRemote) return coreMode.kind !== 'local'; + if (coreMode.kind !== 'cloud') return true; + return ( + normalizeRpcUrl(url.trim() || '') !== coreMode.url || token.trim() !== (coreMode.token ?? '') + ); + })(); + + const handleSave = async () => { + if (saving) return; + if (useRemote) { + const validated = validate(); + if (!validated) return; + log( + 'handleSave: switching to remote core url=%s tokenLen=%d', + validated.url, + validated.token.length + ); + setSaving(true); + // NOTE: the bearer is persisted in plain localStorage via storeCoreToken, + // matching the existing cloud-mode picker. A renderer XSS could read it + // (security audit U3). Migrating this to the OS keychain is a known + // follow-up tracked with the rest of cloud-mode token storage; this panel + // intentionally does not block on it (GH-4396 scope decision). + storeRpcUrl(validated.url); + storeCoreToken(validated.token); + storeCoreMode('cloud'); + clearCoreRpcUrlCache(); + clearCoreRpcTokenCache(); + dispatch(setCoreMode({ kind: 'cloud', url: validated.url, token: validated.token })); + } else { + log('handleSave: switching to local core'); + setSaving(true); + storeRpcUrl(''); + clearStoredCoreToken(); + storeCoreMode('local'); + clearCoreRpcUrlCache(); + clearCoreRpcTokenCache(); + dispatch(setCoreMode({ kind: 'local' })); + } + // Restart so BootCheckGate re-runs against the new mode (unchanged + // boot-gate semantics). In dev this is a renderer reload. + await restartApp(); + }; + + // ── Live status rendering ─────────────────────────────────────────────── + const statusText = (() => { + switch (liveStatus.kind) { + case 'checking': + return t('settings.core.statusChecking'); + case 'connected': + return coreMode.kind === 'cloud' + ? t('settings.core.statusConnectedRemote') + : t('settings.core.statusConnectedLocal'); + case 'authFailed': + return t('settings.core.statusAuthFailed'); + case 'unreachable': + return `${t('settings.core.statusUnreachable')} — ${liveStatus.reason}`; + } + })(); + + const statusDotClass = (() => { + switch (liveStatus.kind) { + case 'connected': + return 'bg-sage-500'; + case 'checking': + return 'bg-amber-400 animate-pulse'; + default: + return 'bg-coral-500'; + } + })(); + + return ( + + {/* Live status indicator */} + + + + + {/* Remote-core toggle + config */} + + { + setUseRemote(next); + setTestStatus({ kind: 'idle' }); + setFormError(null); + }} + aria-label={t('settings.core.useRemoteToggle')} + data-testid="core-use-remote-toggle" + /> + } + /> + + {useRemote && ( +
+
+ + { + setUrl(e.target.value); + setFormError(null); + setTestStatus({ kind: 'idle' }); + }} + /> + {httpWarning && ( +

{httpWarning}

+ )} +
+ +
+ + { + setToken(e.target.value); + setFormError(null); + setTestStatus({ kind: 'idle' }); + }} + /> +

+ {t('bootCheck.storedLocally')} Authorization: Bearer …{' '} + {t('bootCheck.rpcAuthSuffix')} +

+
+ +
+ + {testStatus.kind === 'ok' && ( + + {t('bootCheck.connectedOk')} + + )} + {testStatus.kind === 'auth' && ( + + {t('bootCheck.authFailed')} + + )} + {testStatus.kind === 'unreachable' && ( + + {t('bootCheck.unreachablePrefix')} {testStatus.reason} + + )} +
+
+ )} + + {formError &&

{formError}

} + +
+

+ {t('settings.core.applyRestartNote')} +

+ +
+
+
+ ); +}; + +export default CoreConnectionPanel; diff --git a/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx b/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx new file mode 100644 index 0000000000..ec9aa66734 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx @@ -0,0 +1,230 @@ +/** + * Tests for CoreConnectionPanel (GH-4396) — the first-class Settings surface + * that promotes cloud-mode remote-core config and adds a live status + * indicator. Covers: live status rendering per mode, the remote toggle + * revealing the URL/token form, and the save flow persisting + dispatching + + * restarting. + */ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; + +const hoisted = vi.hoisted(() => ({ + testCoreRpcConnection: vi.fn(), + clearCoreRpcUrlCache: vi.fn(), + clearCoreRpcTokenCache: vi.fn(), + restartApp: vi.fn(), +})); + +vi.mock('../../../../services/coreRpcClient', () => ({ + testCoreRpcConnection: hoisted.testCoreRpcConnection, + clearCoreRpcUrlCache: hoisted.clearCoreRpcUrlCache, + clearCoreRpcTokenCache: hoisted.clearCoreRpcTokenCache, +})); + +vi.mock('../../../../utils/tauriCommands/core', () => ({ restartApp: hoisted.restartApp })); + +function okResponse() { + return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }) }; +} + +/** A Response-shaped stub for an arbitrary HTTP status. */ +function statusResponse(status: number) { + return { ok: status >= 200 && status < 300, status, json: async () => ({}) }; +} + +const CLOUD_STATE = { + coreMode: { mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' } }, +}; + +async function importPanel() { + const mod = await import('../CoreConnectionPanel'); + return mod.default; +} + +describe('CoreConnectionPanel', () => { + beforeEach(() => { + vi.resetModules(); + hoisted.testCoreRpcConnection.mockReset(); + hoisted.clearCoreRpcUrlCache.mockReset(); + hoisted.clearCoreRpcTokenCache.mockReset(); + hoisted.restartApp.mockReset(); + hoisted.restartApp.mockResolvedValue(undefined); + localStorage.clear(); + }); + + test('local mode shows the local connected status once the live check passes', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); + + await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument()); + // Remote toggle is off in local mode → no URL field. + expect(screen.queryByLabelText(/Runtime URL/i)).not.toBeInTheDocument(); + }); + + test('cloud mode surfaces the remote URL and remote connected status', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { + preloadedState: { + coreMode: { + mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' }, + }, + }, + }); + + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); + // Toggle on → the URL field is pre-filled with the persisted value. + expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument(); + }); + + test('unreachable core surfaces the failure status', async () => { + hoisted.testCoreRpcConnection.mockRejectedValue(new Error('boom')); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); + + await waitFor(() => expect(screen.getByText(/Cannot reach the core/i)).toBeInTheDocument()); + }); + + test('switching to remote core persists, dispatches, and restarts', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + const { store } = renderWithProviders(, { + preloadedState: { coreMode: { mode: { kind: 'local' } } }, + }); + + await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument()); + + // Flip the remote toggle on to reveal the form. + fireEvent.click(screen.getByTestId('core-use-remote-toggle')); + + fireEvent.change(screen.getByLabelText(/Runtime URL/i), { + target: { value: 'https://core.example.com/rpc' }, + }); + fireEvent.change(screen.getByLabelText(/Auth Token/i), { + target: { value: 'remote-token-xyz' }, + }); + + fireEvent.click(screen.getByTestId('core-save-btn')); + + await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1)); + + // Redux is now in cloud mode with the typed URL + token. + const mode = store.getState().coreMode.mode as { kind: string; url?: string; token?: string }; + expect(mode.kind).toBe('cloud'); + expect(mode.url).toBe('https://core.example.com/rpc'); + expect(mode.token).toBe('remote-token-xyz'); + + // Persisted synchronously to localStorage (mirrors the cloud-mode picker). + expect(localStorage.getItem('openhuman_core_mode')).toBe('cloud'); + expect(localStorage.getItem('openhuman_core_rpc_url')).toBe('https://core.example.com/rpc'); + expect(localStorage.getItem('openhuman_core_rpc_token')).toBe('remote-token-xyz'); + + // Caches cleared so the new endpoint takes effect on restart. + expect(hoisted.clearCoreRpcUrlCache).toHaveBeenCalled(); + expect(hoisted.clearCoreRpcTokenCache).toHaveBeenCalled(); + }); + + test('a rejected token surfaces the token-rejected live status', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(401)); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + await waitFor(() => expect(screen.getByText(/the token was rejected/i)).toBeInTheDocument()); + }); + + test('a non-ok response surfaces the unreachable live status with the HTTP code', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(503)); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + await waitFor(() => + expect(screen.getByText(/Cannot reach the core — HTTP 503/i)).toBeInTheDocument() + ); + }); + + test('Test connection reports success for the typed remote inputs', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + // Wait for the mount live-check to settle first. + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByTestId('core-test-ok')).toBeInTheDocument()); + }); + + test('Test connection reports an auth failure', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(403)); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByTestId('core-test-auth')).toBeInTheDocument()); + }); + + test('Test connection reports an unreachable endpoint', async () => { + hoisted.testCoreRpcConnection.mockRejectedValue(new Error('network down')); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByTestId('core-test-unreachable')).toBeInTheDocument()); + }); + + test('validation blocks the form when the URL is empty and when the token is missing', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); + + await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument()); + // Reveal the empty remote form. + fireEvent.click(screen.getByTestId('core-use-remote-toggle')); + + // Empty URL → invalid-URL error. + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByText(/enter a runtime URL/i)).toBeInTheDocument()); + + // Valid URL but empty token → token-required error. + fireEvent.change(screen.getByLabelText(/Runtime URL/i), { + target: { value: 'https://core.example.com/rpc' }, + }); + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => + expect(screen.getByText(/need an auth token to connect/i)).toBeInTheDocument() + ); + + // The typed connection was never attempted (validation short-circuits). + expect(hoisted.testCoreRpcConnection).not.toHaveBeenCalledWith( + 'https://core.example.com/rpc', + '' + ); + }); + + test('switching from remote back to local clears persistence, dispatches, and restarts', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + // Seed persisted cloud values so we can assert they are cleared. + localStorage.setItem('openhuman_core_mode', 'cloud'); + localStorage.setItem('openhuman_core_rpc_url', 'https://core.example.com/rpc'); + localStorage.setItem('openhuman_core_rpc_token', 'tok-123456'); + + const Panel = await importPanel(); + const { store } = renderWithProviders(, { preloadedState: CLOUD_STATE }); + + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); + + // Flip remote off → local, then save. + fireEvent.click(screen.getByTestId('core-use-remote-toggle')); + fireEvent.click(screen.getByTestId('core-save-btn')); + + await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1)); + + expect(store.getState().coreMode.mode.kind).toBe('local'); + expect(localStorage.getItem('openhuman_core_mode')).toBe('local'); + expect(localStorage.getItem('openhuman_core_rpc_url')).toBeNull(); + expect(localStorage.getItem('openhuman_core_rpc_token')).toBeNull(); + }); +}); diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx index 641bb6a514..e1425d6a27 100644 --- a/app/src/components/settings/settingsRouteElements.tsx +++ b/app/src/components/settings/settingsRouteElements.tsx @@ -18,6 +18,7 @@ import AutocompletePanel from './panels/AutocompletePanel'; import BillingPanel from './panels/BillingPanel'; import CompanionPanel from './panels/CompanionPanel'; import ComposioTriagePanel from './panels/ComposioTriagePanel'; +import CoreConnectionPanel from './panels/CoreConnectionPanel'; import CronJobsPanel from './panels/CronJobsPanel'; import DesktopAgentPanel from './panels/DesktopAgentPanel'; import DeveloperOptionsPanel from './panels/DeveloperOptionsPanel'; @@ -154,6 +155,9 @@ export function settingsRouteElements(): ReactNode { )} /> {/* ── System ──────────────────────────────────────────────── */} + {/* Core connection — promotes cloud-mode remote-core config into a + first-class setting with a live status indicator (GH-4396). */} + )} /> )} /> )} /> )} /> diff --git a/app/src/components/settings/settingsRouteRegistry.ts b/app/src/components/settings/settingsRouteRegistry.ts index c3e5e47930..ba8efd13b0 100644 --- a/app/src/components/settings/settingsRouteRegistry.ts +++ b/app/src/components/settings/settingsRouteRegistry.ts @@ -286,6 +286,30 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ navGroup: 'general', navOrder: 98, }, + { + // Core connection — promotes cloud-mode remote-core config (persisted + // RPC URL + token) into a first-class setting plus a live status + // indicator (GH-4396). Sits just above About in General. + id: 'core', + titleKey: 'settings.core.title', + descriptionKey: 'settings.core.menuDesc', + section: 'home', + searchKeywords: [ + 'core', + 'remote', + 'rpc', + 'url', + 'token', + 'cloud', + 'local', + 'connection', + 'server', + 'attach', + 'self-hosted', + ], + navGroup: 'general', + navOrder: 97, + }, { id: 'about', titleKey: 'settings.about', diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 22ca4d8c42..4ece7240a7 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // Arabic (العربية) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'اتصال النواة', + 'settings.core.menuDesc': 'استخدم النواة المحلية المدمجة أو اتصل بنواة بعيدة.', + 'settings.core.useRemoteToggle': 'استخدام نواة بعيدة', + 'settings.core.useRemoteToggleDesc': + 'الاتصال بنواة بعيدة عبر HTTP بدلاً من النواة المحلية المدمجة.', + 'settings.core.statusConnectedRemote': 'متصل بالنواة البعيدة', + 'settings.core.statusConnectedLocal': 'متصل بالنواة المحلية', + 'settings.core.statusChecking': 'جارٍ التحقق من الاتصال…', + 'settings.core.statusAuthFailed': 'يمكن الوصول إليه، لكن تم رفض الرمز', + 'settings.core.statusUnreachable': 'تعذّر الوصول إلى النواة', + 'settings.core.recheck': 'إعادة التحقق', + 'settings.core.save': 'حفظ وإعادة التشغيل', + 'settings.core.applyRestartNote': 'يؤدي الحفظ إلى إعادة تشغيل OpenHuman لإعادة الاتصال.', // Cross-host vault (#4278) 'crossHostVault.title': 'الخزنة موجودة على مضيف النواة.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 7265f80cab..84ba2a3d49 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3,6 +3,21 @@ import type { TranslationMap } from './types'; // Bengali (বাংলা) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'কোর সংযোগ', + 'settings.core.menuDesc': + 'অন্তর্নির্মিত স্থানীয় কোর ব্যবহার করুন বা একটি রিমোট কোরে সংযোগ করুন।', + 'settings.core.useRemoteToggle': 'রিমোট কোর ব্যবহার করুন', + 'settings.core.useRemoteToggleDesc': + 'অন্তর্নির্মিত স্থানীয় কোরের পরিবর্তে HTTP-এর মাধ্যমে একটি রিমোট কোরে সংযোগ করুন।', + 'settings.core.statusConnectedRemote': 'রিমোট কোরে সংযুক্ত', + 'settings.core.statusConnectedLocal': 'স্থানীয় কোরে সংযুক্ত', + 'settings.core.statusChecking': 'সংযোগ পরীক্ষা করা হচ্ছে…', + 'settings.core.statusAuthFailed': 'পৌঁছানো যায়, তবে টোকেন প্রত্যাখ্যাত হয়েছে', + 'settings.core.statusUnreachable': 'কোরে পৌঁছানো যাচ্ছে না', + 'settings.core.recheck': 'পুনরায় পরীক্ষা করুন', + 'settings.core.save': 'সংরক্ষণ করে পুনরায় চালু করুন', + 'settings.core.applyRestartNote': 'সংরক্ষণ করলে পুনরায় সংযোগের জন্য OpenHuman পুনরায় চালু হয়।', // Cross-host vault (#4278) 'crossHostVault.title': 'ভল্টটি কোর হোস্টে রয়েছে।', 'crossHostVault.message': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 7552c1c1ca..80d3f9ba5d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3,6 +3,22 @@ import type { TranslationMap } from './types'; // German (Deutsch) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Core-Verbindung', + 'settings.core.menuDesc': + 'Den integrierten lokalen Core verwenden oder mit einem Remote-Core verbinden.', + 'settings.core.useRemoteToggle': 'Remote-Core verwenden', + 'settings.core.useRemoteToggleDesc': + 'Über HTTP mit einem Remote-Core verbinden statt den integrierten lokalen Core zu nutzen.', + 'settings.core.statusConnectedRemote': 'Mit Remote-Core verbunden', + 'settings.core.statusConnectedLocal': 'Mit lokalem Core verbunden', + 'settings.core.statusChecking': 'Verbindung wird geprüft…', + 'settings.core.statusAuthFailed': 'Erreichbar, aber das Token wurde abgelehnt', + 'settings.core.statusUnreachable': 'Core nicht erreichbar', + 'settings.core.recheck': 'Erneut prüfen', + 'settings.core.save': 'Speichern & neu starten', + 'settings.core.applyRestartNote': + 'Beim Speichern startet OpenHuman neu, um die Verbindung herzustellen.', // Cross-host vault (#4278) 'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 82914a54af..aa6b6274a3 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1479,6 +1479,20 @@ const en: TranslationMap = { 'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.', 'settings.about.connectionHelperCloud': 'Connected to a remote core. Change this in BootCheck or the cloud mode picker.', + // Core connection panel (GH-4396) + 'settings.core.title': 'Core connection', + 'settings.core.menuDesc': 'Use the built-in local core or connect to a remote core.', + 'settings.core.useRemoteToggle': 'Use remote core', + 'settings.core.useRemoteToggleDesc': + 'Connect to a remote core over HTTP instead of the built-in local core.', + 'settings.core.statusConnectedRemote': 'Connected to remote core', + 'settings.core.statusConnectedLocal': 'Connected to local core', + 'settings.core.statusChecking': 'Checking connection…', + 'settings.core.statusAuthFailed': 'Reachable, but the token was rejected', + 'settings.core.statusUnreachable': 'Cannot reach the core', + 'settings.core.recheck': 'Recheck', + 'settings.core.save': 'Save & restart', + 'settings.core.applyRestartNote': 'Saving restarts OpenHuman to reconnect.', 'settings.heartbeat.title': 'Heartbeat & loops', 'settings.usage.title': 'Usage & Limits', 'settings.usage.menuDesc': 'Costs, token usage, budgets, and background activity', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index cf8bfc7c79..6379dd5cc9 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // Spanish (Español) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Conexión del core', + 'settings.core.menuDesc': 'Usa el core local integrado o conéctate a un core remoto.', + 'settings.core.useRemoteToggle': 'Usar core remoto', + 'settings.core.useRemoteToggleDesc': + 'Conéctate a un core remoto por HTTP en lugar del core local integrado.', + 'settings.core.statusConnectedRemote': 'Conectado al core remoto', + 'settings.core.statusConnectedLocal': 'Conectado al core local', + 'settings.core.statusChecking': 'Comprobando la conexión…', + 'settings.core.statusAuthFailed': 'Accesible, pero el token fue rechazado', + 'settings.core.statusUnreachable': 'No se puede conectar con el core', + 'settings.core.recheck': 'Volver a comprobar', + 'settings.core.save': 'Guardar y reiniciar', + 'settings.core.applyRestartNote': 'Al guardar, OpenHuman se reinicia para volver a conectarse.', // Cross-host vault (#4278) 'crossHostVault.title': 'El vault está en el host del core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4292d98952..b26081d2a9 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // French (Français) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Connexion au core', + 'settings.core.menuDesc': 'Utilisez le core local intégré ou connectez-vous à un core distant.', + 'settings.core.useRemoteToggle': 'Utiliser un core distant', + 'settings.core.useRemoteToggleDesc': + 'Se connecter à un core distant via HTTP au lieu du core local intégré.', + 'settings.core.statusConnectedRemote': 'Connecté au core distant', + 'settings.core.statusConnectedLocal': 'Connecté au core local', + 'settings.core.statusChecking': 'Vérification de la connexion…', + 'settings.core.statusAuthFailed': 'Accessible, mais le jeton a été refusé', + 'settings.core.statusUnreachable': 'Core inaccessible', + 'settings.core.recheck': 'Revérifier', + 'settings.core.save': 'Enregistrer et redémarrer', + 'settings.core.applyRestartNote': 'Enregistrer redémarre OpenHuman pour se reconnecter.', // Cross-host vault (#4278) 'crossHostVault.title': "Le coffre se trouve sur l'hôte du cœur.", 'crossHostVault.message': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 3243bd11bc..d1c00eb82c 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3,6 +3,22 @@ import type { TranslationMap } from './types'; // Hindi (हिन्दी) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'कोर कनेक्शन', + 'settings.core.menuDesc': + 'अंतर्निहित स्थानीय कोर का उपयोग करें या किसी रिमोट कोर से कनेक्ट करें।', + 'settings.core.useRemoteToggle': 'रिमोट कोर का उपयोग करें', + 'settings.core.useRemoteToggleDesc': + 'अंतर्निहित स्थानीय कोर के बजाय HTTP के माध्यम से किसी रिमोट कोर से कनेक्ट करें।', + 'settings.core.statusConnectedRemote': 'रिमोट कोर से कनेक्ट हो गया', + 'settings.core.statusConnectedLocal': 'स्थानीय कोर से कनेक्ट हो गया', + 'settings.core.statusChecking': 'कनेक्शन जाँचा जा रहा है…', + 'settings.core.statusAuthFailed': 'पहुँच योग्य, लेकिन टोकन अस्वीकृत हो गया', + 'settings.core.statusUnreachable': 'कोर तक नहीं पहुँचा जा सकता', + 'settings.core.recheck': 'फिर से जाँचें', + 'settings.core.save': 'सहेजें और पुनरारंभ करें', + 'settings.core.applyRestartNote': + 'सहेजने पर OpenHuman फिर से कनेक्ट होने के लिए पुनरारंभ होता है।', // Cross-host vault (#4278) 'crossHostVault.title': 'वॉल्ट कोर होस्ट पर है।', 'crossHostVault.message': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 95e225fbd0..d6c9b59b20 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3,6 +3,21 @@ import type { TranslationMap } from './types'; // Indonesian (Bahasa Indonesia) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Koneksi core', + 'settings.core.menuDesc': 'Gunakan core lokal bawaan atau hubungkan ke core jarak jauh.', + 'settings.core.useRemoteToggle': 'Gunakan core jarak jauh', + 'settings.core.useRemoteToggleDesc': + 'Hubungkan ke core jarak jauh melalui HTTP alih-alih core lokal bawaan.', + 'settings.core.statusConnectedRemote': 'Terhubung ke core jarak jauh', + 'settings.core.statusConnectedLocal': 'Terhubung ke core lokal', + 'settings.core.statusChecking': 'Memeriksa koneksi…', + 'settings.core.statusAuthFailed': 'Dapat dijangkau, tetapi token ditolak', + 'settings.core.statusUnreachable': 'Tidak dapat menjangkau core', + 'settings.core.recheck': 'Periksa ulang', + 'settings.core.save': 'Simpan & mulai ulang', + 'settings.core.applyRestartNote': + 'Menyimpan akan memulai ulang OpenHuman untuk menyambung kembali.', // Cross-host vault (#4278) 'crossHostVault.title': 'Vault berada di host core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 580753ee50..d205b46ec6 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // Italian (Italiano) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Connessione al core', + 'settings.core.menuDesc': 'Usa il core locale integrato o connettiti a un core remoto.', + 'settings.core.useRemoteToggle': 'Usa core remoto', + 'settings.core.useRemoteToggleDesc': + 'Connettiti a un core remoto via HTTP invece del core locale integrato.', + 'settings.core.statusConnectedRemote': 'Connesso al core remoto', + 'settings.core.statusConnectedLocal': 'Connesso al core locale', + 'settings.core.statusChecking': 'Verifica della connessione…', + 'settings.core.statusAuthFailed': 'Raggiungibile, ma il token è stato rifiutato', + 'settings.core.statusUnreachable': 'Impossibile raggiungere il core', + 'settings.core.recheck': 'Ricontrolla', + 'settings.core.save': 'Salva e riavvia', + 'settings.core.applyRestartNote': 'Il salvataggio riavvia OpenHuman per riconnettersi.', // Cross-host vault (#4278) 'crossHostVault.title': "Il vault è sull'host del core.", 'crossHostVault.message': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 74046271b0..e0fba7faca 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Korean (한국어) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': '코어 연결', + 'settings.core.menuDesc': '내장 로컬 코어를 사용하거나 원격 코어에 연결하세요.', + 'settings.core.useRemoteToggle': '원격 코어 사용', + 'settings.core.useRemoteToggleDesc': '내장 로컬 코어 대신 HTTP를 통해 원격 코어에 연결합니다.', + 'settings.core.statusConnectedRemote': '원격 코어에 연결됨', + 'settings.core.statusConnectedLocal': '로컬 코어에 연결됨', + 'settings.core.statusChecking': '연결 확인 중…', + 'settings.core.statusAuthFailed': '접근 가능하지만 토큰이 거부되었습니다', + 'settings.core.statusUnreachable': '코어에 연결할 수 없습니다', + 'settings.core.recheck': '다시 확인', + 'settings.core.save': '저장 후 재시작', + 'settings.core.applyRestartNote': '저장하면 다시 연결하기 위해 OpenHuman이 재시작됩니다.', // Cross-host vault (#4278) 'crossHostVault.title': '보관소가 코어 호스트에 있습니다.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ee94ffa56b..8dbe671f8e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3,6 +3,21 @@ import type { TranslationMap } from './types'; // Polish (Polski) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Połączenie z core', + 'settings.core.menuDesc': 'Użyj wbudowanego lokalnego core lub połącz się ze zdalnym core.', + 'settings.core.useRemoteToggle': 'Użyj zdalnego core', + 'settings.core.useRemoteToggleDesc': + 'Połącz się ze zdalnym core przez HTTP zamiast wbudowanego lokalnego core.', + 'settings.core.statusConnectedRemote': 'Połączono ze zdalnym core', + 'settings.core.statusConnectedLocal': 'Połączono z lokalnym core', + 'settings.core.statusChecking': 'Sprawdzanie połączenia…', + 'settings.core.statusAuthFailed': 'Osiągalny, ale token został odrzucony', + 'settings.core.statusUnreachable': 'Nie można połączyć się z core', + 'settings.core.recheck': 'Sprawdź ponownie', + 'settings.core.save': 'Zapisz i uruchom ponownie', + 'settings.core.applyRestartNote': + 'Zapisanie uruchamia ponownie OpenHuman, aby połączyć się ponownie.', // Cross-host vault (#4278) 'crossHostVault.title': 'Skarbiec znajduje się na hoście rdzenia.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 0b60a812b9..bb7cbb4a44 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // Portuguese (Português) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Conexão do core', + 'settings.core.menuDesc': 'Use o core local integrado ou conecte-se a um core remoto.', + 'settings.core.useRemoteToggle': 'Usar core remoto', + 'settings.core.useRemoteToggleDesc': + 'Conecte-se a um core remoto por HTTP em vez do core local integrado.', + 'settings.core.statusConnectedRemote': 'Conectado ao core remoto', + 'settings.core.statusConnectedLocal': 'Conectado ao core local', + 'settings.core.statusChecking': 'Verificando a conexão…', + 'settings.core.statusAuthFailed': 'Acessível, mas o token foi rejeitado', + 'settings.core.statusUnreachable': 'Não é possível acessar o core', + 'settings.core.recheck': 'Verificar novamente', + 'settings.core.save': 'Salvar e reiniciar', + 'settings.core.applyRestartNote': 'Salvar reinicia o OpenHuman para reconectar.', // Cross-host vault (#4278) 'crossHostVault.title': 'O vault está no host do core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8f4ac3700b..9e596839cc 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3,6 +3,22 @@ import type { TranslationMap } from './types'; // Russian (Русский) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': 'Подключение к ядру', + 'settings.core.menuDesc': + 'Используйте встроенное локальное ядро или подключитесь к удалённому ядру.', + 'settings.core.useRemoteToggle': 'Использовать удалённое ядро', + 'settings.core.useRemoteToggleDesc': + 'Подключиться к удалённому ядру по HTTP вместо встроенного локального ядра.', + 'settings.core.statusConnectedRemote': 'Подключено к удалённому ядру', + 'settings.core.statusConnectedLocal': 'Подключено к локальному ядру', + 'settings.core.statusChecking': 'Проверка подключения…', + 'settings.core.statusAuthFailed': 'Доступно, но токен отклонён', + 'settings.core.statusUnreachable': 'Не удаётся подключиться к ядру', + 'settings.core.recheck': 'Проверить снова', + 'settings.core.save': 'Сохранить и перезапустить', + 'settings.core.applyRestartNote': + 'После сохранения OpenHuman перезапустится для повторного подключения.', // Cross-host vault (#4278) 'crossHostVault.title': 'Хранилище находится на хосте ядра.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 53e158ebd8..6de4e796f5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Simplified Chinese (简体中文) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Core connection panel (GH-4396) + 'settings.core.title': '核心连接', + 'settings.core.menuDesc': '使用内置的本地核心,或连接到远程核心。', + 'settings.core.useRemoteToggle': '使用远程核心', + 'settings.core.useRemoteToggleDesc': '通过 HTTP 连接到远程核心,而不是使用内置的本地核心。', + 'settings.core.statusConnectedRemote': '已连接到远程核心', + 'settings.core.statusConnectedLocal': '已连接到本地核心', + 'settings.core.statusChecking': '正在检查连接…', + 'settings.core.statusAuthFailed': '可访问,但令牌被拒绝', + 'settings.core.statusUnreachable': '无法连接到核心', + 'settings.core.recheck': '重新检查', + 'settings.core.save': '保存并重启', + 'settings.core.applyRestartNote': '保存后 OpenHuman 将重启以重新连接。', // Cross-host vault (#4278) 'crossHostVault.title': '记忆库位于核心主机上。', 'crossHostVault.message':