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 e4353c914..ec14dab95 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 78b0f169f..12b85838e 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -3766,7 +3766,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; 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) 打开全局搜索,然后打开设置。返回聊天