Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
SettingsSection,
useRelativeTimeTick,
} from "./settingsLayout";
import { resolveDesktopNetworkEndpointUrl } from "./networkEndpointOverride";
import { Input } from "../ui/input";
import {
Dialog,
Expand Down Expand Up @@ -806,10 +807,24 @@ export function ConnectionsSettings() {
const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState<
DesktopServerExposureState["mode"] | null
>(null);
const [customNetworkHostnameOrUrl, setCustomNetworkHostnameOrUrl] = useState("");
const canManageLocalBackend = currentSessionRole === "owner";
const isLocalBackendNetworkAccessible = desktopBridge
? desktopServerExposureState?.mode === "network-accessible"
: currentAuthPolicy === "remote-reachable";
const desktopNetworkEndpointResolution = useMemo(
() =>
resolveDesktopNetworkEndpointUrl({
endpointUrl: desktopServerExposureState?.endpointUrl,
customHostnameOrUrl: customNetworkHostnameOrUrl,
}),
[customNetworkHostnameOrUrl, desktopServerExposureState?.endpointUrl],
);
const showCustomNetworkHostnameInput = desktopServerExposureState?.mode === "network-accessible";
const effectiveDesktopEndpointUrl =
desktopServerExposureState?.mode === "network-accessible"
? desktopNetworkEndpointResolution.endpointUrl
: (desktopServerExposureState?.endpointUrl ?? null);

const handleDesktopServerExposureChange = useCallback(
async (checked: boolean) => {
Expand Down Expand Up @@ -1120,8 +1135,8 @@ export function ConnectionsSettings() {
<SettingsRow
title="Network access"
description={
desktopServerExposureState?.endpointUrl
? `Reachable at ${desktopServerExposureState.endpointUrl}`
effectiveDesktopEndpointUrl
? `Reachable at ${effectiveDesktopEndpointUrl}`
: desktopServerExposureState?.mode === "network-accessible"
? desktopServerExposureState.advertisedHost
? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
Expand All @@ -1133,6 +1148,10 @@ export function ConnectionsSettings() {
status={
desktopServerExposureError ? (
<span className="block text-destructive">{desktopServerExposureError}</span>
) : showCustomNetworkHostnameInput && desktopNetworkEndpointResolution.error ? (
<span className="block text-destructive">
{desktopNetworkEndpointResolution.error}
</span>
) : null
}
control={
Expand Down Expand Up @@ -1197,7 +1216,26 @@ export function ConnectionsSettings() {
</AlertDialogPopup>
</AlertDialog>
}
/>
>
{showCustomNetworkHostnameInput ? (
<div className="grid gap-1.5 pt-2 pb-4">
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">
Custom hostname or URL
</span>
<Input
value={customNetworkHostnameOrUrl}
onChange={(event) => setCustomNetworkHostnameOrUrl(event.target.value)}
placeholder="e.g. 10.0.0.42 or devbox.local"
/>
</label>
<p className="text-[11px] text-muted-foreground/80">
Optional override for this app session. Used for the reachable URL and copied
pairing links.
</p>
</div>
) : null}
</SettingsRow>
) : (
<SettingsRow
title="Network access"
Expand Down Expand Up @@ -1246,7 +1284,7 @@ export function ConnectionsSettings() {
</div>
) : null}
<PairingClientsList
endpointUrl={desktopServerExposureState?.endpointUrl}
endpointUrl={effectiveDesktopEndpointUrl}
isLoading={isLoadingDesktopAccessManagement}
pairingLinks={visibleDesktopPairingLinks}
clientSessions={desktopClientSessions}
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,57 @@ describe("GeneralSettingsPanel observability", () => {
await expect.element(page.getByText("Revoke others")).toBeInTheDocument();
});

it("uses a custom hostname override for reachable and copied pairing URLs", async () => {
window.desktopBridge = createDesktopBridgeStub({
serverExposureState: {
mode: "network-accessible",
endpointUrl: "http://192.168.1.44:3773",
advertisedHost: "192.168.1.44",
},
});
authAccessHarness.setSnapshot({
pairingLinks: [
makePairingLink({
id: "pairing-link-1",
credential: "pairing-token",
role: "client",
subject: "one-time-token",
label: "Julius iPhone",
createdAt: "2036-04-07T00:00:00.000Z",
expiresAt: "2036-04-10T00:05:00.000Z",
}),
],
clientSessions: [],
});

const writeText = vi.fn<(value: string) => Promise<void>>().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
vi.stubGlobal("isSecureContext", true);

setServerConfigSnapshot(createBaseServerConfig());

mounted = await render(
<AppAtomRegistryProvider>
<ConnectionsSettings />
</AppAtomRegistryProvider>,
);

await expect
.element(page.getByText("Reachable at http://192.168.1.44:3773"))
.toBeInTheDocument();

await page.getByLabelText("Custom hostname or URL").fill("devbox.lan");
await expect.element(page.getByText("Reachable at http://devbox.lan:3773")).toBeInTheDocument();

await page.getByRole("button", { name: "Copy", exact: true }).click();
await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledWith("http://devbox.lan:3773/pair#token=pairing-token");
});
});

it("revokes all other paired clients from settings", async () => {
window.desktopBridge = createDesktopBridgeStub({
serverExposureState: {
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/components/settings/networkEndpointOverride.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";

import { resolveDesktopNetworkEndpointUrl } from "./networkEndpointOverride";

describe("resolveDesktopNetworkEndpointUrl", () => {
it("returns the default endpoint when no override is provided", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: "http://192.168.1.44:3773",
customHostnameOrUrl: " ",
});

expect(result).toEqual({
endpointUrl: "http://192.168.1.44:3773",
customValue: null,
usesCustomEndpoint: false,
error: null,
});
});

it("overrides hostname while keeping protocol and port", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: "http://192.168.1.44:3773",
customHostnameOrUrl: "devbox.local",
});

expect(result).toEqual({
endpointUrl: "http://devbox.local:3773",
customValue: "devbox.local",
usesCustomEndpoint: true,
error: null,
});
});

it("overrides hostname and port when host:port is provided", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: "http://192.168.1.44:3773",
customHostnameOrUrl: "devbox.local:7777",
});

expect(result).toEqual({
endpointUrl: "http://devbox.local:7777",
customValue: "devbox.local:7777",
usesCustomEndpoint: true,
error: null,
});
});

it("overrides scheme and host when a full URL is provided", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: "http://192.168.1.44:3773",
customHostnameOrUrl: "https://demo.example.com",
});

expect(result).toEqual({
endpointUrl: "https://demo.example.com",
customValue: "https://demo.example.com",
usesCustomEndpoint: true,
error: null,
});
});

