From fa2f501dc730c783c3218a12195ef687f94642b3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 10:32:01 +0700 Subject: [PATCH] Use advertised hostnames during pairing --- .../app/src/components/add-host-modal.tsx | 1 + .../app/src/components/name-host-modal.tsx | 117 ------------------ .../app/src/components/pair-link-modal.tsx | 2 +- .../app/src/components/welcome-screen.tsx | 63 +--------- packages/app/src/runtime/host-runtime.test.ts | 61 +++++++++ packages/app/src/runtime/host-runtime.ts | 24 ++-- packages/app/src/screens/settings-screen.tsx | 54 +------- 7 files changed, 85 insertions(+), 237 deletions(-) delete mode 100644 packages/app/src/components/name-host-modal.tsx diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 2a64ebd66..bd272fec2 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -218,6 +218,7 @@ export function AddHostModal({ const profile = await upsertDirectConnection({ serverId, endpoint, + label: hostname ?? undefined, }); onSaved?.({ profile, serverId, hostname, isNewHost }); diff --git a/packages/app/src/components/name-host-modal.tsx b/packages/app/src/components/name-host-modal.tsx deleted file mode 100644 index 3eb831c3b..000000000 --- a/packages/app/src/components/name-host-modal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; -import { Button } from "@/components/ui/button"; - -const styles = StyleSheet.create((theme) => ({ - helper: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - field: { - marginTop: theme.spacing[3], - gap: theme.spacing[2], - }, - label: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - input: { - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - borderWidth: 1, - borderColor: theme.colors.border, - }, - actions: { - flexDirection: "row", - gap: theme.spacing[3], - marginTop: theme.spacing[4], - }, -})); - -export interface NameHostModalProps { - visible: boolean; - serverId: string; - hostname: string | null; - onSkip: () => void; - onSave: (label: string) => void; -} - -export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) { - const { theme } = useUnistyles(); - - const [label, setLabel] = useState(""); - const hasEditedRef = useRef(false); - - const suggested = (hostname?.trim() || serverId).trim(); - - useEffect(() => { - if (!visible) return; - setLabel(suggested); - hasEditedRef.current = false; - }, [suggested, visible]); - - useEffect(() => { - if (!visible) return; - if (hasEditedRef.current) return; - if (!hostname) return; - const trimmed = label.trim(); - if (trimmed.length === 0 || trimmed === serverId) { - setLabel(hostname.trim()); - } - }, [hostname, label, serverId, visible]); - - const handleChange = useCallback((value: string) => { - hasEditedRef.current = true; - setLabel(value); - }, []); - - const handleSave = useCallback(() => { - const trimmed = label.trim(); - if (!trimmed) { - onSkip(); - return; - } - onSave(trimmed); - }, [label, onSave, onSkip]); - - return ( - - Optional. You can rename this later in Settings. - - - Label - - - - - - - - - ); -} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index b0c3d30e8..5abd5a2ad 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -146,7 +146,7 @@ export function PairLinkModal({ await client.close().catch(() => undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId); - const profile = await upsertDaemonFromOfferUrl(raw); + const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined); onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost }); handleClose(); } catch (error) { diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index ed6414c24..9ef3e4d81 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -8,14 +8,11 @@ import type { HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, - useHostMutations, useHostRuntimeSnapshot, useHosts, } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; -import { NameHostModal } from "./name-host-modal"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; @@ -237,31 +234,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); - const { renameHost } = useHostMutations(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const [isDirectOpen, setIsDirectOpen] = useState(false); const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); - const [pendingNameHost, setPendingNameHost] = useState<{ - serverId: string; - hostname: string | null; - } | null>(null); - const [pendingRedirectServerId, setPendingRedirectServerId] = useState(null); const hosts = useHosts(); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); - const pendingNameHostname = useSessionStore( - useCallback( - (state) => { - if (!pendingNameHost) return null; - return ( - state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? - pendingNameHost.hostname ?? - null - ); - }, - [pendingNameHost], - ), - ); useEffect(() => { const currentPathname = @@ -272,11 +250,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { if (!anyOnlineServerId) { return; } - if (pendingNameHost) { - return; - } router.replace(buildHostRootRoute(anyOnlineServerId)); - }, [anyOnlineServerId, pendingNameHost, router]); + }, [anyOnlineServerId, router]); const finishOnboarding = useCallback( (serverId: string) => { @@ -401,13 +376,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { setIsDirectOpen(false)} - onSaved={({ profile, serverId, hostname, isNewHost }) => { + onSaved={({ profile, serverId }) => { onHostAdded?.(profile); - setPendingRedirectServerId(serverId); - if (isNewHost) { - setPendingNameHost({ serverId, hostname }); - return; - } finishOnboarding(serverId); }} /> @@ -415,38 +385,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { setIsPasteLinkOpen(false)} - onSaved={({ profile, serverId, hostname, isNewHost }) => { + onSaved={({ profile, serverId }) => { onHostAdded?.(profile); - setPendingRedirectServerId(serverId); - if (isNewHost) { - setPendingNameHost({ serverId, hostname }); - return; - } finishOnboarding(serverId); }} /> - - {pendingNameHost && pendingRedirectServerId ? ( - { - const serverId = pendingRedirectServerId; - setPendingNameHost(null); - setPendingRedirectServerId(null); - finishOnboarding(serverId); - }} - onSave={(label) => { - const serverId = pendingRedirectServerId; - void renameHost(pendingNameHost.serverId, label).finally(() => { - setPendingNameHost(null); - setPendingRedirectServerId(null); - finishOnboarding(serverId); - }); - }} - /> - ) : null} ); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index dd7827e02..a7331ec91 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -5,6 +5,7 @@ import type { FetchAgentsEntry, FetchAgentsOptions, } from "@server/client/daemon-client"; +import type { ConnectionOffer } from "@server/shared/connection-offer"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { @@ -189,6 +190,17 @@ function makeHost(input?: Partial): HostProfile { }; } +function makeOffer(input?: Partial): ConnectionOffer { + return { + v: 2, + serverId: input?.serverId ?? "srv_offer", + daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer", + relay: { + endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443", + }, + }; +} + function makeDeps( latencyByConnectionId: Record, createdClients: FakeDaemonClient[], @@ -1288,4 +1300,53 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + + it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.label).toBe("mbp"); + + store.syncHosts([]); + }); + + it("keeps a custom host label when re-pairing with an advertised hostname", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertRelayConnection({ + serverId: "srv_offer", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test_offer", + label: "Custom name", + }); + + await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.label).toBe("Custom name"); + + store.syncHosts([]); + }); }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 2ad3648e3..864d69068 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1287,15 +1287,22 @@ export class HostRuntimeStore { }); } - async upsertConnectionFromOffer(offer: ConnectionOffer): Promise { + async upsertConnectionFromOffer( + offer: ConnectionOffer, + label?: string, + ): Promise { return this.upsertRelayConnection({ serverId: offer.serverId, relayEndpoint: offer.relay.endpoint, daemonPublicKeyB64: offer.daemonPublicKeyB64, + label, }); } - async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise { + async upsertConnectionFromOfferUrl( + offerUrlOrFragment: string, + label?: string, + ): Promise { const marker = "#offer="; const idx = offerUrlOrFragment.indexOf(marker); if (idx === -1) { @@ -1307,7 +1314,7 @@ export class HostRuntimeStore { } const payload = decodeOfferFragmentPayload(encoded); const offer = ConnectionOfferSchema.parse(payload); - return this.upsertConnectionFromOffer(offer); + return this.upsertConnectionFromOffer(offer, label); } async addConnectionFromListenAndWaitForOnline(input: { @@ -1956,8 +1963,11 @@ export interface HostMutations { daemonPublicKeyB64: string; label?: string; }) => Promise; - upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise; - upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise; + upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise; + upsertConnectionFromOfferUrl: ( + offerUrlOrFragment: string, + label?: string, + ) => Promise; renameHost: (serverId: string, label: string) => Promise; removeHost: (serverId: string) => Promise; removeConnection: (serverId: string, connectionId: string) => Promise; @@ -1969,8 +1979,8 @@ export function useHostMutations(): HostMutations { () => ({ upsertDirectConnection: (input) => store.upsertDirectConnection(input), upsertRelayConnection: (input) => store.upsertRelayConnection(input), - upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer), - upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url), + upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), + upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label), renameHost: (serverId, label) => store.renameHost(serverId, label), removeHost: (serverId) => store.removeHost(serverId), removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 380329826..29fdb72f7 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -41,7 +41,6 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; -import { NameHostModal } from "@/components/name-host-modal"; import { Button } from "@/components/ui/button"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { @@ -96,20 +95,20 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio const sections: SettingsSectionDef[] = [ { id: "hosts", label: "Hosts", icon: Server }, { id: "general", label: "General", icon: Settings }, - { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "permissions", label: "Permissions", icon: Shield }, ]; if (context.isDesktopApp) { sections.push( + { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "integrations", label: "Integrations", icon: Puzzle }, { id: "pair-device", label: "Pair device", icon: Smartphone }, { id: "daemon", label: "Daemon", icon: Settings }, + { id: "providers", label: "Providers", icon: Blocks }, ); } sections.push( - { id: "providers", label: "Providers", icon: Blocks }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ); @@ -203,10 +202,6 @@ interface HostsSectionProps { goBackToAddConnectionMethods: () => void; setIsDirectHostVisible: (visible: boolean) => void; setIsPasteLinkVisible: (visible: boolean) => void; - pendingNameHost: { serverId: string; hostname: string | null } | null; - setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void; - pendingNameHostname: string | null; - renameHost: (serverId: string, label: string) => Promise; pendingRemoveHost: HostProfile | null; setPendingRemoveHost: (host: HostProfile | null) => void; isRemovingHost: boolean; @@ -289,38 +284,14 @@ function HostsSection(props: HostsSectionProps) { visible={props.isDirectHostVisible} onClose={props.closeAddConnectionFlow} onCancel={props.goBackToAddConnectionMethods} - onSaved={({ serverId, hostname, isNewHost }) => { - if (isNewHost) { - props.setPendingNameHost({ serverId, hostname }); - } - }} /> { - if (isNewHost) { - props.setPendingNameHost({ serverId, hostname }); - } - }} /> - {props.pendingNameHost ? ( - props.setPendingNameHost(null)} - onSave={(label) => { - void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => { - props.setPendingNameHost(null); - }); - }} - /> - ) : null} - {props.pendingRemoveHost ? ( (null); const [pendingRemoveHost, setPendingRemoveHost] = useState(null); const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); @@ -904,19 +871,6 @@ export default function SettingsScreen() { const editingDaemonLive = editingServerId ? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null) : null; - const pendingNameHostname = useSessionStore( - useCallback( - (state) => { - if (!pendingNameHost) return null; - return ( - state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? - pendingNameHost.hostname ?? - null - ); - }, - [pendingNameHost], - ), - ); useEffect(() => { return () => { @@ -1076,10 +1030,6 @@ export default function SettingsScreen() { goBackToAddConnectionMethods, setIsDirectHostVisible, setIsPasteLinkVisible, - pendingNameHost, - setPendingNameHost, - pendingNameHostname, - renameHost, pendingRemoveHost, setPendingRemoveHost, isRemovingHost,