Skip to content
Merged
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
3 changes: 1 addition & 2 deletions web/src/app/features/TryInstall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)}`;

Expand Down
3 changes: 1 addition & 2 deletions web/src/app/update/UpdateClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Manifest | null>(null);
const [failed, setFailed] = useState(false);

Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/community/ShippedPackCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -103,7 +103,7 @@ export default function ShippedPackCard({ pack }: { pack: PatternPack }) {
the pack back over https and posts it to the device itself. */}
<a
className={styles.btnPrimary}
href={packInstallUrl(pack, deviceHost.trim() || "patternflow.local", origin)}
href={packInstallUrl(pack, host, origin)}
>
Install to my board
</a>
Expand Down
21 changes: 21 additions & 0 deletions web/src/components/community/ZipDownload.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SendModuleModal patternTitle="Wave" patternId="p123" onClose={() => {}} />,
);
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(<SendModuleModal patternTitle="Wave" patternId="p123" onClose={() => {}} />);
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(
Expand Down
3 changes: 1 addition & 2 deletions web/src/components/community/ZipDownload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
70 changes: 70 additions & 0 deletions web/src/lib/community/deviceHost.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
28 changes: 19 additions & 9 deletions web/src/lib/community/deviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
// enough. DeviceHostCapture writes the same key when a panel's console links
// here with ?device=<ip>.
//
// 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.
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}
Loading