Add custom headers to direct connections (#2922)
* Add custom headers to direct connections * Address custom header review feedback * Fix direct connection header integration * Fix desktop relay E2E transport setup --------- Co-authored-by: Claude <claude@Claudes-Mac-mini.local> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Claude
Mohamed Boudra
parent
098122be8d
commit
73ba37a07a
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||
import { getIsElectron, isNative } from "@/constants/platform";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import {
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
prepareConnectionHeaders,
|
||||
type ConnectionHeaderDraft,
|
||||
type ConnectionHeaderValidationIssue,
|
||||
} from "@/utils/connection-headers";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -123,6 +129,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
advancedContent: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
headerSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
headerList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
@@ -181,6 +211,94 @@ function prepareDirectConnection(
|
||||
};
|
||||
}
|
||||
|
||||
function headerIssueMessage(
|
||||
issue: ConnectionHeaderValidationIssue,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (issue.type === "missingName") return t("pairing.direct.headers.errors.missingName");
|
||||
if (issue.type === "invalidName") {
|
||||
return t("pairing.direct.headers.errors.invalidName", { name: issue.name });
|
||||
}
|
||||
if (issue.type === "invalidValue") {
|
||||
return t("pairing.direct.headers.errors.invalidValue", { name: issue.name });
|
||||
}
|
||||
return t("pairing.direct.headers.errors.duplicateName", { name: issue.name });
|
||||
}
|
||||
|
||||
interface ConnectionHeaderRowProps {
|
||||
draft: ConnectionHeaderDraft;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
placeholderTextColor: string;
|
||||
iconColor: string;
|
||||
onUpdate: (id: number, field: "name" | "value", value: string) => void;
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
function ConnectionHeaderRow({
|
||||
draft,
|
||||
index,
|
||||
disabled,
|
||||
placeholderTextColor,
|
||||
iconColor,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: ConnectionHeaderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleNameChange = useCallback(
|
||||
(value: string) => onUpdate(draft.id, "name", value),
|
||||
[draft.id, onUpdate],
|
||||
);
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => onUpdate(draft.id, "value", value),
|
||||
[draft.id, onUpdate],
|
||||
);
|
||||
const handleRemove = useCallback(() => onRemove(draft.id), [draft.id, onRemove]);
|
||||
|
||||
return (
|
||||
<View style={styles.headerRow}>
|
||||
<AdaptiveTextInput
|
||||
testID={`direct-header-name-${index}`}
|
||||
nativeID={`direct-header-name-${draft.id}`}
|
||||
accessibilityLabel={t("pairing.direct.headers.name")}
|
||||
initialValue={draft.name}
|
||||
value={draft.name}
|
||||
onChangeText={handleNameChange}
|
||||
placeholder={t("pairing.direct.headers.name")}
|
||||
placeholderTextColor={placeholderTextColor}
|
||||
style={[styles.input, styles.headerInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!disabled}
|
||||
/>
|
||||
<AdaptiveTextInput
|
||||
testID={`direct-header-value-${index}`}
|
||||
nativeID={`direct-header-value-${draft.id}`}
|
||||
accessibilityLabel={t("pairing.direct.headers.value")}
|
||||
initialValue={draft.value}
|
||||
value={draft.value}
|
||||
onChangeText={handleValueChange}
|
||||
placeholder={t("pairing.direct.headers.value")}
|
||||
placeholderTextColor={placeholderTextColor}
|
||||
style={[styles.input, styles.headerInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!disabled}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleRemove}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.direct.headers.remove")}
|
||||
testID={`direct-header-remove-${index}`}
|
||||
>
|
||||
<X size={18} color={iconColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
const parsed = parseConnectionUri(uri);
|
||||
return {
|
||||
@@ -305,6 +423,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [advancedUri, setAdvancedUri] = useState("");
|
||||
const [headerDrafts, setHeaderDrafts] = useState<ConnectionHeaderDraft[]>([]);
|
||||
const nextHeaderId = useRef(1);
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
@@ -315,6 +435,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
setHeaderDrafts([]);
|
||||
nextHeaderId.current = 1;
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
@@ -369,11 +491,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
if (isSaving) return;
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
let headers: Record<string, string> | undefined;
|
||||
try {
|
||||
connection = prepareDirectConnection(
|
||||
{ host, port, useTls, password },
|
||||
directConnectionLabels,
|
||||
);
|
||||
const preparedHeaders = prepareConnectionHeaders(headerDrafts);
|
||||
if (preparedHeaders.issue) {
|
||||
setErrorMessage(headerIssueMessage(preparedHeaders.issue, t));
|
||||
return;
|
||||
}
|
||||
headers = preparedHeaders.headers;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
|
||||
@@ -389,6 +518,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls,
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
|
||||
@@ -425,6 +555,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
headerDrafts,
|
||||
host,
|
||||
isMobile,
|
||||
isSaving,
|
||||
@@ -453,6 +584,22 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handleAddHeader = useCallback(() => {
|
||||
const id = nextHeaderId.current;
|
||||
nextHeaderId.current += 1;
|
||||
setHeaderDrafts((current) => [...current, { id, name: "", value: "" }]);
|
||||
}, []);
|
||||
|
||||
const handleUpdateHeader = useCallback((id: number, field: "name" | "value", value: string) => {
|
||||
setHeaderDrafts((current) =>
|
||||
current.map((draft) => (draft.id === id ? { ...draft, [field]: value } : draft)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRemoveHeader = useCallback((id: number) => {
|
||||
setHeaderDrafts((current) => current.filter((draft) => draft.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
try {
|
||||
@@ -609,24 +756,58 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
<View style={styles.advancedContent}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
|
||||
{isNative || getIsElectron() ? (
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={styles.label}>{t("pairing.direct.headers.title")}</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={handleAddHeader}
|
||||
disabled={isSaving}
|
||||
testID="direct-header-add"
|
||||
>
|
||||
{t("pairing.direct.headers.add")}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.headerList}>
|
||||
{headerDrafts.map((draft, index) => (
|
||||
<ConnectionHeaderRow
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
index={index}
|
||||
disabled={isSaving}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
onUpdate={handleUpdateHeader}
|
||||
onRemove={handleRemoveHeader}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
|
||||
import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
createDesktopWebSocketTransportFactory,
|
||||
} from "./desktop-daemon-transport";
|
||||
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
|
||||
|
||||
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
|
||||
@@ -43,4 +46,25 @@ describe("desktop-daemon-transport", () => {
|
||||
expect(rpc.closedSessions).toEqual(["local-session-2"]);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards remote WebSocket headers and protocols through the desktop bridge", () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const transportFactory = createDesktopWebSocketTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
transportFactory!({
|
||||
url: "wss://daemon.example/ws",
|
||||
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
|
||||
protocols: ["paseo.bearer.secret"],
|
||||
});
|
||||
|
||||
expect(rpc.openCalls).toEqual([
|
||||
{
|
||||
transportType: "websocket",
|
||||
url: "wss://daemon.example/ws",
|
||||
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
|
||||
protocols: ["paseo.bearer.secret"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import type {
|
||||
DaemonTransport,
|
||||
DaemonTransportFactory,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import type { DesktopDaemonTransportTarget, LocalTransportTarget } from "./desktop-daemon";
|
||||
import {
|
||||
defaultLocalDaemonTransportRpc,
|
||||
defaultWebSocketDaemonTransportRpc,
|
||||
type LocalDaemonTransportRpc,
|
||||
} from "./local-daemon-transport-rpc";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
|
||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||
|
||||
@@ -50,11 +52,12 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopLocalDaemonTransportFactory(
|
||||
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
return ({ url }) => {
|
||||
const target = parseLocalDaemonTransportUrl(url);
|
||||
function createDesktopDaemonTransportFactory(
|
||||
resolveTarget: (options: Parameters<DaemonTransportFactory>[0]) => DesktopDaemonTransportTarget,
|
||||
rpc: LocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory {
|
||||
return (options) => {
|
||||
const target = resolveTarget(options);
|
||||
let sessionId: string | null = null;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
@@ -187,3 +190,26 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
return transport;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopLocalDaemonTransportFactory(
|
||||
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
return createDesktopDaemonTransportFactory(({ url }) => parseLocalDaemonTransportUrl(url), rpc);
|
||||
}
|
||||
|
||||
export function createDesktopWebSocketTransportFactory(
|
||||
rpc?: LocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
if (!rpc && !isElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return createDesktopDaemonTransportFactory(
|
||||
({ url, headers, protocols }) => ({
|
||||
transportType: "websocket" as const,
|
||||
url,
|
||||
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(protocols && protocols.length > 0 ? { protocols } : {}),
|
||||
}),
|
||||
rpc ?? defaultWebSocketDaemonTransportRpc,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface LocalTransportTarget {
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface WebSocketTransportTarget {
|
||||
[key: string]: unknown;
|
||||
transportType: "websocket";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export type DesktopDaemonTransportTarget = LocalTransportTarget | WebSocketTransportTarget;
|
||||
|
||||
interface LocalTransportEventPayload {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -193,6 +203,16 @@ export async function openLocalTransportSession(target: LocalTransportTarget): P
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function openWebSocketTransportSession(
|
||||
target: WebSocketTransportTarget,
|
||||
): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("open_websocket_daemon_transport", target);
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("Unexpected WebSocket transport session response.");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
closeLocalTransportSession,
|
||||
listenToLocalTransportEvents,
|
||||
openLocalTransportSession,
|
||||
openWebSocketTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
type LocalTransportTarget,
|
||||
type DesktopDaemonTransportTarget,
|
||||
type WebSocketTransportTarget,
|
||||
} from "./desktop-daemon";
|
||||
|
||||
export interface LocalDaemonTransportEvent {
|
||||
@@ -17,7 +19,7 @@ export interface LocalDaemonTransportEvent {
|
||||
}
|
||||
|
||||
export interface LocalDaemonTransportRpc {
|
||||
openSession(target: LocalTransportTarget): Promise<string>;
|
||||
openSession(target: DesktopDaemonTransportTarget): Promise<string>;
|
||||
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
|
||||
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
|
||||
closeSession(sessionId: string): Promise<void>;
|
||||
@@ -29,3 +31,10 @@ export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = {
|
||||
sendMessage: sendLocalTransportMessage,
|
||||
closeSession: closeLocalTransportSession,
|
||||
};
|
||||
|
||||
export const defaultWebSocketDaemonTransportRpc: LocalDaemonTransportRpc = {
|
||||
openSession: (target) => openWebSocketTransportSession(target as WebSocketTransportTarget),
|
||||
listenToEvents: listenToLocalTransportEvents,
|
||||
sendMessage: sendLocalTransportMessage,
|
||||
closeSession: closeLocalTransportSession,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
|
||||
import type {
|
||||
LocalDaemonTransportEvent,
|
||||
LocalDaemonTransportRpc,
|
||||
@@ -11,7 +11,7 @@ export interface RecordedSend {
|
||||
}
|
||||
|
||||
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
readonly openCalls: LocalTransportTarget[];
|
||||
readonly openCalls: DesktopDaemonTransportTarget[];
|
||||
readonly recordedSends: RecordedSend[];
|
||||
readonly closedSessions: string[];
|
||||
resolveOpen(sessionId: string): void;
|
||||
@@ -22,7 +22,7 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
}
|
||||
|
||||
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
|
||||
const openCalls: LocalTransportTarget[] = [];
|
||||
const openCalls: DesktopDaemonTransportTarget[] = [];
|
||||
const recordedSends: RecordedSend[] = [];
|
||||
const closedSessions: string[] = [];
|
||||
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
|
||||
|
||||
@@ -1405,6 +1405,19 @@ export const ar: TranslationResources = {
|
||||
show: "عرض متقدم",
|
||||
hide: "إخفاء المتقدمة",
|
||||
},
|
||||
headers: {
|
||||
title: "رؤوس مخصصة",
|
||||
add: "إضافة رأس",
|
||||
name: "الاسم",
|
||||
value: "القيمة",
|
||||
remove: "إزالة الرأس",
|
||||
errors: {
|
||||
missingName: "أدخل اسمًا لكل رأس",
|
||||
invalidName: "{{name}} ليس اسم رأس صالحًا",
|
||||
invalidValue: "يحتوي {{name}} على فاصل أسطر غير صالح",
|
||||
duplicateName: "تم إدخال {{name}} أكثر من مرة",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
|
||||
@@ -1416,6 +1416,19 @@ export const en = {
|
||||
show: "Show advanced",
|
||||
hide: "Hide advanced",
|
||||
},
|
||||
headers: {
|
||||
title: "Custom headers",
|
||||
add: "Add header",
|
||||
name: "Name",
|
||||
value: "Value",
|
||||
remove: "Remove header",
|
||||
errors: {
|
||||
missingName: "Enter a name for each header",
|
||||
invalidName: "{{name}} is not a valid header name",
|
||||
invalidValue: "{{name}} contains an invalid line break",
|
||||
duplicateName: "{{name}} is entered more than once",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Show password",
|
||||
hide: "Hide password",
|
||||
|
||||
@@ -1446,6 +1446,19 @@ export const es: TranslationResources = {
|
||||
show: "Mostrar avanzado",
|
||||
hide: "Ocultar avanzado",
|
||||
},
|
||||
headers: {
|
||||
title: "Encabezados personalizados",
|
||||
add: "Añadir encabezado",
|
||||
name: "Nombre",
|
||||
value: "Valor",
|
||||
remove: "Eliminar encabezado",
|
||||
errors: {
|
||||
missingName: "Introduce un nombre para cada encabezado",
|
||||
invalidName: "{{name}} no es un nombre de encabezado válido",
|
||||
invalidValue: "{{name}} contiene un salto de línea no válido",
|
||||
duplicateName: "{{name}} se ha introducido más de una vez",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar contraseña",
|
||||
hide: "Ocultar contraseña",
|
||||
|
||||
@@ -1450,6 +1450,19 @@ export const fr: TranslationResources = {
|
||||
show: "Afficher avancé",
|
||||
hide: "Masquer avancé",
|
||||
},
|
||||
headers: {
|
||||
title: "En-têtes personnalisés",
|
||||
add: "Ajouter un en-tête",
|
||||
name: "Nom",
|
||||
value: "Valeur",
|
||||
remove: "Supprimer l'en-tête",
|
||||
errors: {
|
||||
missingName: "Saisissez un nom pour chaque en-tête",
|
||||
invalidName: "{{name}} n'est pas un nom d'en-tête valide",
|
||||
invalidValue: "{{name}} contient un saut de ligne non valide",
|
||||
duplicateName: "{{name}} est saisi plusieurs fois",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Afficher le mot de passe",
|
||||
hide: "Masquer le mot de passe",
|
||||
|
||||
@@ -1419,6 +1419,19 @@ export const ja: TranslationResources = {
|
||||
show: "詳細設定を表示",
|
||||
hide: "詳細設定を非表示",
|
||||
},
|
||||
headers: {
|
||||
title: "カスタムヘッダー",
|
||||
add: "ヘッダーを追加",
|
||||
name: "名前",
|
||||
value: "値",
|
||||
remove: "ヘッダーを削除",
|
||||
errors: {
|
||||
missingName: "各ヘッダーの名前を入力してください",
|
||||
invalidName: "{{name}} は有効なヘッダー名ではありません",
|
||||
invalidValue: "{{name}} に無効な改行が含まれています",
|
||||
duplicateName: "{{name}} が複数回入力されています",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
|
||||
@@ -1415,6 +1415,19 @@ export const ko: TranslationResources = {
|
||||
show: "고급 표시",
|
||||
hide: "고급 숨기기",
|
||||
},
|
||||
headers: {
|
||||
title: "사용자 지정 헤더",
|
||||
add: "헤더 추가",
|
||||
name: "이름",
|
||||
value: "값",
|
||||
remove: "헤더 삭제",
|
||||
errors: {
|
||||
missingName: "각 헤더의 이름을 입력하세요",
|
||||
invalidName: "{{name}}은(는) 올바른 헤더 이름이 아닙니다",
|
||||
invalidValue: "{{name}}에 잘못된 줄바꿈이 있습니다",
|
||||
duplicateName: "{{name}}이(가) 두 번 이상 입력되었습니다",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
|
||||
@@ -1432,6 +1432,19 @@ export const ptBR: TranslationResources = {
|
||||
show: "Mostrar avançado",
|
||||
hide: "Ocultar avançado",
|
||||
},
|
||||
headers: {
|
||||
title: "Cabeçalhos personalizados",
|
||||
add: "Adicionar cabeçalho",
|
||||
name: "Nome",
|
||||
value: "Valor",
|
||||
remove: "Remover cabeçalho",
|
||||
errors: {
|
||||
missingName: "Digite um nome para cada cabeçalho",
|
||||
invalidName: "{{name}} não é um nome de cabeçalho válido",
|
||||
invalidValue: "{{name}} contém uma quebra de linha inválida",
|
||||
duplicateName: "{{name}} foi inserido mais de uma vez",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar senha",
|
||||
hide: "Ocultar senha",
|
||||
|
||||
@@ -1437,6 +1437,19 @@ export const ru: TranslationResources = {
|
||||
show: "Показать расширенные",
|
||||
hide: "Скрыть расширенные",
|
||||
},
|
||||
headers: {
|
||||
title: "Пользовательские заголовки",
|
||||
add: "Добавить заголовок",
|
||||
name: "Имя",
|
||||
value: "Значение",
|
||||
remove: "Удалить заголовок",
|
||||
errors: {
|
||||
missingName: "Введите имя для каждого заголовка",
|
||||
invalidName: "{{name}} — недопустимое имя заголовка",
|
||||
invalidValue: "{{name}} содержит недопустимый перенос строки",
|
||||
duplicateName: "{{name}} введён несколько раз",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
|
||||
@@ -1388,6 +1388,19 @@ export const zhCN: TranslationResources = {
|
||||
show: "显示高级选项",
|
||||
hide: "隐藏高级选项",
|
||||
},
|
||||
headers: {
|
||||
title: "自定义请求头",
|
||||
add: "添加请求头",
|
||||
name: "名称",
|
||||
value: "值",
|
||||
remove: "删除请求头",
|
||||
errors: {
|
||||
missingName: "请为每个请求头输入名称",
|
||||
invalidName: "{{name}} 不是有效的请求头名称",
|
||||
invalidValue: "{{name}} 包含无效换行符",
|
||||
duplicateName: "{{name}} 输入了多次",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "显示密码",
|
||||
hide: "隐藏密码",
|
||||
|
||||
@@ -2998,7 +2998,7 @@ describe("HostRuntimeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("upsertDirectConnection stores SSL and password settings", async () => {
|
||||
it("upsertDirectConnection stores SSL, password, and custom header settings", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
@@ -3016,6 +3016,7 @@ describe("HostRuntimeStore", () => {
|
||||
endpoint: "example.paseo.test:7443",
|
||||
useTls: true,
|
||||
password: "shared-secret",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
label: "tls host",
|
||||
});
|
||||
|
||||
@@ -3027,6 +3028,7 @@ describe("HostRuntimeStore", () => {
|
||||
endpoint: "example.paseo.test:7443",
|
||||
useTls: true,
|
||||
password: "shared-secret",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
createDesktopWebSocketTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
@@ -477,6 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const webSocketTransportFactory = createDesktopWebSocketTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
clientId,
|
||||
@@ -499,10 +501,12 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
if (connection.type === "directTcp") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(webSocketTransportFactory ? { transportFactory: webSocketTransportFactory } : {}),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(connection.headers ? { headers: connection.headers } : {}),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
@@ -1640,6 +1644,7 @@ export class HostRuntimeStore {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
existingClient?: DaemonClient;
|
||||
}): Promise<HostProfile> {
|
||||
@@ -1654,6 +1659,7 @@ export class HostRuntimeStore {
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
...(input.headers ? { headers: input.headers } : {}),
|
||||
},
|
||||
existingClient: input.existingClient,
|
||||
});
|
||||
@@ -1695,6 +1701,7 @@ export class HostRuntimeStore {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const endpoint = normalizeHostPort(input.endpoint);
|
||||
@@ -1707,6 +1714,7 @@ export class HostRuntimeStore {
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
...(input.headers ? { headers: input.headers } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2431,12 +2439,14 @@ export interface HostMutations {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
probeAndUpsertDirectConnection: (input: {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
upsertRelayConnection: (input: {
|
||||
|
||||
@@ -75,6 +75,49 @@ describe("normalizeStoredHostProfile", () => {
|
||||
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("preserves custom headers on stored direct TCP connections", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_headers",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.connections[0]).toEqual({
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
useTls: false,
|
||||
headers: { "X-Tenant": "acme" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the connection but discards malformed stored custom headers", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_malformed_headers",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-Tenant": 42 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.connections[0]).toEqual({
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
useTls: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy relay ids when TLS is absent", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_relay",
|
||||
@@ -175,6 +218,61 @@ describe("upsertHostConnectionInProfiles", () => {
|
||||
|
||||
expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" });
|
||||
});
|
||||
|
||||
it("deduplicates matching custom headers regardless of key order", () => {
|
||||
const firstConnection: HostConnection = {
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-One": "1", "X-Two": "2" },
|
||||
};
|
||||
const existing = {
|
||||
...makeHost("srv_known"),
|
||||
connections: [firstConnection],
|
||||
};
|
||||
|
||||
const [profile] = upsertHostConnectionInProfiles({
|
||||
profiles: [existing],
|
||||
serverId: "srv_known",
|
||||
connection: {
|
||||
...firstConnection,
|
||||
headers: { "X-Two": "2", "X-One": "1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile.connections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces a direct connection when its custom headers change", () => {
|
||||
const existingConnection: HostConnection = {
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-Tenant": "old" },
|
||||
};
|
||||
const existing = {
|
||||
...makeHost("srv_known"),
|
||||
connections: [existingConnection],
|
||||
preferredConnectionId: existingConnection.id,
|
||||
};
|
||||
|
||||
const [profile] = upsertHostConnectionInProfiles({
|
||||
profiles: [existing],
|
||||
serverId: "srv_known",
|
||||
connection: {
|
||||
...existingConnection,
|
||||
headers: { "X-Tenant": "new" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile.connections).toEqual([
|
||||
{
|
||||
...existingConnection,
|
||||
headers: { "X-Tenant": "new" },
|
||||
},
|
||||
]);
|
||||
expect(profile.preferredConnectionId).toBe(existingConnection.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActiveHostServerId", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defaultHostAppearance,
|
||||
normalizeStoredHostAppearance,
|
||||
} from "@/hosts/appearance";
|
||||
import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers";
|
||||
|
||||
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
||||
|
||||
@@ -104,6 +105,18 @@ export function resolveActiveHostServerId(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function stringRecordEquals(
|
||||
left: Record<string, string> | undefined,
|
||||
right: Record<string, string> | undefined,
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left ?? {});
|
||||
const rightRecord = right ?? {};
|
||||
return (
|
||||
leftEntries.length === Object.keys(rightRecord).length &&
|
||||
leftEntries.every(([key, value]) => rightRecord[key] === value)
|
||||
);
|
||||
}
|
||||
|
||||
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
|
||||
if (left.type !== right.type || left.id !== right.id) {
|
||||
return false;
|
||||
@@ -113,7 +126,8 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
|
||||
return (
|
||||
left.endpoint === right.endpoint &&
|
||||
(left.useTls ?? false) === (right.useTls ?? false) &&
|
||||
left.password === right.password
|
||||
left.password === right.password &&
|
||||
stringRecordEquals(left.headers, right.headers)
|
||||
);
|
||||
}
|
||||
if (left.type === "directSocket" && right.type === "directSocket") {
|
||||
@@ -137,14 +151,23 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function dedupeHostConnections(connections: HostConnection[]): HostConnection[] {
|
||||
function upsertHostConnectionById(
|
||||
connections: HostConnection[],
|
||||
connection: HostConnection,
|
||||
): HostConnection[] {
|
||||
const next: HostConnection[] = [];
|
||||
for (const connection of connections) {
|
||||
if (next.some((existing) => hostConnectionEquals(existing, connection))) {
|
||||
let replaced = false;
|
||||
for (const existing of connections) {
|
||||
if (existing.id !== connection.id) {
|
||||
next.push(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (replaced) continue;
|
||||
next.push(connection);
|
||||
replaced = true;
|
||||
}
|
||||
if (!replaced) next.push(connection);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -190,10 +213,10 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
|
||||
const matchedProfiles = matchingIndexes.map((index) => existing[index]);
|
||||
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
|
||||
const nextConnections = dedupeHostConnections([
|
||||
...matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||
const nextConnections = upsertHostConnectionById(
|
||||
matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||
input.connection,
|
||||
]);
|
||||
);
|
||||
const nextLifecycle = prev.lifecycle;
|
||||
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
|
||||
const nextPreferredConnectionId =
|
||||
@@ -292,6 +315,11 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeStoredHeaders(value: unknown): { headers?: Record<string, string> } {
|
||||
const headers = normalizeConnectionHeadersRecord(value);
|
||||
return headers ? { headers } : {};
|
||||
}
|
||||
|
||||
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
const record = toObjectRecord(connection);
|
||||
if (!record) {
|
||||
@@ -309,6 +337,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
endpoint,
|
||||
useTls: record.useTls,
|
||||
...(typeof record.password === "string" ? { password: record.password } : {}),
|
||||
...normalizeStoredHeaders(record.headers),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeConnectionHeadersRecord, prepareConnectionHeaders } from "./connection-headers";
|
||||
|
||||
describe("prepareConnectionHeaders", () => {
|
||||
it("trims and returns custom headers", () => {
|
||||
expect(
|
||||
prepareConnectionHeaders([
|
||||
{ id: 1, name: " X-Tenant ", value: " acme " },
|
||||
{ id: 2, name: "X-Empty", value: "" },
|
||||
]),
|
||||
).toEqual({ headers: { "X-Tenant": "acme", "X-Empty": "" } });
|
||||
});
|
||||
|
||||
it("ignores completely empty draft rows", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "" }])).toEqual({});
|
||||
});
|
||||
|
||||
it("requires a name when a value is entered", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "acme" }])).toEqual({
|
||||
issue: { type: "missingName" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid and duplicate names case-insensitively", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "Bad Header", value: "value" }])).toEqual({
|
||||
issue: { type: "invalidName", name: "Bad Header" },
|
||||
});
|
||||
expect(
|
||||
prepareConnectionHeaders([
|
||||
{ id: 1, name: "X-Tenant", value: "one" },
|
||||
{ id: 2, name: "x-tenant", value: "two" },
|
||||
]),
|
||||
).toEqual({ issue: { type: "duplicateName", name: "x-tenant" } });
|
||||
});
|
||||
|
||||
it("rejects line breaks in values", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "X-Test", value: "one\ntwo" }])).toEqual({
|
||||
issue: { type: "invalidValue", name: "X-Test" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeConnectionHeadersRecord", () => {
|
||||
it("accepts a valid persisted string record", () => {
|
||||
expect(normalizeConnectionHeadersRecord({ "X-Tenant": "acme" })).toEqual({
|
||||
"X-Tenant": "acme",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed persisted header values", () => {
|
||||
expect(normalizeConnectionHeadersRecord({ "X-Tenant": 42 })).toBeUndefined();
|
||||
expect(
|
||||
normalizeConnectionHeadersRecord({ "X-Tenant": "acme\r\nX-Injected: true" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface ConnectionHeaderDraft {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type ConnectionHeaderValidationIssue =
|
||||
| { type: "missingName" }
|
||||
| { type: "invalidName"; name: string }
|
||||
| { type: "invalidValue"; name: string }
|
||||
| { type: "duplicateName"; name: string };
|
||||
|
||||
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function prepareConnectionHeaders(drafts: readonly ConnectionHeaderDraft[]): {
|
||||
headers?: Record<string, string>;
|
||||
issue?: ConnectionHeaderValidationIssue;
|
||||
} {
|
||||
const headers: Record<string, string> = {};
|
||||
const normalizedNames = new Set<string>();
|
||||
|
||||
for (const draft of drafts) {
|
||||
const name = draft.name.trim();
|
||||
const value = draft.value.trim();
|
||||
if (!name && !value) continue;
|
||||
if (!name) return { issue: { type: "missingName" } };
|
||||
if (!HTTP_HEADER_NAME_PATTERN.test(name)) {
|
||||
return { issue: { type: "invalidName", name } };
|
||||
}
|
||||
if (/\r|\n/.test(value)) {
|
||||
return { issue: { type: "invalidValue", name } };
|
||||
}
|
||||
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (normalizedNames.has(normalizedName)) {
|
||||
return { issue: { type: "duplicateName", name } };
|
||||
}
|
||||
normalizedNames.add(normalizedName);
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
return Object.keys(headers).length > 0 ? { headers } : {};
|
||||
}
|
||||
|
||||
export function normalizeConnectionHeadersRecord(
|
||||
value: unknown,
|
||||
): Record<string, string> | undefined {
|
||||
if (!isPlainRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const drafts: ConnectionHeaderDraft[] = [];
|
||||
for (const [name, headerValue] of Object.entries(value)) {
|
||||
if (typeof headerValue !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
drafts.push({ id: drafts.length, name, value: headerValue });
|
||||
}
|
||||
const result = prepareConnectionHeaders(drafts);
|
||||
return result.issue ? undefined : result.headers;
|
||||
}
|
||||
@@ -134,6 +134,46 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
|
||||
});
|
||||
|
||||
it("passes direct TCP custom headers into the initial probe client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(probe.createdConfigs()[0]?.headers).toEqual({ "X-Tenant": "acme" });
|
||||
});
|
||||
|
||||
it("uses the Electron WebSocket transport for the initial probe", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const transportFactory: NonNullable<DaemonClientConfig["transportFactory"]> = () => {
|
||||
throw new Error("The fake probe client should not open the transport.");
|
||||
};
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
...probe.deps,
|
||||
createWebSocketTransportFactory: () => transportFactory,
|
||||
},
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(probe.createdConfigs()[0]?.transportFactory).toBe(transportFactory);
|
||||
});
|
||||
|
||||
it("passes performance tracing into the connected client", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const trace = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
createDesktopWebSocketTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
export interface DaemonProbeClient {
|
||||
@@ -29,6 +30,7 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
createWebSocketTransportFactory?(): DaemonClientConfig["transportFactory"] | null;
|
||||
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
|
||||
createClient(config: DaemonClientConfig): TClient;
|
||||
}
|
||||
@@ -37,10 +39,17 @@ const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonCl
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
||||
createWebSocketTransportFactory: createDesktopWebSocketTransportFactory,
|
||||
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
||||
createClient: (config) => new DaemonClient(config),
|
||||
};
|
||||
|
||||
function resolveWebSocketTransportFactory(
|
||||
deps: Pick<DaemonConnectionDependencies<DaemonProbeClient>, "createWebSocketTransportFactory">,
|
||||
): DaemonClientConfig["transportFactory"] {
|
||||
return deps.createWebSocketTransportFactory?.() ?? undefined;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -103,7 +112,11 @@ export async function buildClientConfig(
|
||||
},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
| "getClientId"
|
||||
| "resolveAppVersion"
|
||||
| "createLocalTransportFactory"
|
||||
| "createWebSocketTransportFactory"
|
||||
| "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await deps.getClientId();
|
||||
@@ -135,8 +148,10 @@ export async function buildClientConfig(
|
||||
if (connection.type === "directTcp") {
|
||||
return {
|
||||
...base,
|
||||
transportFactory: resolveWebSocketTransportFactory(deps),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(connection.headers ? { headers: connection.headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user