feat(desktop): support remote runtime headers
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user