diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c09447..e48ce8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- HTML Apps can open external links in the system browser with `hubble.links.open(url)`. [#263](https://github.com/bholmesdev/hubble.md/pull/263) - Add a spellcheck option to Settings. Allows for enabling / disabling, and selecting custom spellcheck dictionaries on Windows and Linux. Thanks [@JoeJoeflyn](https://github.com/JoeJoeflyn)! [#234](https://github.com/bholmesdev/hubble.md/pull/234) ### Changed diff --git a/apps/desktop/src/editor/IframeView.test.ts b/apps/desktop/src/editor/IframeView.test.ts index bf79ba70..70806399 100644 --- a/apps/desktop/src/editor/IframeView.test.ts +++ b/apps/desktop/src/editor/IframeView.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const desktopApi = vi.hoisted(() => ({ platform: "linux", + openExternalUrl: vi.fn(), pathExists: vi.fn(), realPath: vi.fn(), resolvePath: vi.fn(), @@ -10,7 +11,7 @@ const desktopApi = vi.hoisted(() => ({ vi.mock("../desktopApi", () => ({ desktopApi })); -import { resolveHtmlAppGlob } from "./IframeView"; +import { handleHtmlAppRequest, resolveHtmlAppGlob } from "./IframeView"; const workspacePath = "/vault"; const htmlAppPath = "/vault/apps/project-dashboard/index.html"; @@ -60,3 +61,56 @@ describe("HTML app relative globs", () => { ).rejects.toThrow("must stay inside the workspace"); }); }); + +describe("HTML app external links", () => { + beforeEach(() => { + desktopApi.openExternalUrl.mockReset(); + desktopApi.openExternalUrl.mockResolvedValue(undefined); + }); + + const openLink = (url: unknown) => + handleHtmlAppRequest( + { type: "hubble:request", id: 1, method: "links.open", params: { url } }, + workspacePath, + htmlAppPath, + ); + + it("opens http(s) URLs through the desktop external-URL API", async () => { + await expect(openLink("https://example.com/docs")).resolves.toEqual({ + ok: true, + value: { url: "https://example.com/docs" }, + }); + await expect(openLink("HTTP://example.com")).resolves.toMatchObject({ + ok: true, + }); + expect(desktopApi.openExternalUrl).toHaveBeenCalledTimes(2); + expect(desktopApi.openExternalUrl).toHaveBeenCalledWith( + "https://example.com/docs", + ); + }); + + it("rejects non-http(s) URLs without calling the desktop API", async () => { + for (const url of [ + "file:///etc/passwd", + "javascript:alert(1)", + "example.com", + 42, + ]) { + const response = await openLink(url); + expect(response.ok).toBe(false); + } + expect(desktopApi.openExternalUrl).not.toHaveBeenCalled(); + }); + + it("keeps rejecting unknown methods", async () => { + const response = await handleHtmlAppRequest( + { type: "hubble:request", id: 1, method: "links.close", params: {} }, + workspacePath, + htmlAppPath, + ); + expect(response).toMatchObject({ + ok: false, + error: { message: "Unknown Hubble HTML app method: links.close" }, + }); + }); +}); diff --git a/apps/desktop/src/editor/IframeView.tsx b/apps/desktop/src/editor/IframeView.tsx index 11ff3646..d6132acb 100644 --- a/apps/desktop/src/editor/IframeView.tsx +++ b/apps/desktop/src/editor/IframeView.tsx @@ -72,6 +72,12 @@ const createInputSchema = z open: z.boolean().optional(), }) .strict(); +const externalUrlSchema = z + .string() + .refine( + (url) => /^https?:\/\//i.test(url), + "Only http(s) external URLs are allowed", + ); const filePatchSchema = z .object({ body: z.string().optional(), @@ -195,19 +201,27 @@ export function toAssetUrl(path: string): string { return `hubble-asset://local/${pathWithEncodedRoot}`; } -async function handleHtmlAppRequest( +export async function handleHtmlAppRequest( request: HtmlAppRequest, workspacePath: string | null, htmlAppPath: string, ) { try { - if (!workspacePath) { - throw new Error("Open a workspace to query files."); - } const params = request.params && typeof request.params === "object" ? (request.params as Record) : {}; + if (request.method === "links.open") { + const url = parseInput(externalUrlSchema, params.url); + await desktopApi.openExternalUrl(url); + return { + ok: true, + value: { url }, + }; + } + if (!workspacePath) { + throw new Error("Open a workspace to query files."); + } const resolveFilePath = (path: string, mustExist: boolean) => { const basePath = isDotRelative(path) ? dirname(htmlAppPath) diff --git a/docs/adr/0007-iframe-html-embeds-use-workspace-hubble-deps.md b/docs/adr/0007-iframe-html-embeds-use-workspace-hubble-deps.md index 89fad311..50987cd9 100644 --- a/docs/adr/0007-iframe-html-embeds-use-workspace-hubble-deps.md +++ b/docs/adr/0007-iframe-html-embeds-use-workspace-hubble-deps.md @@ -15,7 +15,8 @@ The HTML file must live inside the open Folder. For an Embed, the iframe `src` m - **Load authored HTML by `src`, not `srcdoc`.** Opaque sandboxed `srcdoc` rendered blank in Electron because the child document got a zero layout box on cold start. Loading the workspace file through `hubble-asset://` preserves the opaque sandbox and gives Chromium a normal frame document. - **Inject dependencies from the host.** Desktop serves Folder `.html` files through `hubble-asset://` after injecting vendorized scripts. Authored HTML should not include dependency `