it("accepts a full URL when the default endpoint is not available", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: null,
customHostnameOrUrl: "https://demo.example.com:8443/path",
});

expect(result).toEqual({
endpointUrl: "https://demo.example.com:8443",
customValue: "https://demo.example.com:8443/path",
usesCustomEndpoint: true,
error: null,
});
});

it("keeps the fallback endpoint and returns an error for invalid host input", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: "http://192.168.1.44:3773",
customHostnameOrUrl: "demo.example.com/path",
});

expect(result).toEqual({
endpointUrl: "http://192.168.1.44:3773",
customValue: "demo.example.com/path",
usesCustomEndpoint: false,
error: "Enter only a hostname (and optional port), or a full URL.",
});
});

it("requires a full URL when no default endpoint is available", () => {
const result = resolveDesktopNetworkEndpointUrl({
endpointUrl: null,
customHostnameOrUrl: "demo.example.com",
});

expect(result).toEqual({
endpointUrl: null,
customValue: "demo.example.com",
usesCustomEndpoint: false,
error: "Enter a full URL while network access is loading.",
});
});
});
124 changes: 124 additions & 0 deletions apps/web/src/components/settings/networkEndpointOverride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
const ABSOLUTE_URL_SCHEME_RE = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//;

export interface DesktopNetworkEndpointResolution {
endpointUrl: string | null;
customValue: string | null;
usesCustomEndpoint: boolean;
error: string | null;
}

function isHttpProtocol(protocol: string): boolean {
return protocol === "http:" || protocol === "https:";
}

function parseHttpUrl(rawValue: string): URL | null {
try {
const url = new URL(rawValue);
if (!isHttpProtocol(url.protocol)) {
return null;
}
return url;
} catch {
return null;
}
}

function formatEndpointUrl(url: URL): string {
if (url.pathname === "/" && url.search === "" && url.hash === "") {
return `${url.protocol}//${url.host}`;
}
return url.toString();
}

function parseAbsoluteCustomUrl(rawValue: string): URL {
const parsed = new URL(rawValue);
if (!isHttpProtocol(parsed.protocol)) {
throw new Error("Use an http:// or https:// URL.");
}
if (!parsed.hostname || parsed.username || parsed.password) {
throw new Error("Enter a valid hostname or URL.");
}
return parsed;
}

function parseHostOverride(rawValue: string): URL {
const parsed = new URL(`http://${rawValue}`);
if (
!parsed.hostname ||
parsed.username ||
parsed.password ||
parsed.pathname !== "/" ||
parsed.search !== "" ||
parsed.hash !== ""
) {
throw new Error("Enter only a hostname (and optional port), or a full URL.");
}
return parsed;
}

function applyCustomEndpointOverride(defaultEndpoint: URL | null, customValue: string): string {
if (ABSOLUTE_URL_SCHEME_RE.test(customValue)) {
const customUrl = parseAbsoluteCustomUrl(customValue);
if (!defaultEndpoint) {
return `${customUrl.protocol}//${customUrl.host}`;
}
const next = new URL(defaultEndpoint.toString());
next.protocol = customUrl.protocol;
if (customUrl.port === "") {
next.hostname = customUrl.hostname;
next.port = "";
} else {
next.host = customUrl.host;
}
return formatEndpointUrl(next);
}

if (!defaultEndpoint) {
throw new Error("Enter a full URL while network access is loading.");
}

const hostOverride = parseHostOverride(customValue);
const next = new URL(defaultEndpoint.toString());
if (hostOverride.port === "") {
next.hostname = hostOverride.hostname;
} else {
next.host = hostOverride.host;
}
return formatEndpointUrl(next);
}

export function resolveDesktopNetworkEndpointUrl(input: {
endpointUrl: string | null | undefined;
customHostnameOrUrl: string;
}): DesktopNetworkEndpointResolution {
const rawEndpointUrl = input.endpointUrl?.trim() ?? "";
const defaultEndpoint = rawEndpointUrl === "" ? null : parseHttpUrl(rawEndpointUrl);
const fallbackEndpointUrl = defaultEndpoint ? rawEndpointUrl : null;
const customValue = input.customHostnameOrUrl.trim();

if (customValue === "") {
return {
endpointUrl: fallbackEndpointUrl,
customValue: null,
usesCustomEndpoint: false,
error: null,
};
}

try {
const endpointUrl = applyCustomEndpointOverride(defaultEndpoint, customValue);
return {
endpointUrl,
customValue,
usesCustomEndpoint: true,
error: null,
};
} catch (error) {
return {
endpointUrl: fallbackEndpointUrl,
customValue,
usesCustomEndpoint: false,
error: error instanceof Error ? error.message : "Enter a valid hostname or URL.",
};
}
}
Loading