feat(desktop): support remote-only startup
Allow Desktop to skip its in-process OpenChamber server with OPENCHAMBER_SKIP_LOCAL_SERVER=1 while continuing to load the packaged UI shell. Carry local runtime availability through the boot contract so unavailable or unconfigured remotes enter a remote-only chooser instead of offering broken local recovery actions. The chooser can select saved instances, add a server by URL, or redeem an OpenChamber pairing link over direct or E2EE relay transports. Keep additional windows, Mini Chat, background startup, and unreachable-host recovery functional without a local origin. Render boot and recovery surfaces with the active theme background rather than exposing the native vibrancy backing. Document the environment variable and cover serverless boot routing plus malformed pairing imports with focused tests.
This commit is contained in:
@@ -111,6 +111,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
|||||||
|----------|-----|
|
|----------|-----|
|
||||||
| `OPENCHAMBER_ELECTRON_DEV=1` | Marks the runtime as desktop development mode |
|
| `OPENCHAMBER_ELECTRON_DEV=1` | Marks the runtime as desktop development mode |
|
||||||
| `OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1` | Uses staged web assets instead of the HMR dev server |
|
| `OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1` | Uses staged web assets instead of the HMR dev server |
|
||||||
|
| `OPENCHAMBER_SKIP_LOCAL_SERVER=1` | Skips the in-process local OpenChamber server and uses the configured default remote instance; packaged/bundled UI remains available for connection recovery |
|
||||||
| `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` |
|
| `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` |
|
||||||
| `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
|
| `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
|
||||||
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server |
|
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server |
|
||||||
|
|||||||
+42
-20
@@ -183,6 +183,7 @@ const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA';
|
|||||||
const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
|
const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
|
||||||
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
|
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
|
||||||
const OPENCODE_SHUTDOWN_GRACE_MS = 100;
|
const OPENCODE_SHUTDOWN_GRACE_MS = 100;
|
||||||
|
const SKIP_LOCAL_SERVER = process.env.OPENCHAMBER_SKIP_LOCAL_SERVER === '1';
|
||||||
|
|
||||||
const { autoUpdater } = updaterPkg;
|
const { autoUpdater } = updaterPkg;
|
||||||
|
|
||||||
@@ -194,6 +195,7 @@ const state = {
|
|||||||
clientToken: null,
|
clientToken: null,
|
||||||
requestHeaders: {},
|
requestHeaders: {},
|
||||||
bootOutcome: null,
|
bootOutcome: null,
|
||||||
|
startupResolved: false,
|
||||||
initScript: null,
|
initScript: null,
|
||||||
mainWindow: null,
|
mainWindow: null,
|
||||||
quitRequested: false,
|
quitRequested: false,
|
||||||
@@ -1531,6 +1533,7 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken
|
|||||||
};
|
};
|
||||||
|
|
||||||
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
|
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
|
||||||
|
const availability = { localAvailable };
|
||||||
if (envTargetUrl) {
|
if (envTargetUrl) {
|
||||||
const status = probe?.status === 'unreachable'
|
const status = probe?.status === 'unreachable'
|
||||||
? 'unreachable'
|
? 'unreachable'
|
||||||
@@ -1539,23 +1542,23 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) =>
|
|||||||
: probe?.status === 'wrong-service'
|
: probe?.status === 'wrong-service'
|
||||||
? 'wrong-service'
|
? 'wrong-service'
|
||||||
: 'ok';
|
: 'ok';
|
||||||
return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl };
|
return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl, ...availability };
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultId = config.defaultHostId || '';
|
const defaultId = config.defaultHostId || '';
|
||||||
if (!defaultId) {
|
if (!defaultId) {
|
||||||
return { target: null, status: 'not-configured' };
|
return { target: null, status: 'not-configured', ...availability };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultId === LOCAL_HOST_ID) {
|
if (defaultId === LOCAL_HOST_ID) {
|
||||||
return localAvailable
|
return localAvailable
|
||||||
? { target: 'local', status: 'ok' }
|
? { target: 'local', status: 'ok', ...availability }
|
||||||
: { target: 'local', status: 'unreachable' };
|
: { target: 'local', status: 'unreachable', ...availability };
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = config.hosts.find((entry) => entry.id === defaultId);
|
const host = config.hosts.find((entry) => entry.id === defaultId);
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return { target: 'remote', status: 'missing', hostId: defaultId };
|
return { target: 'remote', status: 'missing', hostId: defaultId, ...availability };
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = probe?.status === 'unreachable'
|
const status = probe?.status === 'unreachable'
|
||||||
@@ -1565,7 +1568,7 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) =>
|
|||||||
: probe?.status === 'wrong-service'
|
: probe?.status === 'wrong-service'
|
||||||
? 'wrong-service'
|
? 'wrong-service'
|
||||||
: 'ok';
|
: 'ok';
|
||||||
return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url };
|
return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability };
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildStartupSplashHtml = () => {
|
const buildStartupSplashHtml = () => {
|
||||||
@@ -2468,6 +2471,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = {}) => {
|
const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = {}) => {
|
||||||
|
state.startupResolved = true;
|
||||||
state.localOrigin = localOrigin;
|
state.localOrigin = localOrigin;
|
||||||
state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl;
|
state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl;
|
||||||
state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : '';
|
state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : '';
|
||||||
@@ -2506,7 +2510,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openMainWindow = async () => {
|
const openMainWindow = async () => {
|
||||||
if (!state.localOrigin) {
|
if (!state.startupResolved) {
|
||||||
const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
|
const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
|
||||||
return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders });
|
return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders });
|
||||||
}
|
}
|
||||||
@@ -2540,7 +2544,7 @@ const openMainWindow = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createAdditionalWindow = async (url, runtimeConfig = {}) => {
|
const createAdditionalWindow = async (url, runtimeConfig = {}) => {
|
||||||
if (!state.localOrigin) {
|
if (!state.startupResolved || !url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const browserWindow = createBrowserWindow({
|
const browserWindow = createBrowserWindow({
|
||||||
@@ -2553,12 +2557,14 @@ const createAdditionalWindow = async (url, runtimeConfig = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => {
|
const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => {
|
||||||
const base = state.localOrigin || state.sidecarUrl;
|
const base = shouldUsePackagedUi()
|
||||||
|
? buildPackagedUiUrl('/mini-chat.html')
|
||||||
|
: state.localOrigin || state.sidecarUrl;
|
||||||
if (!base) {
|
if (!base) {
|
||||||
throw new Error('Local UI is not available');
|
throw new Error('Local UI is not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(shouldUsePackagedUi() ? buildPackagedUiUrl('/mini-chat.html') : '/mini-chat.html', base);
|
const url = new URL(shouldUsePackagedUi() ? base : '/mini-chat.html', base);
|
||||||
url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft');
|
url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft');
|
||||||
if (sessionId) url.searchParams.set('sessionId', sessionId);
|
if (sessionId) url.searchParams.set('sessionId', sessionId);
|
||||||
if (directory) url.searchParams.set('directory', directory);
|
if (directory) url.searchParams.set('directory', directory);
|
||||||
@@ -2754,9 +2760,11 @@ const resolveInitialUrl = async () => {
|
|||||||
const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173';
|
const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173';
|
||||||
const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`;
|
const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`;
|
||||||
const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`;
|
const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`;
|
||||||
const localUrl = isDev && await waitForHealth(hmrApiUrl, 5_000, 100)
|
const localUrl = SKIP_LOCAL_SERVER
|
||||||
? hmrApiUrl
|
? null
|
||||||
: await spawnLocalServer();
|
: isDev && await waitForHealth(hmrApiUrl, 5_000, 100)
|
||||||
|
? hmrApiUrl
|
||||||
|
: await spawnLocalServer();
|
||||||
|
|
||||||
const localUiUrl = shouldUsePackagedUi()
|
const localUiUrl = shouldUsePackagedUi()
|
||||||
? buildPackagedUiUrl('/index.html')
|
? buildPackagedUiUrl('/index.html')
|
||||||
@@ -2767,10 +2775,10 @@ const resolveInitialUrl = async () => {
|
|||||||
state.sidecarUrl = localUrl;
|
state.sidecarUrl = localUrl;
|
||||||
const localAvailable = Boolean(localUrl);
|
const localAvailable = Boolean(localUrl);
|
||||||
|
|
||||||
const localOrigin = new URL(localUrl).origin;
|
const localOrigin = localUrl ? new URL(localUrl).origin : null;
|
||||||
let initialUrl = localUiUrl;
|
let initialUrl = localUiUrl;
|
||||||
let apiBaseUrl = localUrl;
|
let apiBaseUrl = localUrl || '';
|
||||||
let clientToken = readDesktopLocalClientToken();
|
let clientToken = localUrl ? readDesktopLocalClientToken() : '';
|
||||||
let requestHeaders = {};
|
let requestHeaders = {};
|
||||||
let remoteProbe = null;
|
let remoteProbe = null;
|
||||||
|
|
||||||
@@ -2798,13 +2806,22 @@ const resolveInitialUrl = async () => {
|
|||||||
}
|
}
|
||||||
if (remoteProbe.status === 'unreachable') {
|
if (remoteProbe.status === 'unreachable') {
|
||||||
state.unreachableHosts.add(apiBaseUrl);
|
state.unreachableHosts.add(apiBaseUrl);
|
||||||
apiBaseUrl = localUrl;
|
apiBaseUrl = localUrl || '';
|
||||||
clientToken = readDesktopLocalClientToken();
|
clientToken = localUrl ? readDesktopLocalClientToken() : '';
|
||||||
requestHeaders = {};
|
requestHeaders = {};
|
||||||
initialUrl = localUiUrl;
|
initialUrl = localUiUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!initialUrl && apiBaseUrl && remoteProbe?.status !== 'unreachable') {
|
||||||
|
initialUrl = apiBaseUrl;
|
||||||
|
}
|
||||||
|
if (!initialUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'OPENCHAMBER_SKIP_LOCAL_SERVER=1 requires bundled UI, a running desktop HMR UI, or a reachable remote instance.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const bootOutcome = computeBootOutcome({
|
const bootOutcome = computeBootOutcome({
|
||||||
envTargetUrl: envTarget || null,
|
envTargetUrl: envTarget || null,
|
||||||
probe: remoteProbe,
|
probe: remoteProbe,
|
||||||
@@ -4891,11 +4908,16 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBackgroundStart) {
|
if (isBackgroundStart) {
|
||||||
const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl();
|
const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
|
||||||
state.localOrigin = localOrigin;
|
state.localOrigin = localOrigin;
|
||||||
|
state.apiBaseUrl = apiBaseUrl;
|
||||||
|
state.clientToken = clientToken;
|
||||||
state.bootOutcome = bootOutcome ?? null;
|
state.bootOutcome = bootOutcome ?? null;
|
||||||
state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {});
|
state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {});
|
||||||
state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders);
|
// Serverless background startup re-probes the remote when a window is
|
||||||
|
// eventually opened instead of trusting reachability from login time.
|
||||||
|
state.startupResolved = !SKIP_LOCAL_SERVER;
|
||||||
|
state.initScript = buildInitScript(localOrigin, state.bootOutcome, apiBaseUrl, clientToken, state.requestHeaders);
|
||||||
log.info('[electron] started in background without window');
|
log.info('[electron] started in background without window');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -833,10 +833,11 @@ function App({ apis }: AppProps) {
|
|||||||
if (bootView.screen === 'chooser') {
|
if (bootView.screen === 'chooser') {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="h-full text-foreground bg-transparent">
|
<div className="h-full text-foreground bg-background">
|
||||||
<React.Suspense fallback={<div className="h-full" />}>
|
<React.Suspense fallback={<div className="h-full" />}>
|
||||||
<OnboardingScreen
|
<OnboardingScreen
|
||||||
mode="first-launch"
|
mode="first-launch"
|
||||||
|
localAvailable={bootView.localAvailable !== false}
|
||||||
onCliAvailable={handleDesktopBootDismiss}
|
onCliAvailable={handleDesktopBootDismiss}
|
||||||
onChooseRemote={() => {
|
onChooseRemote={() => {
|
||||||
// Switch to remote tab - handled internally by OnboardingScreen
|
// Switch to remote tab - handled internally by OnboardingScreen
|
||||||
@@ -854,13 +855,14 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="h-full text-foreground bg-transparent">
|
<div className="h-full text-foreground bg-background">
|
||||||
<React.Suspense fallback={<div className="h-full" />}>
|
<React.Suspense fallback={<div className="h-full" />}>
|
||||||
<OnboardingScreen
|
<OnboardingScreen
|
||||||
mode="recovery"
|
mode="recovery"
|
||||||
recoveryVariant={recoveryVariant}
|
recoveryVariant={recoveryVariant}
|
||||||
recoveryHostUrl={hostUrl}
|
recoveryHostUrl={hostUrl}
|
||||||
recoveryHostLabel={undefined}
|
recoveryHostLabel={undefined}
|
||||||
|
localAvailable={bootView.localAvailable !== false}
|
||||||
onCliAvailable={handleDesktopBootDismiss}
|
onCliAvailable={handleDesktopBootDismiss}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
|
|||||||
type ChooserScreenProps = {
|
type ChooserScreenProps = {
|
||||||
/** Callback when CLI becomes available */
|
/** Callback when CLI becomes available */
|
||||||
onCliAvailable?: () => void;
|
onCliAvailable?: () => void;
|
||||||
|
localAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: string }) {
|
function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: string }) {
|
||||||
@@ -45,7 +46,7 @@ function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
export function ChooserScreen({ onCliAvailable, localAvailable = true }: ChooserScreenProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
||||||
@@ -53,7 +54,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
|||||||
const [isManualChecking, setIsManualChecking] = React.useState(false);
|
const [isManualChecking, setIsManualChecking] = React.useState(false);
|
||||||
const [opencodeBinary, setOpencodeBinary] = React.useState('');
|
const [opencodeBinary, setOpencodeBinary] = React.useState('');
|
||||||
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
|
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
|
||||||
const [activeTab, setActiveTab] = React.useState<'local' | 'remote'>('local');
|
const [activeTab, setActiveTab] = React.useState<'local' | 'remote'>(() => localAvailable ? 'local' : 'remote');
|
||||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||||
const [troubleOpen, setTroubleOpen] = React.useState(false);
|
const [troubleOpen, setTroubleOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
|||||||
// whether the OpenCode CLI is reachable. As soon as it is, transition
|
// whether the OpenCode CLI is reachable. As soon as it is, transition
|
||||||
// automatically — the user doesn't have to click anything.
|
// automatically — the user doesn't have to click anything.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeTab !== 'local') return;
|
if (!localAvailable || activeTab !== 'local') return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -164,7 +165,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [activeTab, checkCliAvailability, announceAvailable]);
|
}, [activeTab, checkCliAvailability, announceAvailable, localAvailable]);
|
||||||
|
|
||||||
const handleManualCheck = React.useCallback(async () => {
|
const handleManualCheck = React.useCallback(async () => {
|
||||||
setIsManualChecking(true);
|
setIsManualChecking(true);
|
||||||
@@ -223,7 +224,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
|||||||
? '/home/you/.bun/bin/opencode'
|
? '/home/you/.bun/bin/opencode'
|
||||||
: '/Users/you/.bun/bin/opencode';
|
: '/Users/you/.bun/bin/opencode';
|
||||||
|
|
||||||
const showLocal = !isDesktopApp || activeTab === 'local';
|
const showLocal = localAvailable && (!isDesktopApp || activeTab === 'local');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -240,7 +241,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isDesktopApp && (
|
{isDesktopApp && localAvailable && (
|
||||||
<div className="app-region-no-drag flex gap-1.5">
|
<div className="app-region-no-drag flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -272,9 +273,10 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
|||||||
{isDesktopApp && activeTab === 'remote' ? (
|
{isDesktopApp && activeTab === 'remote' ? (
|
||||||
<div className="app-region-no-drag">
|
<div className="app-region-no-drag">
|
||||||
<RemoteConnectionForm
|
<RemoteConnectionForm
|
||||||
onBack={() => setActiveTab('local')}
|
onBack={() => localAvailable && setActiveTab('local')}
|
||||||
showBackButton={false}
|
showBackButton={false}
|
||||||
onSwitchToLocal={() => setActiveTab('local')}
|
showInstancePicker={!localAvailable}
|
||||||
|
onSwitchToLocal={localAvailable ? () => setActiveTab('local') : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type OnboardingScreenProps = {
|
|||||||
onEnterLocalSetup?: () => void;
|
onEnterLocalSetup?: () => void;
|
||||||
/** Callback when user wants to switch to remote (first-launch only) */
|
/** Callback when user wants to switch to remote (first-launch only) */
|
||||||
onChooseRemote?: () => void;
|
onChooseRemote?: () => void;
|
||||||
|
localAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OnboardingScreen({
|
export function OnboardingScreen({
|
||||||
@@ -33,6 +34,7 @@ export function OnboardingScreen({
|
|||||||
recoveryHostUrl,
|
recoveryHostUrl,
|
||||||
recoveryHostLabel,
|
recoveryHostLabel,
|
||||||
onEnterLocalSetup,
|
onEnterLocalSetup,
|
||||||
|
localAvailable = true,
|
||||||
}: OnboardingScreenProps) {
|
}: OnboardingScreenProps) {
|
||||||
const [showRecoveryRemoteForm, setShowRecoveryRemoteForm] = React.useState(false);
|
const [showRecoveryRemoteForm, setShowRecoveryRemoteForm] = React.useState(false);
|
||||||
const [recoveryEnteredLocalSetup, setRecoveryEnteredLocalSetup] = React.useState(false);
|
const [recoveryEnteredLocalSetup, setRecoveryEnteredLocalSetup] = React.useState(false);
|
||||||
@@ -55,6 +57,7 @@ export function OnboardingScreen({
|
|||||||
variant={recoveryVariant}
|
variant={recoveryVariant}
|
||||||
hostUrl={recoveryHostUrl}
|
hostUrl={recoveryHostUrl}
|
||||||
hostLabel={recoveryHostLabel}
|
hostLabel={recoveryHostLabel}
|
||||||
|
onChooseRemote={() => setShowRecoveryRemoteForm(true)}
|
||||||
showRemoteForm={showRecoveryRemoteForm}
|
showRemoteForm={showRecoveryRemoteForm}
|
||||||
onCloseRemoteForm={() => setShowRecoveryRemoteForm(false)}
|
onCloseRemoteForm={() => setShowRecoveryRemoteForm(false)}
|
||||||
onSwitchToLocalFromRemote={() => {
|
onSwitchToLocalFromRemote={() => {
|
||||||
@@ -65,6 +68,7 @@ export function OnboardingScreen({
|
|||||||
setRecoveryEnteredLocalSetup(true);
|
setRecoveryEnteredLocalSetup(true);
|
||||||
onEnterLocalSetup?.();
|
onEnterLocalSetup?.();
|
||||||
}}
|
}}
|
||||||
|
localAvailable={localAvailable}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,6 +95,7 @@ export function OnboardingScreen({
|
|||||||
return (
|
return (
|
||||||
<ChooserScreen
|
<ChooserScreen
|
||||||
onCliAvailable={onCliAvailable}
|
onCliAvailable={onCliAvailable}
|
||||||
|
localAvailable={localAvailable}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type RecoveryScreenProps = {
|
|||||||
onEnterLocalSetup?: () => void;
|
onEnterLocalSetup?: () => void;
|
||||||
/** Whether retry action is in progress */
|
/** Whether retry action is in progress */
|
||||||
isRetrying?: boolean;
|
isRetrying?: boolean;
|
||||||
|
localAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RecoveryScreen({
|
export function RecoveryScreen({
|
||||||
@@ -40,6 +41,7 @@ export function RecoveryScreen({
|
|||||||
onSwitchToLocalFromRemote,
|
onSwitchToLocalFromRemote,
|
||||||
onEnterLocalSetup,
|
onEnterLocalSetup,
|
||||||
isRetrying = false,
|
isRetrying = false,
|
||||||
|
localAvailable = true,
|
||||||
}: RecoveryScreenProps) {
|
}: RecoveryScreenProps) {
|
||||||
// Persist the user's first choice (local or remote)
|
// Persist the user's first choice (local or remote)
|
||||||
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
||||||
@@ -103,7 +105,8 @@ export function RecoveryScreen({
|
|||||||
initialUrl={prefillUrl}
|
initialUrl={prefillUrl}
|
||||||
initialLabel={prefillLabel}
|
initialLabel={prefillLabel}
|
||||||
isRecoveryMode={true}
|
isRecoveryMode={true}
|
||||||
onSwitchToLocal={onSwitchToLocalFromRemote || (() => {
|
showInstancePicker={!localAvailable}
|
||||||
|
onSwitchToLocal={localAvailable ? (onSwitchToLocalFromRemote || (() => {
|
||||||
persistFirstChoice('local').then(() => {
|
persistFirstChoice('local').then(() => {
|
||||||
if (isDesktopShell()) {
|
if (isDesktopShell()) {
|
||||||
restartDesktopApp();
|
restartDesktopApp();
|
||||||
@@ -111,7 +114,7 @@ export function RecoveryScreen({
|
|||||||
onEnterLocalSetup?.();
|
onEnterLocalSetup?.();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})}
|
})) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ export function RecoveryScreen({
|
|||||||
hostLabel={hostLabel}
|
hostLabel={hostLabel}
|
||||||
hostUrl={hostUrl}
|
hostUrl={hostUrl}
|
||||||
onRetry={handleRecoveryRetry}
|
onRetry={handleRecoveryRetry}
|
||||||
onUseLocal={handleRecoveryUseLocal}
|
onUseLocal={localAvailable ? handleRecoveryUseLocal : undefined}
|
||||||
onUseRemote={handleRecoveryUseRemote}
|
onUseRemote={handleRecoveryUseRemote}
|
||||||
isRetrying={isRetrying}
|
isRetrying={isRetrying}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
desktopHostsGet,
|
desktopHostsGet,
|
||||||
desktopHostsSet,
|
desktopHostsSet,
|
||||||
desktopHostProbe,
|
desktopHostProbe,
|
||||||
resolveDesktopHostUrl,
|
resolveDesktopHostUrl,
|
||||||
|
importDesktopHostPairing,
|
||||||
|
type DesktopHost,
|
||||||
type HostProbeResult,
|
type HostProbeResult,
|
||||||
} from '@/lib/desktopHosts';
|
} from '@/lib/desktopHosts';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -27,6 +29,7 @@ export interface RemoteConnectionFormProps {
|
|||||||
onConnect?: () => void;
|
onConnect?: () => void;
|
||||||
/** Optional: callback when user wants to switch to local setup */
|
/** Optional: callback when user wants to switch to local setup */
|
||||||
onSwitchToLocal?: () => void;
|
onSwitchToLocal?: () => void;
|
||||||
|
showInstancePicker?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProbeStatus = HostProbeResult['status'] | null;
|
type ProbeStatus = HostProbeResult['status'] | null;
|
||||||
@@ -62,6 +65,7 @@ export function RemoteConnectionForm({
|
|||||||
isRecoveryMode = false,
|
isRecoveryMode = false,
|
||||||
onConnect,
|
onConnect,
|
||||||
onSwitchToLocal,
|
onSwitchToLocal,
|
||||||
|
showInstancePicker = false,
|
||||||
}: RemoteConnectionFormProps) {
|
}: RemoteConnectionFormProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [url, setUrl] = useState(initialUrl);
|
const [url, setUrl] = useState(initialUrl);
|
||||||
@@ -69,6 +73,16 @@ export function RemoteConnectionForm({
|
|||||||
const [state, setState] = useState<ConnectionState>('idle');
|
const [state, setState] = useState<ConnectionState>('idle');
|
||||||
const [probeResult, setProbeResult] = useState<HostProbeResult | null>(null);
|
const [probeResult, setProbeResult] = useState<HostProbeResult | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [hosts, setHosts] = useState<DesktopHost[]>([]);
|
||||||
|
const [view, setView] = useState<'instances' | 'add' | 'import'>(() => showInstancePicker ? 'instances' : 'add');
|
||||||
|
const [connectLink, setConnectLink] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showInstancePicker) return;
|
||||||
|
void desktopHostsGet().then((config) => setHosts(config.hosts)).catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}, [showInstancePicker]);
|
||||||
|
|
||||||
const resolvedUrl = resolveDesktopHostUrl(url);
|
const resolvedUrl = resolveDesktopHostUrl(url);
|
||||||
const normalizedUrl = resolvedUrl?.persistedUrl ?? null;
|
const normalizedUrl = resolvedUrl?.persistedUrl ?? null;
|
||||||
@@ -162,6 +176,38 @@ export function RemoteConnectionForm({
|
|||||||
}
|
}
|
||||||
}, [resolvedUrl, label, onConnect, t]);
|
}, [resolvedUrl, label, onConnect, t]);
|
||||||
|
|
||||||
|
const selectHost = useCallback(async (hostId: string) => {
|
||||||
|
const config = await desktopHostsGet();
|
||||||
|
await desktopHostsSet({
|
||||||
|
hosts: config.hosts,
|
||||||
|
defaultHostId: hostId,
|
||||||
|
initialHostChoiceCompleted: true,
|
||||||
|
});
|
||||||
|
await restartDesktopApp();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImport = useCallback(async () => {
|
||||||
|
setState('testing');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const config = await desktopHostsGet();
|
||||||
|
const imported = await importDesktopHostPairing(connectLink, config.hosts);
|
||||||
|
await desktopHostsSet({
|
||||||
|
hosts: imported.hosts,
|
||||||
|
defaultHostId: imported.hostId,
|
||||||
|
initialHostChoiceCompleted: true,
|
||||||
|
});
|
||||||
|
await restartDesktopApp();
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error && err.message === 'invalid-connect-link'
|
||||||
|
? t('settings.remoteInstances.direct.error.invalidConnectLink')
|
||||||
|
: t('onboarding.remoteConnection.errors.failedToSaveConnection'),
|
||||||
|
);
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
}, [connectLink, t]);
|
||||||
|
|
||||||
const isTesting = state === 'testing';
|
const isTesting = state === 'testing';
|
||||||
const canTest = normalizedUrl !== null && !isTesting;
|
const canTest = normalizedUrl !== null && !isTesting;
|
||||||
const canConnect = normalizedUrl !== null && !isTesting && !isBlockingStatus(probeResult?.status ?? null);
|
const canConnect = normalizedUrl !== null && !isTesting && !isBlockingStatus(probeResult?.status ?? null);
|
||||||
@@ -172,12 +218,81 @@ export function RemoteConnectionForm({
|
|||||||
const isAuth = probeResult?.status === 'auth';
|
const isAuth = probeResult?.status === 'auth';
|
||||||
const isBlocking = isBlockingStatus(probeResult?.status ?? null);
|
const isBlocking = isBlockingStatus(probeResult?.status ?? null);
|
||||||
|
|
||||||
|
if (showInstancePicker && view === 'instances') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
|
||||||
|
{t('desktopHostSwitcher.actions.switchInstance')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{t('settings.remoteInstances.direct.description')}</p>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="text-sm text-[var(--status-error)]">{error}</div> : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hosts.length === 0 ? (
|
||||||
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t('settings.remoteInstances.direct.state.empty')}
|
||||||
|
</div>
|
||||||
|
) : hosts.map((host) => (
|
||||||
|
<Button
|
||||||
|
key={host.id}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => void selectHost(host.id)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{host.label}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={() => setView('import')}>
|
||||||
|
{t('settings.remoteInstances.direct.import.action')}
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={() => setView('add')}>
|
||||||
|
{t('settings.remoteInstances.direct.actions.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showInstancePicker && view === 'import') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<Button variant="ghost" onClick={() => setView('instances')} className="p-0 text-muted-foreground">
|
||||||
|
{t('onboarding.common.actions.back')}
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
|
||||||
|
{t('settings.remoteInstances.direct.import.action')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{t('settings.remoteInstances.direct.import.description')}</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={connectLink}
|
||||||
|
onChange={(event) => setConnectLink(event.target.value)}
|
||||||
|
placeholder={t('settings.remoteInstances.direct.import.placeholder')}
|
||||||
|
disabled={isTesting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error ? <div className="text-sm text-[var(--status-error)]">{error}</div> : null}
|
||||||
|
<Button onClick={() => void handleImport()} disabled={isTesting || !connectLink.trim()}>
|
||||||
|
{t('settings.remoteInstances.direct.import.action')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
{showBackButton && (
|
{(showBackButton || showInstancePicker) && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Button variant="ghost" onClick={onBack} className="p-0 text-muted-foreground hover:text-foreground">
|
<Button variant="ghost" onClick={showInstancePicker ? () => setView('instances') : onBack} className="p-0 text-muted-foreground hover:text-foreground">
|
||||||
{t('onboarding.common.actions.back')}
|
{t('onboarding.common.actions.back')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,27 @@ describe('resolveDesktopBootView', () => {
|
|||||||
).toEqual({ screen: 'recovery', variant: 'remote-unreachable', hostId: 'remote-a', url: 'https://x.test' });
|
).toEqual({ screen: 'recovery', variant: 'remote-unreachable', hostId: 'remote-a', url: 'https://x.test' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preserves disabled local runtime capability for remote recovery', () => {
|
||||||
|
expect(
|
||||||
|
resolveDesktopBootView({
|
||||||
|
isDesktopShell: true,
|
||||||
|
bootOutcome: {
|
||||||
|
target: 'remote',
|
||||||
|
status: 'unreachable',
|
||||||
|
hostId: 'remote-a',
|
||||||
|
url: 'https://x.test',
|
||||||
|
localAvailable: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
screen: 'recovery',
|
||||||
|
variant: 'remote-unreachable',
|
||||||
|
hostId: 'remote-a',
|
||||||
|
url: 'https://x.test',
|
||||||
|
localAvailable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('returns main for local ok', () => {
|
test('returns main for local ok', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDesktopBootView({
|
resolveDesktopBootView({
|
||||||
@@ -77,13 +98,22 @@ describe('resolveDesktopBootView', () => {
|
|||||||
).toEqual({ screen: 'recovery', variant: 'remote-incompatible', hostId: 'old-host', url: 'https://old.test' });
|
).toEqual({ screen: 'recovery', variant: 'remote-incompatible', hostId: 'old-host', url: 'https://old.test' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns recovery view for local unreachable', () => {
|
test('returns chooser for local unreachable', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDesktopBootView({
|
resolveDesktopBootView({
|
||||||
isDesktopShell: true,
|
isDesktopShell: true,
|
||||||
bootOutcome: { target: 'local', status: 'unreachable' },
|
bootOutcome: { target: 'local', status: 'unreachable' },
|
||||||
}),
|
}),
|
||||||
).toEqual({ screen: 'recovery', variant: 'local-unavailable' });
|
).toEqual({ screen: 'chooser' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns remote-only chooser when local runtime is disabled', () => {
|
||||||
|
expect(
|
||||||
|
resolveDesktopBootView({
|
||||||
|
isDesktopShell: true,
|
||||||
|
bootOutcome: { target: 'local', status: 'unreachable', localAvailable: false },
|
||||||
|
}),
|
||||||
|
).toEqual({ screen: 'chooser', localAvailable: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns recovery view for remote missing', () => {
|
test('returns recovery view for remote missing', () => {
|
||||||
|
|||||||
@@ -18,32 +18,34 @@
|
|||||||
* This makes it easier to add new states without updating multiple files and
|
* This makes it easier to add new states without updating multiple files and
|
||||||
* allows UI to reason about outcomes with simple status checks.
|
* allows UI to reason about outcomes with simple status checks.
|
||||||
*/
|
*/
|
||||||
|
type DesktopBootAvailability = { localAvailable?: boolean };
|
||||||
|
|
||||||
export type DesktopBootOutcome =
|
export type DesktopBootOutcome =
|
||||||
// Main screens - CLI or remote connection is working
|
// Main screens - CLI or remote connection is working
|
||||||
| { target: 'local'; status: 'ok' }
|
| ({ target: 'local'; status: 'ok' } & DesktopBootAvailability)
|
||||||
| { target: 'remote'; status: 'ok'; hostId: string; url: string }
|
| ({ target: 'remote'; status: 'ok'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
|
|
||||||
// First launch - user hasn't made a choice yet
|
// First launch - user hasn't made a choice yet
|
||||||
| { target: null; status: 'not-configured' }
|
| ({ target: null; status: 'not-configured' } & DesktopBootAvailability)
|
||||||
|
|
||||||
// Recovery screens - something is wrong
|
// Recovery screens - something is wrong
|
||||||
| { target: 'local'; status: 'unreachable' }
|
| ({ target: 'local'; status: 'unreachable' } & DesktopBootAvailability)
|
||||||
| { target: 'remote'; status: 'unreachable'; hostId: string; url: string }
|
| ({ target: 'remote'; status: 'unreachable'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { target: 'remote'; status: 'incompatible'; hostId: string; url: string }
|
| ({ target: 'remote'; status: 'incompatible'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { target: 'remote'; status: 'wrong-service'; hostId: string; url: string }
|
| ({ target: 'remote'; status: 'wrong-service'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { target: 'remote'; status: 'missing'; hostId: string };
|
| ({ target: 'remote'; status: 'missing'; hostId: string } & DesktopBootAvailability);
|
||||||
|
|
||||||
// ── UI-facing view ──
|
// ── UI-facing view ──
|
||||||
|
|
||||||
export type DesktopBootView =
|
export type DesktopBootView =
|
||||||
| { screen: 'main' }
|
| ({ screen: 'main' } & DesktopBootAvailability)
|
||||||
| { screen: 'main'; hostId: string; url: string }
|
| ({ screen: 'main'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { screen: 'chooser' }
|
| ({ screen: 'chooser' } & DesktopBootAvailability)
|
||||||
| { screen: 'recovery'; variant: 'local-unavailable' }
|
| ({ screen: 'recovery'; variant: 'local-unavailable' } & DesktopBootAvailability)
|
||||||
| { screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string }
|
| ({ screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string }
|
| ({ screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string }
|
| ({ screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string } & DesktopBootAvailability)
|
||||||
| { screen: 'recovery'; variant: 'remote-missing'; hostId: string };
|
| ({ screen: 'recovery'; variant: 'remote-missing'; hostId: string } & DesktopBootAvailability);
|
||||||
|
|
||||||
// ── Resolver inputs ──
|
// ── Resolver inputs ──
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = raw as Record<string, unknown>;
|
const record = raw as Record<string, unknown>;
|
||||||
|
const availability = record.localAvailable === false ? { localAvailable: false } : {};
|
||||||
const target = record.target;
|
const target = record.target;
|
||||||
const status = record.status;
|
const status = record.status;
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
|||||||
if (target === 'remote' || target === 'local') {
|
if (target === 'remote' || target === 'local') {
|
||||||
if (status === 'ok' && target === 'local') {
|
if (status === 'ok' && target === 'local') {
|
||||||
// { target: 'local'; status: 'ok' } is valid
|
// { target: 'local'; status: 'ok' } is valid
|
||||||
return { valid: true, outcome: { target: 'local', status: 'ok' } };
|
return { valid: true, outcome: { target: 'local', status: 'ok', ...availability } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'ok' && target === 'remote') {
|
if (status === 'ok' && target === 'remote') {
|
||||||
@@ -101,19 +104,19 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
|||||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||||
return { valid: false };
|
return { valid: false };
|
||||||
}
|
}
|
||||||
return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url } };
|
return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url, ...availability } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'unreachable') {
|
if (status === 'unreachable') {
|
||||||
if (target === 'local') {
|
if (target === 'local') {
|
||||||
// { target: 'local'; status: 'unreachable' } is valid
|
// { target: 'local'; status: 'unreachable' } is valid
|
||||||
return { valid: true, outcome: { target: 'local', status: 'unreachable' } };
|
return { valid: true, outcome: { target: 'local', status: 'unreachable', ...availability } };
|
||||||
} else {
|
} else {
|
||||||
// { target: 'remote'; status: 'unreachable' } requires hostId and url
|
// { target: 'remote'; status: 'unreachable' } requires hostId and url
|
||||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||||
return { valid: false };
|
return { valid: false };
|
||||||
}
|
}
|
||||||
return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url } };
|
return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url, ...availability } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +125,7 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
|||||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||||
return { valid: false };
|
return { valid: false };
|
||||||
}
|
}
|
||||||
return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url } };
|
return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url, ...availability } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'missing') {
|
if (status === 'missing') {
|
||||||
@@ -130,14 +133,14 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
|||||||
if (typeof record.hostId !== 'string') {
|
if (typeof record.hostId !== 'string') {
|
||||||
return { valid: false };
|
return { valid: false };
|
||||||
}
|
}
|
||||||
return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId } };
|
return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId, ...availability } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target === null) {
|
if (target === null) {
|
||||||
if (status === 'not-configured') {
|
if (status === 'not-configured') {
|
||||||
// { target: null; status: 'not-configured' } is valid (first launch)
|
// { target: null; status: 'not-configured' } is valid (first launch)
|
||||||
return { valid: true, outcome: { target: null, status: 'not-configured' } };
|
return { valid: true, outcome: { target: null, status: 'not-configured', ...availability } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'missing') {
|
if (status === 'missing') {
|
||||||
@@ -166,35 +169,36 @@ export function resolveDesktopBootView(
|
|||||||
if (!outcome) {
|
if (!outcome) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const availability = outcome.localAvailable === false ? { localAvailable: false } : {};
|
||||||
|
|
||||||
// Main screens - CLI or remote connection is working
|
// Main screens - CLI or remote connection is working
|
||||||
if (outcome.status === 'ok') {
|
if (outcome.status === 'ok') {
|
||||||
if (outcome.target === 'local') {
|
if (outcome.target === 'local') {
|
||||||
return { screen: 'main' };
|
return { screen: 'main', ...availability };
|
||||||
} else if (outcome.target === 'remote') {
|
} else if (outcome.target === 'remote') {
|
||||||
return { screen: 'main', hostId: outcome.hostId, url: outcome.url };
|
return { screen: 'main', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First launch - user hasn't made a choice yet
|
// First launch - user hasn't made a choice yet
|
||||||
if (outcome.target === null && outcome.status === 'not-configured') {
|
if (outcome.target === null && outcome.status === 'not-configured') {
|
||||||
return { screen: 'chooser' };
|
return { screen: 'chooser', ...availability };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery screens - something is wrong
|
// Recovery screens - something is wrong
|
||||||
if (outcome.target === 'local' && outcome.status === 'unreachable') {
|
if (outcome.target === 'local' && outcome.status === 'unreachable') {
|
||||||
return { screen: 'recovery', variant: 'local-unavailable' };
|
return { screen: 'chooser', ...availability };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outcome.target === 'remote') {
|
if (outcome.target === 'remote') {
|
||||||
if (outcome.status === 'unreachable') {
|
if (outcome.status === 'unreachable') {
|
||||||
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url };
|
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||||
} else if (outcome.status === 'incompatible') {
|
} else if (outcome.status === 'incompatible') {
|
||||||
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url };
|
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||||
} else if (outcome.status === 'wrong-service') {
|
} else if (outcome.status === 'wrong-service') {
|
||||||
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url };
|
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||||
} else if (outcome.status === 'missing') {
|
} else if (outcome.status === 'missing') {
|
||||||
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId };
|
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId, ...availability };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
||||||
|
|
||||||
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
|
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
|
||||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
@@ -54,6 +54,12 @@ describe('resolveDesktopHostUrl', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('importDesktopHostPairing', () => {
|
||||||
|
test('rejects malformed pairing links before changing hosts', async () => {
|
||||||
|
await expect(importDesktopHostPairing('not-a-connect-link', [])).rejects.toThrow('invalid-connect-link');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('desktop host runtime headers', () => {
|
describe('desktop host runtime headers', () => {
|
||||||
test('parses persisted request headers from desktop config', async () => {
|
test('parses persisted request headers from desktop config', async () => {
|
||||||
await withDesktopBridge(async (cmd) => {
|
await withDesktopBridge(async (cmd) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
||||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||||
|
import { parsePairingConnectionPayload, type PairingEndpointCandidate } from '@/lib/connectionPayload';
|
||||||
|
|
||||||
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
@@ -79,6 +80,122 @@ export type DesktopHostsConfigInput = {
|
|||||||
localClientToken?: string | null;
|
localClientToken?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const desktopPlatformName = (): string | undefined => {
|
||||||
|
if (typeof navigator === 'undefined') return undefined;
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/Macintosh|Mac OS X/i.test(ua)) return 'macos';
|
||||||
|
if (/Windows/i.test(ua)) return 'windows';
|
||||||
|
if (/Linux/i.test(ua)) return 'linux';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importDesktopHostPairing = async (
|
||||||
|
link: string,
|
||||||
|
hosts: DesktopHost[],
|
||||||
|
): Promise<{ hosts: DesktopHost[]; hostId: string }> => {
|
||||||
|
const payload = parsePairingConnectionPayload(link);
|
||||||
|
if (!payload) throw new Error('invalid-connect-link');
|
||||||
|
|
||||||
|
const installId = await desktopInstallIdGet().catch(() => '');
|
||||||
|
const redeemInit: RequestInit = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pairingId: payload.pairingId,
|
||||||
|
secret: payload.secret,
|
||||||
|
clientLabel: payload.label || 'OpenChamber Desktop',
|
||||||
|
clientKind: 'desktop',
|
||||||
|
deviceName: 'OpenChamber Desktop',
|
||||||
|
devicePlatform: desktopPlatformName(),
|
||||||
|
...(installId ? { dedupeKey: `desktop:${installId}` } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const readToken = async (response: Response): Promise<string | null> => {
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const body = (await response.json().catch(() => null)) as { clientToken?: unknown } | null;
|
||||||
|
const token = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
|
||||||
|
return token || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let redeemed: { directUrl?: string; relay?: DesktopHostRelay; token: string } | null = null;
|
||||||
|
const candidates = [...payload.candidates].sort(
|
||||||
|
(a, b) => (a.type === 'relay' ? 1 : 0) - (b.type === 'relay' ? 1 : 0),
|
||||||
|
);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate.type === 'relay') {
|
||||||
|
const tunnel = createRelayTunnelClient({
|
||||||
|
relayUrl: candidate.relayUrl,
|
||||||
|
serverId: candidate.serverId,
|
||||||
|
hostEncPubJwk: candidate.hostEncPubJwk,
|
||||||
|
...(candidate.grant ? { grant: candidate.grant } : {}),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const token = await readToken(await tunnel.fetch('/api/client-auth/pairing/redeem', redeemInit));
|
||||||
|
if (token) {
|
||||||
|
redeemed = {
|
||||||
|
relay: { relayUrl: candidate.relayUrl, serverId: candidate.serverId, hostEncPubJwk: candidate.hostEncPubJwk },
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next advertised transport.
|
||||||
|
} finally {
|
||||||
|
tunnel.close();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const directUrl = normalizeHostUrl(candidate.url);
|
||||||
|
if (!directUrl) continue;
|
||||||
|
try {
|
||||||
|
const token = await readToken(await fetch(`${directUrl}/api/client-auth/pairing/redeem`, redeemInit));
|
||||||
|
if (token) {
|
||||||
|
redeemed = { directUrl, token };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next advertised transport.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!redeemed) throw new Error('pairing-redeem-failed');
|
||||||
|
|
||||||
|
const relayCandidate = payload.candidates.find(
|
||||||
|
(candidate): candidate is Extract<PairingEndpointCandidate, { type: 'relay' }> => candidate.type === 'relay',
|
||||||
|
);
|
||||||
|
const relay = redeemed.relay || (relayCandidate
|
||||||
|
? { relayUrl: relayCandidate.relayUrl, serverId: relayCandidate.serverId, hostEncPubJwk: relayCandidate.hostEncPubJwk }
|
||||||
|
: undefined);
|
||||||
|
const firstDirectUrl = payload.candidates
|
||||||
|
.filter((candidate): candidate is Extract<PairingEndpointCandidate, { type: 'lan' | 'tunnel' }> => candidate.type !== 'relay')
|
||||||
|
.map((candidate) => normalizeHostUrl(candidate.url))
|
||||||
|
.find((value): value is string => Boolean(value));
|
||||||
|
const directUrl = redeemed.directUrl || firstDirectUrl;
|
||||||
|
const url = directUrl || (relay ? relayHostDisplayUrl(relay.serverId) : null);
|
||||||
|
if (!url) throw new Error('pairing-missing-transport');
|
||||||
|
|
||||||
|
const existing = hosts.find((host) => (
|
||||||
|
relay ? host.relay?.serverId === relay.serverId : (!host.relay && normalizeHostUrl(host.apiUrl || host.url) === url)
|
||||||
|
));
|
||||||
|
const hostId = existing?.id || (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
|
const nextHost: DesktopHost = {
|
||||||
|
...(existing || {}),
|
||||||
|
id: hostId,
|
||||||
|
label: payload.label || existing?.label || redactSensitiveUrl(url),
|
||||||
|
url,
|
||||||
|
apiUrl: directUrl,
|
||||||
|
clientToken: redeemed.token,
|
||||||
|
...(relay ? { relay } : {}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
hostId,
|
||||||
|
hosts: existing
|
||||||
|
? hosts.map((host) => host.id === hostId ? nextHost : host)
|
||||||
|
: [nextHost, ...hosts],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type HostProbeResult = {
|
export type HostProbeResult = {
|
||||||
status: 'ok' | 'auth' | 'update-recommended' | 'incompatible' | 'wrong-service' | 'unreachable';
|
status: 'ok' | 'auth' | 'update-recommended' | 'incompatible' | 'wrong-service' | 'unreachable';
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user