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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion apps/desktop/src/editor/IframeView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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(),
}));

vi.mock("../desktopApi", () => ({ desktopApi }));

import { resolveHtmlAppGlob } from "./IframeView";
import { handleHtmlAppRequest, resolveHtmlAppGlob } from "./IframeView";

const workspacePath = "/vault";
const htmlAppPath = "/vault/apps/project-dashboard/index.html";
Expand Down Expand Up @@ -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" },
});
});
});
22 changes: 18 additions & 4 deletions apps/desktop/src/editor/IframeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<string, unknown>)
: {};
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` tags for the Hubble runtime, Tailwind browser, or Alpine.
- **Bundle a canonical dependency set.** The first slice injects Hubble runtime, Tailwind browser v4, and Alpine for every HTML App. There is no opt-in or opt-out yet.
- **The HTML app runtime exposes a small global API.** Today it provides `window.hubble.files.list()`, `window.hubble.files.read()`, `window.hubble.files.open()`, `window.hubble.files.create()`, `window.hubble.files.update()`, and `window.hubble.files.remove()` plus height reporting over `postMessage` when the HTML App is embedded inline. Each file method throws on failure and has a `safe*` variant that returns `{ ok, value | error }`.
- **The HTML app runtime exposes a small global API.** Today it provides `window.hubble.files.list()`, `window.hubble.files.read()`, `window.hubble.files.open()`, `window.hubble.files.create()`, `window.hubble.files.update()`, and `window.hubble.files.remove()`, plus `window.hubble.links.open()` and height reporting over `postMessage` when the HTML App is embedded inline. Each method throws on failure and has a `safe*` variant that returns `{ ok, value | error }`.
- **HTML app link opens go through the existing desktop external-URL path.** The sandbox has no `allow-popups` or `allow-top-navigation`, so apps cannot open URLs themselves. `links.open(url)` forwards over the broker to the same validated `desktop:open-external-url` IPC handler the Markdown editor uses, which only accepts `http(s)` URLs and opens them in the system browser.
- **HTML app file operations are Markdown File operations, not raw filesystem access.** Bare file paths are workspace-relative Markdown paths. Paths beginning with `./` or `../` resolve from the HTML App's folder, including when that app is embedded in a different Markdown File. File results always return canonical workspace-relative paths. `read()` returns `{ path, body, properties }`; `update()` is patch-like and accepts `body`, `properties`, or both. Omitted fields are preserved, and `null` deletes a property key. `create()` fails if the destination already exists. `remove()` prompts for user confirmation before deleting.

## Consequences
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/global.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
remove: (path) => requestHubble("files.remove", { path }),
safeRemove: (path) => safeRequestHubble("files.remove", { path }),
},
links: {
open: (url) => requestHubble("links.open", { url }),
safeOpen: (url) => safeRequestHubble("links.open", { url }),
},
};

const send = () => {
Expand Down