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>
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
||||
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
||||
|
||||
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');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
__OPENCHAMBER_DESKTOP__: {
|
||||
invoke: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previousWindow) {
|
||||
Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe('resolveDesktopHostUrl', () => {
|
||||
test('keeps regular host URLs unchanged', () => {
|
||||
@@ -32,3 +53,65 @@ describe('resolveDesktopHostUrl', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('desktop host runtime headers', () => {
|
||||
test('parses persisted request headers from desktop config', async () => {
|
||||
await withDesktopBridge(async (cmd) => {
|
||||
expect(cmd).toBe('desktop_hosts_get');
|
||||
return {
|
||||
hosts: [{
|
||||
id: 'remote-1',
|
||||
label: 'Remote',
|
||||
url: 'https://remote.example',
|
||||
requestHeaders: {
|
||||
' CF-Access-Client-Id ': ' client-id ',
|
||||
Authorization: 'Bearer should-not-be-read',
|
||||
'Bad:Name': 'bad',
|
||||
},
|
||||
}],
|
||||
defaultHostId: 'remote-1',
|
||||
initialHostChoiceCompleted: true,
|
||||
};
|
||||
}, async () => {
|
||||
const config = await desktopHostsGet();
|
||||
expect(config.hosts[0]?.requestHeaders).toEqual({
|
||||
'CF-Access-Client-Id': 'client-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('passes request headers through host save and probe IPC calls', async () => {
|
||||
const calls: Array<{ cmd: string; args: Record<string, unknown> }> = [];
|
||||
await withDesktopBridge(async (cmd, args) => {
|
||||
calls.push({ cmd, args });
|
||||
if (cmd === 'desktop_host_probe') return { status: 'ok', latencyMs: 7 };
|
||||
return null;
|
||||
}, async () => {
|
||||
const requestHeaders = { 'CF-Access-Client-Id': 'client-id' };
|
||||
await desktopHostsSet({
|
||||
hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders }],
|
||||
defaultHostId: 'remote-1',
|
||||
});
|
||||
const probe = await desktopHostProbe('https://remote.example', { requestHeaders });
|
||||
expect(probe).toEqual({ status: 'ok', latencyMs: 7 });
|
||||
});
|
||||
|
||||
expect(calls[0]).toEqual({
|
||||
cmd: 'desktop_hosts_set',
|
||||
args: {
|
||||
input: {
|
||||
hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders: { 'CF-Access-Client-Id': 'client-id' } }],
|
||||
defaultHostId: 'remote-1',
|
||||
initialHostChoiceCompleted: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(calls[1]).toEqual({
|
||||
cmd: 'desktop_host_probe',
|
||||
args: {
|
||||
url: 'https://remote.example',
|
||||
requestHeaders: { 'CF-Access-Client-Id': 'client-id' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,25 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
||||
|
||||
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization';
|
||||
|
||||
const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | undefined => {
|
||||
if (!isRecord(headers)) return undefined;
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const name = key.trim();
|
||||
const headerValue = typeof value === 'string' ? value.trim() : '';
|
||||
if (!name || !headerValue || /[\r\n:]/.test(name) || /[\r\n]/.test(headerValue)) continue;
|
||||
if (isReservedRequestHeaderName(name)) continue;
|
||||
next[name] = headerValue;
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
export type DesktopHost = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -11,6 +30,8 @@ export type DesktopHost = {
|
||||
apiUrl?: string;
|
||||
/** Remote client bearer token for packaged-client API access. */
|
||||
clientToken?: string;
|
||||
/** Extra headers for desktop runtime API requests. */
|
||||
requestHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type DesktopHostsConfig = {
|
||||
@@ -135,10 +156,6 @@ export const locationMatchesHost = (locationHref: string, hostUrl: string): bool
|
||||
}
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
const readString = (obj: Record<string, unknown>, key: string): string | null => {
|
||||
const val = obj[key];
|
||||
return typeof val === 'string' ? val : null;
|
||||
@@ -156,6 +173,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
const url = readString(value, 'url');
|
||||
const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url');
|
||||
const clientToken = readString(value, 'clientToken') || readString(value, 'client_token');
|
||||
const requestHeaders = sanitizeRequestHeaders(value.requestHeaders);
|
||||
if (!id || !label || !url) return null;
|
||||
return {
|
||||
id,
|
||||
@@ -163,6 +181,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
url,
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(clientToken ? { clientToken } : {}),
|
||||
...(requestHeaders ? { requestHeaders } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -226,13 +245,13 @@ export const desktopLocalClientTokenGet = async (): Promise<string> => {
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
|
||||
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise<HostProbeResult> => {
|
||||
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
return { status: 'unreachable', latencyMs: 0 };
|
||||
}
|
||||
|
||||
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined });
|
||||
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined });
|
||||
if (!isRecord(raw)) {
|
||||
return { status: 'unreachable', latencyMs: 0 };
|
||||
}
|
||||
@@ -247,8 +266,8 @@ export const desktopHostProbe = async (url: string, options?: { clientToken?: st
|
||||
return { status, latencyMs };
|
||||
};
|
||||
|
||||
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise<void> => {
|
||||
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined });
|
||||
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined });
|
||||
};
|
||||
|
||||
@@ -258,6 +258,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)',
|
||||
'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': 'Add Server',
|
||||
'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
|
||||
@@ -225,6 +225,12 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port",
|
||||
"settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)",
|
||||
"settings.remoteInstances.direct.note": "Los tokens de conexión se guardan en este dispositivo y solo se usan cuando esta app se conecta a ese servidor.",
|
||||
"settings.remoteInstances.direct.headers.title": "Additional headers",
|
||||
"settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.",
|
||||
"settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name",
|
||||
"settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value",
|
||||
"settings.remoteInstances.direct.headers.actions.add": "Add header",
|
||||
"settings.remoteInstances.direct.headers.removeAria": "Remove header",
|
||||
"settings.remoteInstances.direct.actions.add": "Añadir servidor",
|
||||
"settings.remoteInstances.direct.import.description": "Pega un enlace de conexión de otro servidor de OpenChamber.",
|
||||
"settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...",
|
||||
|
||||
@@ -1729,6 +1729,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://hôte:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token de connexion (facultatif pour les serveurs locaux de confiance)',
|
||||
'settings.remoteInstances.direct.note': 'Les tokens de connexion sont enregistrés sur cet appareil et utilisés uniquement lorsque cette application se connecte à ce serveur.',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': 'Ajouter le serveur',
|
||||
'settings.remoteInstances.direct.import.description': 'Collez un lien de connexion provenant d’un autre serveur OpenChamber.',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
|
||||
@@ -258,6 +258,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)',
|
||||
'settings.remoteInstances.direct.note': '接続 Token はこのデバイスに保存され、このアプリがそのサーバーに接続するときにのみ使用されます。',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': 'サーバーを追加',
|
||||
'settings.remoteInstances.direct.import.description': '別の OpenChamber サーバーからの接続リンクを貼り付けてください。',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
|
||||
@@ -225,6 +225,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)',
|
||||
'settings.remoteInstances.direct.note': '연결 토큰은 이 기기에 저장되며 이 앱이 해당 서버에 연결할 때만 사용됩니다.',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': '서버 추가',
|
||||
'settings.remoteInstances.direct.import.description': '다른 OpenChamber 서버에서 만든 연결 링크를 붙여넣으세요.',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
|
||||
@@ -1434,6 +1434,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)',
|
||||
'settings.remoteInstances.direct.note': 'Tokeny połączenia są zapisywane na tym urządzeniu i używane tylko wtedy, gdy ta aplikacja łączy się z danym serwerem.',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': 'Dodaj serwer',
|
||||
'settings.remoteInstances.direct.import.description': 'Wklej link połączenia z innego serwera OpenChamber.',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
|
||||
@@ -225,6 +225,12 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port",
|
||||
"settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)",
|
||||
"settings.remoteInstances.direct.note": "Os tokens de conexão ficam salvos neste dispositivo e são usados apenas quando este app se conecta a esse servidor.",
|
||||
"settings.remoteInstances.direct.headers.title": "Additional headers",
|
||||
"settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.",
|
||||
"settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name",
|
||||
"settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value",
|
||||
"settings.remoteInstances.direct.headers.actions.add": "Add header",
|
||||
"settings.remoteInstances.direct.headers.removeAria": "Remove header",
|
||||
"settings.remoteInstances.direct.actions.add": "Adicionar servidor",
|
||||
"settings.remoteInstances.direct.import.description": "Cole um link de conexão de outro servidor OpenChamber.",
|
||||
"settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...",
|
||||
|
||||
@@ -225,6 +225,12 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port",
|
||||
"settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)",
|
||||
"settings.remoteInstances.direct.note": "Токени підключення зберігаються на цьому пристрої й використовуються лише коли цей застосунок підключається до відповідного сервера.",
|
||||
"settings.remoteInstances.direct.headers.title": "Additional headers",
|
||||
"settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.",
|
||||
"settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name",
|
||||
"settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value",
|
||||
"settings.remoteInstances.direct.headers.actions.add": "Add header",
|
||||
"settings.remoteInstances.direct.headers.removeAria": "Remove header",
|
||||
"settings.remoteInstances.direct.actions.add": "Додати сервер",
|
||||
"settings.remoteInstances.direct.import.description": "Вставте посилання для підключення з іншого сервера OpenChamber.",
|
||||
"settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...",
|
||||
|
||||
@@ -225,6 +225,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)',
|
||||
'settings.remoteInstances.direct.note': '连接令牌会保存在此设备上,并且只在此应用连接到该服务器时使用。',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': '添加服务器',
|
||||
'settings.remoteInstances.direct.import.description': '粘贴来自另一个 OpenChamber 服务器的连接链接。',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
|
||||
@@ -231,6 +231,12 @@
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)',
|
||||
'settings.remoteInstances.direct.note': '直接連線假設遠端伺服器已在執行並可從此裝置存取。',
|
||||
'settings.remoteInstances.direct.headers.title': 'Additional headers',
|
||||
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
|
||||
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
|
||||
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
|
||||
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
|
||||
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
|
||||
'settings.remoteInstances.direct.actions.add': '新增直接連線',
|
||||
'settings.remoteInstances.direct.import.description': '貼上 connect-url 輸出或 openchamber://connect 連結來匯入。',
|
||||
'settings.remoteInstances.direct.import.placeholder': '貼上連線連結',
|
||||
|
||||
@@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildRuntimeAuthHeaders,
|
||||
clearRuntimeAuthCredentialProvider,
|
||||
clearRuntimeUrlAuthToken,
|
||||
getRuntimeBearerTokenSync,
|
||||
refreshRuntimeUrlAuthToken,
|
||||
setRuntimeAuthCredentialProvider,
|
||||
setRuntimeBearerToken,
|
||||
setRuntimeExtraHeaders,
|
||||
} from './runtime-auth';
|
||||
|
||||
describe('runtime auth headers', () => {
|
||||
@@ -60,4 +63,56 @@ describe('runtime auth headers', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('adds runtime extra headers without overriding bearer authorization', async () => {
|
||||
try {
|
||||
setRuntimeBearerToken('runtime-token');
|
||||
setRuntimeExtraHeaders({
|
||||
'CF-Access-Client-Id': 'client-id',
|
||||
Authorization: 'Bearer proxy-token',
|
||||
});
|
||||
|
||||
const headers = await buildRuntimeAuthHeaders();
|
||||
|
||||
expect(headers.get('CF-Access-Client-Id')).toBe('client-id');
|
||||
expect(headers.get('Authorization')).toBe('Bearer runtime-token');
|
||||
} finally {
|
||||
setRuntimeExtraHeaders(null);
|
||||
clearRuntimeAuthCredentialProvider();
|
||||
}
|
||||
});
|
||||
|
||||
test('sends runtime extra headers when minting URL auth tokens', async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
let seenHeaders = new Headers();
|
||||
try {
|
||||
clearRuntimeUrlAuthToken();
|
||||
setRuntimeBearerToken('runtime-token');
|
||||
setRuntimeExtraHeaders({
|
||||
'CF-Access-Client-Id': 'client-id',
|
||||
Authorization: 'Bearer proxy-token',
|
||||
});
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenUrl = String(input);
|
||||
seenHeaders = new Headers(init?.headers);
|
||||
return new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const token = await refreshRuntimeUrlAuthToken('https://runtime.example');
|
||||
|
||||
expect(token).toBe('url-token');
|
||||
expect(seenUrl).toBe('https://runtime.example/auth/url-token');
|
||||
expect(seenHeaders.get('CF-Access-Client-Id')).toBe('client-id');
|
||||
expect(seenHeaders.get('Authorization')).toBe('Bearer runtime-token');
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
clearRuntimeUrlAuthToken();
|
||||
setRuntimeExtraHeaders(null);
|
||||
clearRuntimeAuthCredentialProvider();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promis
|
||||
|
||||
let credentialProvider: RuntimeAuthCredentialProvider = () => null;
|
||||
let runtimeBearerToken = '';
|
||||
let runtimeExtraHeaders: Record<string, string> = {};
|
||||
let runtimeUrlAuthToken = '';
|
||||
let runtimeUrlAuthTokenExpiresAt = 0;
|
||||
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
||||
@@ -13,6 +14,18 @@ let runtimeAuthGeneration = 0;
|
||||
|
||||
const URL_AUTH_REFRESH_SKEW_MS = 10_000;
|
||||
|
||||
const isReservedRuntimeExtraHeaderName = (name: string): boolean => name.toLowerCase() === 'authorization';
|
||||
|
||||
const sanitizeRuntimeExtraHeaders = (headers: Record<string, string> | null | undefined): Record<string, string> => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers || {})) {
|
||||
const name = key.trim();
|
||||
const headerValue = value.trim();
|
||||
if (name && headerValue && !isReservedRuntimeExtraHeaderName(name)) next[name] = headerValue;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const normalizeBearerToken = (token: string | null | undefined): string => {
|
||||
if (typeof token !== 'string') return '';
|
||||
return token.trim();
|
||||
@@ -74,6 +87,20 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void =>
|
||||
credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null;
|
||||
};
|
||||
|
||||
export const setRuntimeExtraHeaders = (headers: Record<string, string> | null | undefined): void => {
|
||||
// These headers are for runtime HTTP fetches and URL-token minting. Browser-owned
|
||||
// realtime transports (EventSource/WebSocket) cannot attach arbitrary headers.
|
||||
runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers);
|
||||
resetRuntimeAuthGeneration();
|
||||
};
|
||||
|
||||
export const getRuntimeExtraHeadersSync = (): Record<string, string> => {
|
||||
if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders;
|
||||
if (typeof window === 'undefined') return {};
|
||||
const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string> }).__OPENCHAMBER_RUNTIME_HEADERS__;
|
||||
return injected && typeof injected === 'object' ? sanitizeRuntimeExtraHeaders(injected) : {};
|
||||
};
|
||||
|
||||
export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken();
|
||||
|
||||
export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => {
|
||||
@@ -127,6 +154,9 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
|
||||
const refreshPromise = (async () => {
|
||||
const credential = await getRuntimeAuthCredential();
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
if (credential?.type === 'bearer') {
|
||||
headers.set('Authorization', `Bearer ${credential.token}`);
|
||||
}
|
||||
@@ -257,6 +287,9 @@ export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void)
|
||||
|
||||
export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise<Headers> => {
|
||||
const next = new Headers(headers);
|
||||
for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) {
|
||||
if (!next.has(key)) next.set(key, value);
|
||||
}
|
||||
if (next.has('Authorization')) {
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@/lib/runtime-auth';
|
||||
import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth';
|
||||
import { configureRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
|
||||
export type RuntimeEndpointChangedDetail = {
|
||||
@@ -68,7 +68,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null;
|
||||
activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl));
|
||||
};
|
||||
|
||||
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null }): void => {
|
||||
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record<string, string> | null }): void => {
|
||||
const apiBaseUrl = options.apiBaseUrl.trim();
|
||||
const previousApiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const previousRuntimeKey = getRuntimeKey();
|
||||
@@ -79,11 +79,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
|
||||
const runtimeWindow = window as typeof window & {
|
||||
__OPENCHAMBER_API_BASE_URL__?: string;
|
||||
__OPENCHAMBER_CLIENT_TOKEN__?: string;
|
||||
__OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string>;
|
||||
};
|
||||
runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl;
|
||||
runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined;
|
||||
runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined;
|
||||
}
|
||||
configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl });
|
||||
setRuntimeExtraHeaders(options.requestHeaders || null);
|
||||
setRuntimeBearerToken(options.clientToken || null);
|
||||
void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {});
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -404,7 +404,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
page: 'remote-instances',
|
||||
titleKey: 'settings.remoteInstances.direct.title',
|
||||
descriptionKey: 'settings.remoteInstances.direct.description',
|
||||
keywords: ['server url', 'connection token', 'import link', 'host switcher'],
|
||||
keywords: ['server url', 'connection token', 'import link', 'host switcher', 'additional headers', 'request headers', 'cloudflare access', 'service token'],
|
||||
isAvailable: (ctx) => ctx.isDesktop,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user