From 8b2845004cf31d46449aaaff5fb62bcace127006 Mon Sep 17 00:00:00 2001 From: yuxino Date: Sun, 27 Sep 2026 02:50:09 +0800 Subject: [PATCH 1/2] fix(browser): recognize scheme-less hosts with ports The browser treated localhost:3000 and dotted hosts with ports as custom schemes, rejecting common preview addresses. Recognize those host and port forms before applying the existing HTTP(S) allowlist. Cover normalization and the BrowserPane navigation path while preserving unsupported scheme rejection. Sync the browser UX and E2E scenario. --- apps/desktop/electron/main/browser-view.ts | 11 +++++-- .../test/browser-pane-navigation.test.mjs | 31 ++++++++++++++++++- docs/spec/04-ux/08-component-spec.md | 6 ++++ docs/spec/06-delivery/04-e2e-test-plan.md | 5 ++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/main/browser-view.ts b/apps/desktop/electron/main/browser-view.ts index 48c2af1d6..12fafc59d 100644 --- a/apps/desktop/electron/main/browser-view.ts +++ b/apps/desktop/electron/main/browser-view.ts @@ -25,7 +25,12 @@ const LIVE_RELOAD_DEBOUNCE_MS = 250; export function normalizeUrl(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) return null; - const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) + const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed); + // A dotted hostname or localhost followed by a numeric port is an HTTP + // address, not a custom scheme. Keep other schemes subject to the allowlist. + const hasHostPort = + /^(?:localhost|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]+):\d+(?:[/?#]|$)/i.test(trimmed); + const withScheme = hasScheme && !hasHostPort ? trimmed : `http://${trimmed}`; try { @@ -53,8 +58,8 @@ function isWithinRoot(path: string, root: string): boolean { * Resolve user input to a previewable file inside the workspace: a file:// * URL, an absolute path, or a workspace-relative path (./demo/index.html, * index.html). Returns null unless the target exists as a file within the - * root — inputs like "localhost:3000/a.html" then fall through to URL - * handling instead of a broken file load. + * root. A missing relative file (for example, "localhost:3000/a.html") + * returns null so the caller can try HTTP URL normalization. */ export function resolveLocalFile(raw: string, root: string | null): string | null { const trimmed = raw.trim(); diff --git a/apps/desktop/test/browser-pane-navigation.test.mjs b/apps/desktop/test/browser-pane-navigation.test.mjs index e8a57d6d1..21ceae7de 100644 --- a/apps/desktop/test/browser-pane-navigation.test.mjs +++ b/apps/desktop/test/browser-pane-navigation.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import { register, registerHooks } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; // Electron is the external boundary; all navigation and timeout logic below @@ -35,7 +37,7 @@ registerHooks({ resolve(specifier, context, next) { return specifier === "electron" ? { url: electron, shortCircuit: true } : next(specifier, context); } }); register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); -const { BrowserPane } = await import("../electron/main/browser-view.ts"); +const { BrowserPane, normalizeUrl } = await import("../electron/main/browser-view.ts"); const { WebContentsView } = await import("electron"); const settled = () => new Promise(setImmediate); @@ -48,6 +50,33 @@ function harness(t) { return { pane, wc, request }; } +test("address-bar host and port inputs normalize to HTTP without accepting other schemes", () => { + assert.equal(normalizeUrl("localhost:3000"), "http://localhost:3000/"); + assert.equal(normalizeUrl("localhost:3000/index.html"), "http://localhost:3000/index.html"); + assert.equal(normalizeUrl("example.com:8080"), "http://example.com:8080/"); + assert.equal(normalizeUrl("127.0.0.1:3000"), "http://127.0.0.1:3000/"); + assert.equal(normalizeUrl("example.com"), "http://example.com/"); + assert.equal(normalizeUrl("http://localhost:3000/index.html"), "http://localhost:3000/index.html"); + assert.equal(normalizeUrl("file:///tmp/demo.html"), null); + assert.equal(normalizeUrl("javascript:alert(1)"), null); + assert.equal(normalizeUrl("custom:8080"), null); +}); + +test("submitting a localhost address with a workspace root loads and publishes the HTTP URL", async (t) => { + const { mkdtempSync, rmSync } = await import("node:fs"); + const root = mkdtempSync(join(tmpdir(), "browser-host-port-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const published = []; + const pane = new BrowserPane((state) => published.push(state)); + const request = pane.navigateAndWait("localhost:3000/index.html", root); + const wc = WebContentsView.instances.at(-1).webContents; + assert.equal(wc.pendingLoads[0].url, "http://localhost:3000/index.html"); + assert.equal(published.at(-1).url, "http://localhost:3000/index.html"); + wc.url = "http://localhost:3000/index.html"; + wc.pendingLoads.shift().resolve(); + assert.equal((await request).url, "http://localhost:3000/index.html"); +}); + test("timing out a new preview does not return the previous document as ready", async (t) => { const { request, wc } = harness(t); wc.pendingLoads[0].resolve(); // An old document finishing cannot satisfy this load. diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 63ec1467f..926ddcfe3 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1063,6 +1063,12 @@ entirely inside the plugin's isolated page: main-frame wait shows a retryable error, with the old guest hidden. Superseded and cancelled requests cannot publish over the new navigation. +- Browser address input accepts HTTP(S) URLs and scheme-less web hosts. A + `localhost` or dotted hostname followed by a numeric port (for example, + `localhost:3000/index.html` or `example.com:8080`) is treated as an HTTP + address rather than a custom URL scheme. Unsupported schemes remain blocked; + workspace file previews continue to use the in-root file path gate. + - Opening an HTTP(S) link in the work panel creates an additional Browser resource tab instead of replacing the previous URL. Each tab owns a host-retained WebContents, address, title and navigation history. Switching hides/shows pages without reloading, preserving live form, diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 2cbcbec72..cfd153d85 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -3737,7 +3737,10 @@ identify the platform validation still needed. #### E2E-059: Embedded browser preview isolation and overlays - **Preconditions**: A local dev server is running; a URL or BrowserPreview artifact exists. -- **Steps**: 1) Activate the artifact, enter `localhost:` without a scheme, and submit. +- **Steps**: 1) Activate the artifact, enter `localhost:` and + `localhost:/index.html` without a scheme, and submit each. Enter a + dotted host with a port (for example, `example.com:8080`), then verify an + explicit HTTP(S) URL and a rejected `javascript:` URL. 2) Navigate site links; use back/forward/reload/stop. 3) Trigger a `window.open` popup and a permission-requesting page (e.g. notification prompt). 4) Open global search, then rename a session from the left sidebar; From 978015a4796bd0783f733f11a19758d08fed3b2d Mon Sep 17 00:00:00 2001 From: yuxino Date: Sun, 27 Sep 2026 10:49:19 +0800 Subject: [PATCH 2/2] docs(browser): mirror host-port navigation in Chinese specs The browser URL contract and E2E scenario have Chinese counterparts. Keep those counterparts aligned with the address-input behavior documented in the fix, including localhost paths and dotted hosts with ports. --- docs/zh-CN/spec/04-ux/08-component-spec.md | 5 +++++ docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index 2d0621f10..3de282315 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -772,6 +772,11 @@ vendor 进来的 `pi.file-manager` 视图在插件自己的隔离页面内完成 导航失败或主框架等待超过 15 秒时显示可重试的错误,并隐藏旧页面; 被替代或取消的请求不得覆盖新导航。 +- 浏览器地址栏接受 HTTP(S) URL 和省略协议的网页主机。`localhost` 或点分主机名 + 后接数字端口时(如 `localhost:3000/index.html`、`example.com:8080`), + 按 HTTP 地址处理,而不是误认为自定义 scheme。不支持的 scheme 仍会被拒绝; + 工作区文件预览继续使用根目录范围校验。 + - 在工作面板中打开 HTTP(S) 链接会新增浏览器资源标签,不覆盖原网址。 每个标签保留自己的 WebContents、地址、标题及浏览历史。切换只隐藏/显示页面, 保留当前表单、滚动位置和 JavaScript 状态;后台页面启用节流。关闭标签释放页面和 CDP 资源, diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 5eb4f4533..5c64b1bc3 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -2271,7 +2271,9 @@ MainChat 弥补了缺口。 Maximized/fullscreen 调用保留最新的 #### E2E-059:嵌入式浏览器预览隔离和覆盖 - **前提条件**:本地开发服务器正在运行;存在 URL 或 BrowserPreview 工件。 -- **步骤**: 1) 激活工件,输入 `localhost:`(不带方案),然后提交。 +- **步骤**: 1) 激活工件,依次输入不带协议的 `localhost:` 和 + `localhost:/index.html` 并提交;再输入带端口的点分主机名 + (如 `example.com:8080`),验证显式 HTTP(S) 地址及被拒绝的 `javascript:` 地址。 2) 导航站点链接;使用 back/forward/reload/stop。 3) 触发 `window.open` 弹出窗口和权限请求页面(例如通知 提示)。 4) 打开全局搜索,然后打开设置。返回聊天