From c19e58ff832de159bd61d1837dcfec3905452894 Mon Sep 17 00:00:00 2001 From: "marco.kobayashi" Date: Wed, 12 Aug 2026 12:48:11 -0700 Subject: [PATCH 1/2] Add hubble.links.open so HTML Apps can open external links HTML Apps render in a sandboxed iframe without allow-popups or allow-top-navigation, so window.open and target="_blank" are no-ops and apps have no way to send the user to an external URL. Add a links.open method to the runtime broker: the injected runtime exposes hubble.links.open(url) / hubble.links.safeOpen(url) in the same style as files.*, and the renderer dispatch validates the URL (http/https only, mirroring the main-process guard) before forwarding to the existing desktop:open-external-url IPC handler. No new IPC surface is added. Closes #262 --- apps/desktop/src/editor/IframeView.test.ts | 56 ++++++++++++++++++- apps/desktop/src/editor/IframeView.tsx | 22 ++++++-- ...e-html-embeds-use-workspace-hubble-deps.md | 3 +- packages/runtime/global.js | 4 ++ 4 files changed, 79 insertions(+), 6 deletions(-) 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 `