feat(desktop): support remote runtime headers

This commit is contained in:
Bohdan Triapitsyn
2026-06-30 00:30:48 +03:00
parent 9c1eb755f9
commit 359c73fcf3
23 changed files with 458 additions and 45 deletions
@@ -424,7 +424,7 @@ export function DesktopHostSwitcherDialog({
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
@@ -492,7 +492,7 @@ export function DesktopHostSwitcherDialog({
if (!apiOrigin) return;
setSwitchingHostId(host.id);
const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || '');
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
@@ -504,7 +504,7 @@ export function DesktopHostSwitcherDialog({
return;
}
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) });
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) });
onHostSwitched?.();
setSwitchingHostId(null);
return;
@@ -598,7 +598,7 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
@@ -646,7 +646,7 @@ export function DesktopHostSwitcherDialog({
const url = resolved.persistedUrl;
const label = (editLabel || redactSensitiveUrl(url)).trim();
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url, apiUrl: url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
if (resolved.redeemUrl) {
@@ -663,7 +663,7 @@ export function DesktopHostSwitcherDialog({
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => {
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
@@ -199,6 +199,36 @@ const formatLogLine = (line: string): string => {
return `[${iso}] [${level}] ${message}`;
};
type HeaderDraft = {
id: string;
name: string;
value: string;
};
const createHeaderDraft = (name = '', value = ''): HeaderDraft => ({
id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `header-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name,
value,
});
const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization';
const buildRequestHeaders = (headers: HeaderDraft[]): Record<string, string> | undefined => {
const next: Record<string, string> = {};
for (const header of headers) {
const name = header.name.trim();
const value = header.value.trim();
if (name && value && !isReservedRequestHeaderName(name)) next[name] = value;
}
return Object.keys(next).length > 0 ? next : undefined;
};
const readRequestHeaderDrafts = (headers: Record<string, string> | undefined): HeaderDraft[] => {
return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value));
};
const navigateToUrl = (rawUrl: string): void => {
const target = rawUrl.trim();
if (!target) {
@@ -301,6 +331,7 @@ export const RemoteInstancesPage: React.FC = () => {
const [directLabel, setDirectLabel] = React.useState('');
const [directUrl, setDirectUrl] = React.useState('');
const [directToken, setDirectToken] = React.useState('');
const [directHeaders, setDirectHeaders] = React.useState<HeaderDraft[]>([]);
const [directConnectLink, setDirectConnectLink] = React.useState('');
const [directError, setDirectError] = React.useState<string | null>(null);
const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
@@ -309,6 +340,7 @@ export const RemoteInstancesPage: React.FC = () => {
const [directEditLabel, setDirectEditLabel] = React.useState('');
const [directEditUrl, setDirectEditUrl] = React.useState('');
const [directEditToken, setDirectEditToken] = React.useState('');
const [directEditHeaders, setDirectEditHeaders] = React.useState<HeaderDraft[]>([]);
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
@@ -374,16 +406,18 @@ export const RemoteInstancesPage: React.FC = () => {
url,
apiUrl: url,
...(directToken.trim() ? { clientToken: directToken.trim() } : {}),
...(buildRequestHeaders(directHeaders) ? { requestHeaders: buildRequestHeaders(directHeaders) } : {}),
};
await persistDirectHosts([host, ...directHosts], directDefaultHostId);
setDirectLabel('');
setDirectUrl('');
setDirectToken('');
setDirectHeaders([]);
setDirectAddDialogOpen(false);
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
}, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
}, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
const importDirectConnectLink = React.useCallback(async () => {
const payload = parseClientConnectionPayload(directConnectLink);
@@ -427,6 +461,7 @@ export const RemoteInstancesPage: React.FC = () => {
setDirectEditLabel(host.label);
setDirectEditUrl(host.apiUrl || host.url);
setDirectEditToken(host.clientToken || '');
setDirectEditHeaders(readRequestHeaderDrafts(host.requestHeaders));
setDirectError(null);
}, []);
@@ -445,6 +480,7 @@ export const RemoteInstancesPage: React.FC = () => {
url,
apiUrl: url,
clientToken: directEditToken.trim() || undefined,
requestHeaders: buildRequestHeaders(directEditHeaders),
}
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
@@ -452,7 +488,7 @@ export const RemoteInstancesPage: React.FC = () => {
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
}, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
}, [directDefaultHostId, directEditHeaders, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
const createSshInstanceFromDialog = React.useCallback(async () => {
const command = sshCommandDraft.trim();
@@ -1095,6 +1131,25 @@ export const RemoteInstancesPage: React.FC = () => {
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
</div>
{directHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
<Input className="h-8 font-mono text-xs" value={header.name} onChange={(event) => setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} />
<Input className="h-8 font-mono text-xs" value={header.value} onChange={(event) => setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} />
<button type="button" onClick={() => setDirectHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
))}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setDirectHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.headers.actions.add')}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directUrl.trim()}>{t('settings.remoteInstances.direct.actions.add')}</Button>
@@ -1113,6 +1168,25 @@ export const RemoteInstancesPage: React.FC = () => {
<Input className="h-8" value={directEditLabel} onChange={(event) => setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
</div>
{directEditHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
<Input className="h-8 font-mono text-xs" value={header.name} onChange={(event) => setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} />
<Input className="h-8 font-mono text-xs" value={header.value} onChange={(event) => setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} />
<button type="button" onClick={() => setDirectEditHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
))}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setDirectEditHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.headers.actions.add')}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving}>{t('settings.common.actions.saveChanges')}</Button>