Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
|
||||
import { RemoteConnectionForm } from './RemoteConnectionForm';
|
||||
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const DOCS_URL = 'https://opencode.ai/docs';
|
||||
@@ -78,7 +79,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
@@ -105,7 +106,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
|
||||
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const response = await runtimeFetch('/health');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
return data.openCodeRunning === true || data.isOpenCodeReady === true;
|
||||
@@ -206,7 +207,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
await runtimeFetch('/api/config/reload', { method: 'POST' });
|
||||
} finally {
|
||||
setTimeout(() => setIsApplyingPath(false), 1000);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DesktopConnectionRecovery({
|
||||
if (variant === 'remote-unreachable') {
|
||||
return { host: t('onboarding.desktopRecovery.placeholders.remoteServer') };
|
||||
}
|
||||
if (variant === 'remote-wrong-service') {
|
||||
if (variant === 'remote-wrong-service' || variant === 'remote-incompatible') {
|
||||
return { host: t('onboarding.desktopRecovery.placeholders.unknownServer') };
|
||||
}
|
||||
return undefined;
|
||||
@@ -84,7 +84,7 @@ export function DesktopConnectionRecovery({
|
||||
</div>
|
||||
|
||||
{/* Host info if available */}
|
||||
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
|
||||
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service' || variant === 'remote-incompatible') && (
|
||||
<div className="rounded-lg border border-border bg-background/50 p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">{t('onboarding.remoteConnection.field.serverAddress')}</div>
|
||||
<div className="font-mono text-sm text-foreground truncate">{redactSensitiveUrl(hostUrl)}</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { restartDesktopApp } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const DOCS_URL = 'https://opencode.ai/docs';
|
||||
@@ -99,7 +100,7 @@ export function LocalSetupScreen({
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
@@ -134,7 +135,7 @@ export function LocalSetupScreen({
|
||||
|
||||
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const response = await runtimeFetch('/health');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
return data.openCodeRunning === true || data.isOpenCodeReady === true;
|
||||
@@ -182,7 +183,7 @@ export function LocalSetupScreen({
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
await runtimeFetch('/api/config/reload', { method: 'POST' });
|
||||
} finally {
|
||||
setTimeout(() => setIsRetrying(false), 1000);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnec
|
||||
import { RemoteConnectionForm } from './RemoteConnectionForm';
|
||||
import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
|
||||
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type RecoveryScreenProps = {
|
||||
/** Recovery variant */
|
||||
@@ -62,7 +63,7 @@ export function RecoveryScreen({
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
await runtimeFetch('/api/config/reload', { method: 'POST' });
|
||||
onRetry?.();
|
||||
}, [onRetry]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
desktopHostsGet,
|
||||
desktopHostsSet,
|
||||
desktopHostProbe,
|
||||
normalizeHostUrl,
|
||||
resolveDesktopHostUrl,
|
||||
type HostProbeResult,
|
||||
} from '@/lib/desktopHosts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -37,6 +37,10 @@ function getProbeStatusMessageKey(status: ProbeStatus): string | null {
|
||||
return null; // Success is shown separately
|
||||
case 'auth':
|
||||
return 'onboarding.remoteConnection.probe.authMessage';
|
||||
case 'update-recommended':
|
||||
return 'onboarding.remoteConnection.probe.updateRecommendedMessage';
|
||||
case 'incompatible':
|
||||
return 'onboarding.remoteConnection.probe.incompatibleMessage';
|
||||
case 'wrong-service':
|
||||
return 'onboarding.remoteConnection.probe.wrongServiceMessage';
|
||||
case 'unreachable':
|
||||
@@ -47,7 +51,7 @@ function getProbeStatusMessageKey(status: ProbeStatus): string | null {
|
||||
}
|
||||
|
||||
function isBlockingStatus(status: ProbeStatus): boolean {
|
||||
return status === 'wrong-service' || status === 'unreachable';
|
||||
return status === 'wrong-service' || status === 'unreachable' || status === 'incompatible';
|
||||
}
|
||||
|
||||
export function RemoteConnectionForm({
|
||||
@@ -66,7 +70,8 @@ export function RemoteConnectionForm({
|
||||
const [probeResult, setProbeResult] = useState<HostProbeResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const normalizedUrl = normalizeHostUrl(url);
|
||||
const resolvedUrl = resolveDesktopHostUrl(url);
|
||||
const normalizedUrl = resolvedUrl?.persistedUrl ?? null;
|
||||
|
||||
const handleUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(e.target.value);
|
||||
@@ -89,7 +94,7 @@ export function RemoteConnectionForm({
|
||||
try {
|
||||
const result = await desktopHostProbe(normalizedUrl);
|
||||
setProbeResult(result);
|
||||
setState(result.status === 'ok' ? 'success' : 'error');
|
||||
setState(result.status === 'ok' || result.status === 'update-recommended' ? 'success' : 'error');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.connectionTestFailed'));
|
||||
setState('error');
|
||||
@@ -97,14 +102,15 @@ export function RemoteConnectionForm({
|
||||
}, [normalizedUrl, t]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (!normalizedUrl) return;
|
||||
if (!resolvedUrl) return;
|
||||
const targetUrl = resolvedUrl.persistedUrl;
|
||||
|
||||
setState('testing');
|
||||
setProbeResult(null);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const probe = await desktopHostProbe(normalizedUrl);
|
||||
const probe = await desktopHostProbe(targetUrl);
|
||||
setProbeResult(probe);
|
||||
|
||||
// Block connection on wrong-service or unreachable
|
||||
@@ -114,10 +120,10 @@ export function RemoteConnectionForm({
|
||||
}
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
const hostLabel = label.trim() || normalizedUrl;
|
||||
const hostLabel = label.trim() || targetUrl;
|
||||
|
||||
const existingHost = config.hosts.find(
|
||||
(h) => h.url === normalizedUrl
|
||||
(h) => h.url === targetUrl
|
||||
);
|
||||
|
||||
const hostId = existingHost ? existingHost.id : `host-${Date.now().toString(16)}`;
|
||||
@@ -125,7 +131,8 @@ export function RemoteConnectionForm({
|
||||
const newHost = {
|
||||
id: hostId,
|
||||
label: hostLabel,
|
||||
url: normalizedUrl,
|
||||
url: targetUrl,
|
||||
apiUrl: targetUrl,
|
||||
};
|
||||
|
||||
const updatedHosts = existingHost
|
||||
@@ -141,6 +148,11 @@ export function RemoteConnectionForm({
|
||||
|
||||
onConnect?.();
|
||||
|
||||
if (resolvedUrl.redeemUrl) {
|
||||
window.location.assign(resolvedUrl.redeemUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTauriShell()) {
|
||||
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_restart');
|
||||
@@ -149,7 +161,7 @@ export function RemoteConnectionForm({
|
||||
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection'));
|
||||
setState('error');
|
||||
}
|
||||
}, [normalizedUrl, label, onConnect, t]);
|
||||
}, [resolvedUrl, label, onConnect, t]);
|
||||
|
||||
const isTesting = state === 'testing';
|
||||
const canTest = normalizedUrl !== null && !isTesting;
|
||||
@@ -157,6 +169,7 @@ export function RemoteConnectionForm({
|
||||
|
||||
const probeMessageKey = getProbeStatusMessageKey(probeResult?.status ?? null);
|
||||
const isSuccess = probeResult?.status === 'ok';
|
||||
const isUpdateRecommended = probeResult?.status === 'update-recommended';
|
||||
const isAuth = probeResult?.status === 'auth';
|
||||
const isBlocking = isBlockingStatus(probeResult?.status ?? null);
|
||||
|
||||
@@ -238,6 +251,18 @@ export function RemoteConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{probeResult && isUpdateRecommended && (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--status-warning)',
|
||||
color: 'var(--status-warning)',
|
||||
}}
|
||||
>
|
||||
{probeMessageKey ? t(probeMessageKey as Parameters<typeof t>[0]) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blocking errors */}
|
||||
{probeResult && isBlocking && (
|
||||
<div
|
||||
|
||||
@@ -60,6 +60,15 @@ describe('getDesktopRecoveryConfig', () => {
|
||||
expect(config.useRemoteLabel).toBe('Use Remote');
|
||||
});
|
||||
|
||||
test('remote-incompatible exposes retry and both actions', () => {
|
||||
const config = getDesktopRecoveryConfig('remote-incompatible', 'Old Server', 'https://old.example');
|
||||
|
||||
expect(config.showRetry).toBe(true);
|
||||
expect(config.showUseLocal).toBe(true);
|
||||
expect(config.showUseRemote).toBe(true);
|
||||
expect(config.titleKey).toBe('onboarding.desktopRecovery.remoteIncompatible.title');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. missing-default-host: chooser-with-context (both actions, no retry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
export type RecoveryVariant =
|
||||
| 'local-unavailable'
|
||||
| 'remote-unreachable'
|
||||
| 'remote-incompatible'
|
||||
| 'remote-wrong-service'
|
||||
| 'remote-missing'
|
||||
| 'missing-default-host';
|
||||
@@ -113,6 +114,27 @@ export function getDesktopRecoveryConfig(
|
||||
};
|
||||
}
|
||||
|
||||
case 'remote-incompatible': {
|
||||
const host = formatHostDisplay(hostLabel, hostUrl);
|
||||
return {
|
||||
title: 'Server Update Required',
|
||||
description: `The OpenChamber server at "${host || 'unknown'}" is not compatible with this app version. Update OpenChamber on the server, then try again.`,
|
||||
titleKey: 'onboarding.desktopRecovery.remoteIncompatible.title',
|
||||
descriptionKey: 'onboarding.desktopRecovery.remoteIncompatible.description',
|
||||
descriptionParams: host ? { host } : undefined,
|
||||
iconKey: 'remote',
|
||||
showRetry: true,
|
||||
retryLabel: 'Retry Connection',
|
||||
retryLabelKey: 'onboarding.desktopRecovery.remoteUnreachable.retry',
|
||||
showUseLocal: true,
|
||||
showUseRemote: true,
|
||||
useLocalLabel: 'Use Local',
|
||||
useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
|
||||
useRemoteLabel: 'Use Remote',
|
||||
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
|
||||
};
|
||||
}
|
||||
|
||||
case 'missing-default-host':
|
||||
return {
|
||||
title: 'No Default Connection',
|
||||
|
||||
@@ -17,6 +17,10 @@ const EXPECTED_ROUTING: Record<RecoveryVariant, Record<RecoveryPrimaryAction, Re
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
'remote-incompatible': {
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
'remote-wrong-service': {
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
|
||||
@@ -20,6 +20,7 @@ export function resolveRecoveryNextStep(
|
||||
case 'local-unavailable':
|
||||
return { kind: 'local-setup' };
|
||||
case 'remote-unreachable':
|
||||
case 'remote-incompatible':
|
||||
case 'remote-wrong-service':
|
||||
case 'remote-missing':
|
||||
case 'missing-default-host':
|
||||
|
||||
Reference in New Issue
Block a user