diff --git a/web/src/app/features/TryInstall.tsx b/web/src/app/features/TryInstall.tsx index 60f1281c6..787c3f2cb 100644 --- a/web/src/app/features/TryInstall.tsx +++ b/web/src/app/features/TryInstall.tsx @@ -20,7 +20,7 @@ import type { TryOut } from "./features-data"; const SITE = "https://patternflow.work"; export default function TryInstall({ t }: { t: TryOut }) { - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); // The panel's page fetches the image, so the URL has to be absolute. The // production origin is the server-side answer, so the first paint matches; // a preview or a dev server reads its own in on the client. A store that @@ -32,7 +32,6 @@ export default function TryInstall({ t }: { t: TryOut }) { () => SITE, ); - const host = deviceHost.trim() || "patternflow.local"; const src = new URL(t.url, origin).toString(); const href = `http://${host}/update?src=${encodeURIComponent(src)}`; diff --git a/web/src/app/update/UpdateClient.tsx b/web/src/app/update/UpdateClient.tsx index 9fe4ebc56..020e141ca 100644 --- a/web/src/app/update/UpdateClient.tsx +++ b/web/src/app/update/UpdateClient.tsx @@ -36,7 +36,7 @@ function appImagePath(manifest: Manifest): string | null { } export default function UpdateClient() { - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); const [manifest, setManifest] = useState(null); const [failed, setFailed] = useState(false); @@ -60,7 +60,6 @@ export default function UpdateClient() { ? new URL(`/flash/${imagePath}`, window.location.origin).toString() : null; - const host = deviceHost.trim() || "patternflow.local"; const wirelessUrl = imageUrl ? `http://${host}/update?src=${encodeURIComponent(imageUrl)}` : null; diff --git a/web/src/components/community/ShippedPackCard.tsx b/web/src/components/community/ShippedPackCard.tsx index ed6f8becf..70641d8ff 100644 --- a/web/src/components/community/ShippedPackCard.tsx +++ b/web/src/components/community/ShippedPackCard.tsx @@ -45,7 +45,7 @@ export default function ShippedPackCard({ pack }: { pack: PatternPack }) { // resolve — and this is the card a new owner reaches first, with no account // and nothing on the board yet. Telling us the IP once, anywhere, should be // enough for all of them. - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); const shown = pack.order .slice(0, STRIP_CELLS - 1) @@ -103,7 +103,7 @@ export default function ShippedPackCard({ pack }: { pack: PatternPack }) { the pack back over https and posts it to the device itself. */} Install to my board diff --git a/web/src/components/community/ZipDownload.test.tsx b/web/src/components/community/ZipDownload.test.tsx index 3a23c0657..a52f87ba2 100644 --- a/web/src/components/community/ZipDownload.test.tsx +++ b/web/src/components/community/ZipDownload.test.tsx @@ -58,6 +58,27 @@ describe("zip surfaces", () => { expect(screen.getByRole("link", { name: "http://192.168.0.44/patterns" })).toBeInTheDocument(); }); + it("sends a pasted http:// address and an emptied field to a real board", async () => { + window.localStorage.setItem("pf-device-host", "http://192.168.0.44/"); + const { unmount } = render( + {}} />, + ); + await waitFor(() => expect(screen.getByText(/Wave is ready/)).toBeInTheDocument()); + let href = screen.getByText("Send over Wi-Fi").closest("a")?.getAttribute("href") ?? ""; + expect(new URL(href).host).toBe("192.168.0.44"); + expect(screen.getByRole("link", { name: "http://192.168.0.44/patterns" })).toBeInTheDocument(); + unmount(); + + window.localStorage.setItem("pf-device-host", ""); + render( {}} />); + await waitFor(() => expect(screen.getByText(/Wave is ready/)).toBeInTheDocument()); + href = screen.getByText("Send over Wi-Fi").closest("a")?.getAttribute("href") ?? ""; + expect(new URL(href).host).toBe("patternflow.local"); + expect(screen.getByRole("link", { name: "http://patternflow.local/patterns" })).toBeInTheDocument(); + // The field keeps what was typed: nothing, over the placeholder. + expect(screen.getByRole("textbox", { name: "Board address" })).toHaveValue(""); + }); + it("pattern mode disables sending a header that cannot compile", async () => { fetchMock.mockImplementation(() => Promise.resolve( diff --git a/web/src/components/community/ZipDownload.tsx b/web/src/components/community/ZipDownload.tsx index b5feac204..024b7f414 100644 --- a/web/src/components/community/ZipDownload.tsx +++ b/web/src/components/community/ZipDownload.tsx @@ -86,8 +86,7 @@ export function ZipProgress({ // type. Changing it here changes it everywhere. export function ZipInstallNote({ kind }: { kind: ZipKind }) { - const { deviceHost, changeDeviceHost } = useDeviceHost(); - const host = deviceHost.trim() || "patternflow.local"; + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); const patternsPage = `http://${host}/patterns`; return ( diff --git a/web/src/lib/community/deviceHost.test.ts b/web/src/lib/community/deviceHost.test.ts new file mode 100644 index 000000000..02524a100 --- /dev/null +++ b/web/src/lib/community/deviceHost.test.ts @@ -0,0 +1,70 @@ +// The board address as people type it. An emptied field used to make +// http:///patterns?src=…, which a browser reads as a host called "patterns", +// and an address pasted from the console's own tab (http://192.168.0.44/) +// made a host called "http". + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { deviceHostName, useDeviceHost } from "./deviceHost"; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("deviceHostName", () => { + it("falls back to patternflow.local when the field is empty", () => { + expect(deviceHostName("")).toBe("patternflow.local"); + expect(deviceHostName(" ")).toBe("patternflow.local"); + expect(deviceHostName("http://")).toBe("patternflow.local"); + }); + + it("drops the scheme and anything after the host", () => { + expect(deviceHostName("http://192.168.0.44")).toBe("192.168.0.44"); + expect(deviceHostName(" HTTP://192.168.0.44/ ")).toBe("192.168.0.44"); + expect(deviceHostName("https://patternflow.local/patterns")).toBe("patternflow.local"); + expect(deviceHostName("192.168.0.44/")).toBe("192.168.0.44"); + expect(deviceHostName("192.168.0.44/?src=x#top")).toBe("192.168.0.44"); + }); + + it("keeps a plain host and its port as typed", () => { + expect(deviceHostName("patternflow.local")).toBe("patternflow.local"); + expect(deviceHostName("192.168.0.44:8080")).toBe("192.168.0.44:8080"); + expect(deviceHostName("patternflow")).toBe("patternflow"); + }); +}); + +describe("useDeviceHost", () => { + const patternsHost = (stored: string) => { + window.localStorage.setItem("pf-device-host", stored); + const { result } = renderHook(() => useDeviceHost()); + const url = new URL(result.current.patternsUrl("/api/community/patterns/p123/zip")); + return { url, result }; + }; + + it("sends an emptied field to patternflow.local, not to a host called patterns", () => { + const { url, result } = patternsHost(""); + expect(url.host).toBe("patternflow.local"); + expect(url.pathname).toBe("/patterns"); + expect(result.current.host).toBe("patternflow.local"); + // The field itself still shows what was typed: nothing, over the placeholder. + expect(result.current.deviceHost).toBe(""); + }); + + it("sends a pasted http:// address to the board, not to a host called http", () => { + const { url, result } = patternsHost("http://192.168.0.44/"); + expect(url.host).toBe("192.168.0.44"); + expect(url.pathname).toBe("/patterns"); + expect(url.searchParams.get("src")).toBe( + `${window.location.origin}/api/community/patterns/p123/zip`, + ); + expect(result.current.host).toBe("192.168.0.44"); + expect(result.current.deviceHost).toBe("http://192.168.0.44/"); + }); + + it("follows the field when it changes", () => { + const { result } = patternsHost("patternflow.local"); + act(() => result.current.changeDeviceHost("https://10.0.0.7/")); + expect(result.current.host).toBe("10.0.0.7"); + expect(new URL(result.current.patternsUrl("/x")).host).toBe("10.0.0.7"); + }); +}); diff --git a/web/src/lib/community/deviceHost.ts b/web/src/lib/community/deviceHost.ts index 301586421..be140d844 100644 --- a/web/src/lib/community/deviceHost.ts +++ b/web/src/lib/community/deviceHost.ts @@ -8,6 +8,11 @@ // enough. DeviceHostCapture writes the same key when a panel's console links // here with ?device=. // +// The field keeps what was typed, and every link is built from +// deviceHostName() of it. An emptied field used to make http:///patterns, +// which a browser reads as a host called "patterns", and an address pasted +// from the console's own tab (http://192.168.0.44/) made a host called "http". +// // The URLs handed to the device must be absolute even when the community API // is same-origin, because it is the *device* that fetches them, from its own // origin on the LAN. @@ -27,6 +32,16 @@ const KEY = "pf-device-host"; const FALLBACK = "patternflow.local"; const CHANGE_EVENT = "pf-device-host-change"; +/** The host a link should go to: no scheme, no path, and the fallback name when empty. */ +export function deviceHostName(value: string): string { + const host = value + .trim() + .replace(/^https?:\/\//i, "") + .split(/[/?#]/)[0] + .trim(); + return host || FALLBACK; +} + function readStored(): string { try { return window.localStorage.getItem(KEY) ?? FALLBACK; @@ -56,21 +71,16 @@ export function useDeviceHost() { window.dispatchEvent(new Event(CHANGE_EVENT)); }; + const host = deviceHostName(deviceHost); + const absolute = (apiPath: string) => new URL(communityApiUrl(apiPath), window.location.origin).toString(); /** Device's pattern manager, told to fetch and install a module build. */ const patternsUrl = (modulesUrl: string) => { if (typeof window === "undefined") return "#"; - return `http://${deviceHost.trim()}/patterns?src=${encodeURIComponent(absolute(modulesUrl))}`; - }; - - /** Device's update page, told to fetch and flash a firmware build. */ - const updateUrl = (buildId: string) => { - if (typeof window === "undefined") return "#"; - const firmware = absolute(`/api/community/builds/${buildId}/firmware`); - return `http://${deviceHost.trim()}/update?src=${encodeURIComponent(firmware)}`; + return `http://${host}/patterns?src=${encodeURIComponent(absolute(modulesUrl))}`; }; - return { deviceHost, changeDeviceHost, patternsUrl, updateUrl }; + return { deviceHost, host, changeDeviceHost, patternsUrl }; }