Skip to content
Merged
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
11 changes: 8 additions & 3 deletions apps/desktop/electron/main/browser-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 30 additions & 1 deletion apps/desktop/test/browser-pane-navigation.test.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` without a scheme, and submit.
- **Steps**: 1) Activate the artifact, enter `localhost:<port>` and
`localhost:<port>/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;
Expand Down
5 changes: 5 additions & 0 deletions docs/zh-CN/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 资源,
Expand Down
4 changes: 3 additions & 1 deletion docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2271,7 +2271,9 @@ MainChat 弥补了缺口。 Maximized/fullscreen 调用保留最新的
#### E2E-059:嵌入式浏览器预览隔离和覆盖

- **前提条件**:本地开发服务器正在运行;存在 URL 或 BrowserPreview 工件。
- **步骤**: 1) 激活工件,输入 `localhost:<port>`(不带方案),然后提交。
- **步骤**: 1) 激活工件,依次输入不带协议的 `localhost:<port>` 和
`localhost:<port>/index.html` 并提交;再输入带端口的点分主机名
(如 `example.com:8080`),验证显式 HTTP(S) 地址及被拒绝的 `javascript:` 地址。
2) 导航站点链接;使用 back/forward/reload/stop。 3) 触发
`window.open` 弹出窗口和权限请求页面(例如通知
提示)。 4) 打开全局搜索,然后打开设置。返回聊天
Expand Down
Loading