Restore macOS direct LAN connections (#3071)

* fix(desktop): restore direct LAN connections

Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression.

* fix(app): replace host connections by identity

Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
This commit is contained in:
Mohamed Boudra
2026-08-09 15:32:31 +08:00
committed by GitHub
parent 7761d122d8
commit fd9cc5d693
36 changed files with 153 additions and 1131 deletions
+20 -201
View File
@@ -1,10 +1,9 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useCallback, useMemo, useReducer, 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 { getIsElectron, isNative } from "@/constants/platform";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
@@ -13,11 +12,6 @@ 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";
@@ -129,30 +123,6 @@ 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],
@@ -211,94 +181,6 @@ 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 {
@@ -423,8 +305,6 @@ 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(() => {
@@ -435,8 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
setHeaderDrafts([]);
nextHeaderId.current = 1;
bumpInputResetKey();
}, []);
@@ -491,18 +369,11 @@ 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;
@@ -518,7 +389,6 @@ 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);
@@ -555,7 +425,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
daemons,
directConnectionLabels,
handleClose,
headerDrafts,
host,
isMobile,
isSaving,
@@ -584,22 +453,6 @@ 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 {
@@ -756,58 +609,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
{isAdvancedOpen ? (
<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>
<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}
/>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
@@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "./desktop-daemon-transport";
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
@@ -46,25 +43,4 @@ 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,13 +2,11 @@ import type {
DaemonTransport,
DaemonTransportFactory,
} from "@getpaseo/client/internal/daemon-client";
import type { DesktopDaemonTransportTarget, LocalTransportTarget } from "./desktop-daemon";
import type { 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:";
@@ -52,12 +50,11 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
function createDesktopDaemonTransportFactory(
resolveTarget: (options: Parameters<DaemonTransportFactory>[0]) => DesktopDaemonTransportTarget,
rpc: LocalDaemonTransportRpc,
): DaemonTransportFactory {
return (options) => {
const target = resolveTarget(options);
export function createDesktopLocalDaemonTransportFactory(
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
@@ -190,26 +187,3 @@ function createDesktopDaemonTransportFactory(
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,16 +46,6 @@ 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";
@@ -203,16 +193,6 @@ 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,10 +2,8 @@ import {
closeLocalTransportSession,
listenToLocalTransportEvents,
openLocalTransportSession,
openWebSocketTransportSession,
sendLocalTransportMessage,
type DesktopDaemonTransportTarget,
type WebSocketTransportTarget,
type LocalTransportTarget,
} from "./desktop-daemon";
export interface LocalDaemonTransportEvent {
@@ -19,7 +17,7 @@ export interface LocalDaemonTransportEvent {
}
export interface LocalDaemonTransportRpc {
openSession(target: DesktopDaemonTransportTarget): Promise<string>;
openSession(target: LocalTransportTarget): Promise<string>;
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
closeSession(sessionId: string): Promise<void>;
@@ -31,10 +29,3 @@ 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 { DesktopDaemonTransportTarget } from "./desktop-daemon";
import type { LocalTransportTarget } from "./desktop-daemon";
import type {
LocalDaemonTransportEvent,
LocalDaemonTransportRpc,
@@ -11,7 +11,7 @@ export interface RecordedSend {
}
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
readonly openCalls: DesktopDaemonTransportTarget[];
readonly openCalls: LocalTransportTarget[];
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: DesktopDaemonTransportTarget[] = [];
const openCalls: LocalTransportTarget[] = [];
const recordedSends: RecordedSend[] = [];
const closedSessions: string[] = [];
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
-13
View File
@@ -1426,19 +1426,6 @@ export const ar: TranslationResources = {
show: "عرض متقدم",
hide: "إخفاء المتقدمة",
},
headers: {
title: "رؤوس مخصصة",
add: "إضافة رأس",
name: "الاسم",
value: "القيمة",
remove: "إزالة الرأس",
errors: {
missingName: "أدخل اسمًا لكل رأس",
invalidName: "{{name}} ليس اسم رأس صالحًا",
invalidValue: "يحتوي {{name}} على فاصل أسطر غير صالح",
duplicateName: "تم إدخال {{name}} أكثر من مرة",
},
},
passwordVisibility: {
show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور",
-13
View File
@@ -1437,19 +1437,6 @@ 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",
-13
View File
@@ -1467,19 +1467,6 @@ 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",
-13
View File
@@ -1471,19 +1471,6 @@ 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",
-13
View File
@@ -1440,19 +1440,6 @@ export const ja: TranslationResources = {
show: "詳細設定を表示",
hide: "詳細設定を非表示",
},
headers: {
title: "カスタムヘッダー",
add: "ヘッダーを追加",
name: "名前",
value: "値",
remove: "ヘッダーを削除",
errors: {
missingName: "各ヘッダーの名前を入力してください",
invalidName: "{{name}} は有効なヘッダー名ではありません",
invalidValue: "{{name}} に無効な改行が含まれています",
duplicateName: "{{name}} が複数回入力されています",
},
},
passwordVisibility: {
show: "パスワードを表示",
hide: "パスワードを非表示",
-13
View File
@@ -1436,19 +1436,6 @@ export const ko: TranslationResources = {
show: "고급 표시",
hide: "고급 숨기기",
},
headers: {
title: "사용자 지정 헤더",
add: "헤더 추가",
name: "이름",
value: "값",
remove: "헤더 삭제",
errors: {
missingName: "각 헤더의 이름을 입력하세요",
invalidName: "{{name}}은(는) 올바른 헤더 이름이 아닙니다",
invalidValue: "{{name}}에 잘못된 줄바꿈이 있습니다",
duplicateName: "{{name}}이(가) 두 번 이상 입력되었습니다",
},
},
passwordVisibility: {
show: "비밀번호 표시",
hide: "비밀번호 숨기기",
-13
View File
@@ -1453,19 +1453,6 @@ 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",
-13
View File
@@ -1458,19 +1458,6 @@ export const ru: TranslationResources = {
show: "Показать расширенные",
hide: "Скрыть расширенные",
},
headers: {
title: "Пользовательские заголовки",
add: "Добавить заголовок",
name: "Имя",
value: "Значение",
remove: "Удалить заголовок",
errors: {
missingName: "Введите имя для каждого заголовка",
invalidName: "{{name}} — недопустимое имя заголовка",
invalidValue: "{{name}} содержит недопустимый перенос строки",
duplicateName: "{{name}} введён несколько раз",
},
},
passwordVisibility: {
show: "Показать пароль",
hide: "Скрыть пароль",
-13
View File
@@ -1409,19 +1409,6 @@ export const zhCN: TranslationResources = {
show: "显示高级选项",
hide: "隐藏高级选项",
},
headers: {
title: "自定义请求头",
add: "添加请求头",
name: "名称",
value: "值",
remove: "删除请求头",
errors: {
missingName: "请为每个请求头输入名称",
invalidName: "{{name}} 不是有效的请求头名称",
invalidValue: "{{name}} 包含无效换行符",
duplicateName: "{{name}} 输入了多次",
},
},
passwordVisibility: {
show: "显示密码",
hide: "隐藏密码",
@@ -3042,7 +3042,7 @@ describe("HostRuntimeStore", () => {
}
});
it("upsertDirectConnection stores SSL, password, and custom header settings", async () => {
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -3060,7 +3060,6 @@ describe("HostRuntimeStore", () => {
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
headers: { "X-Tenant": "acme" },
label: "tls host",
});
@@ -3072,7 +3071,6 @@ describe("HostRuntimeStore", () => {
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
headers: { "X-Tenant": "acme" },
},
]);
+1 -12
View File
@@ -36,7 +36,6 @@ import {
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
@@ -479,10 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const webSocketTransportFactory = createDesktopWebSocketTransportFactory();
const webSocketConfig = webSocketTransportFactory
? { transportFactory: webSocketTransportFactory }
: { webSocketFactory: createAppWebSocketFactory() };
const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
const base = {
suppressSendErrors: true,
clientId,
@@ -510,7 +506,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
useTls: connection.useTls ?? false,
}),
...(connection.password ? { password: connection.password } : {}),
...(connection.headers ? { headers: connection.headers } : {}),
});
}
return new DaemonClient({
@@ -1651,7 +1646,6 @@ export class HostRuntimeStore {
endpoint: string;
useTls?: boolean;
password?: string;
headers?: Record<string, string>;
label?: string;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
@@ -1666,7 +1660,6 @@ export class HostRuntimeStore {
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
...(input.headers ? { headers: input.headers } : {}),
},
existingClient: input.existingClient,
});
@@ -1708,7 +1701,6 @@ 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);
@@ -1721,7 +1713,6 @@ export class HostRuntimeStore {
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
...(input.headers ? { headers: input.headers } : {}),
},
});
}
@@ -2463,14 +2454,12 @@ 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: {
+12 -81
View File
@@ -75,49 +75,6 @@ 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",
@@ -219,59 +176,33 @@ 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", () => {
it("replaces a direct connection when its settings change", () => {
const existingConnection: HostConnection = {
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-Tenant": "old" },
useTls: false,
password: "old-secret",
};
const existing = {
const existing: HostProfile = {
...makeHost("srv_known"),
connections: [existingConnection],
preferredConnectionId: existingConnection.id,
};
const replacement: HostConnection = {
...existingConnection,
useTls: true,
password: "new-secret",
};
const [profile] = upsertHostConnectionInProfiles({
profiles: [existing],
serverId: "srv_known",
connection: {
...existingConnection,
headers: { "X-Tenant": "new" },
},
connection: replacement,
});
expect(profile.connections).toEqual([
{
...existingConnection,
headers: { "X-Tenant": "new" },
},
]);
expect(profile.preferredConnectionId).toBe(existingConnection.id);
expect(profile.connections).toEqual([replacement]);
expect(profile.preferredConnectionId).toBe(replacement.id);
});
});
+1 -21
View File
@@ -11,7 +11,6 @@ import {
defaultHostAppearance,
normalizeStoredHostAppearance,
} from "@/hosts/appearance";
import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers";
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
@@ -105,18 +104,6 @@ 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;
@@ -126,8 +113,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
return (
left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password &&
stringRecordEquals(left.headers, right.headers)
left.password === right.password
);
}
if (left.type === "directSocket" && right.type === "directSocket") {
@@ -315,11 +301,6 @@ 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) {
@@ -337,7 +318,6 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
endpoint,
useTls: record.useTls,
...(typeof record.password === "string" ? { password: record.password } : {}),
...normalizeStoredHeaders(record.headers),
});
} catch {
return null;
@@ -1,56 +0,0 @@
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();
});
});
@@ -1,65 +0,0 @@
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;
}
@@ -101,6 +101,29 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.clientIdsRequested).toBe(2);
});
it("keeps direct TCP probes on the renderer WebSocket", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const deps = {
...probe.deps,
createWebSocketTransportFactory: () => {
throw new Error("Direct TCP must not use the desktop WebSocket bridge");
},
};
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
deps,
);
await result.client.close();
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
});
it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
@@ -134,46 +157,6 @@ 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,7 +11,6 @@ import {
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
@@ -30,7 +29,6 @@ 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;
}
@@ -39,17 +37,10 @@ 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();
@@ -112,11 +103,7 @@ export async function buildClientConfig(
},
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
| "getClientId"
| "resolveAppVersion"
| "createLocalTransportFactory"
| "createWebSocketTransportFactory"
| "buildLocalTransportUrl"
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId();
@@ -148,10 +135,8 @@ 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 } : {}),
};
}