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
16 changes: 13 additions & 3 deletions apps/desktop/electron/main/browser-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,16 @@ export class BrowserPane {

getState(): BrowserState | null {
const wc = this.view?.webContents;
if (!wc || wc.isDestroyed()) return null;
if (!wc || wc.isDestroyed()) {
return this.loadError ? {
url: this.loadError.url,
title: "",
isLoading: false,
loadError: this.loadError.message,
canGoBack: false,
canGoForward: false,
} : null;
}
return {
url: this.loadError?.url ?? this.pendingTarget ?? wc.getURL(),
title: wc.getTitle(),
Expand Down Expand Up @@ -163,12 +172,13 @@ export class BrowserPane {
): Promise<BrowserState | null> {
if (fileRoot) this.fileRoot = fileRoot;
const localPath = resolveLocalFile(raw, this.fileRoot);
const localInput = /^file:/i.test(raw.trim()) || isAbsolute(raw.trim());
const target = localPath
? pathToFileURL(localPath).toString()
: normalizeUrl(raw);
: localInput ? null : normalizeUrl(raw);
if (!target) {
this.beginManagedNavigation();
this.loadError = { url: raw, message: "INVALID_URL" };
this.loadError = { url: raw, message: localInput ? "LOCAL_FILE_NOT_ALLOWED" : "INVALID_URL" };
const state = this.getState();
if (state) this.onState(state);
return null;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/resources/plugins/pi.browser/views/browser.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ <h1 id="empty-title">No page open</h1>
stoppedBody: "Enter an address to try again.",
failedTitle: "Could not open this page",
failedBody: "Check the address or connection, then try again.",
localFileBody: "Only existing files inside this project's workspace can be opened.",
emptyTitle: "No page open",
emptyBody: "Enter an address, or preview an HTML file from the conversation.",
},
Expand All @@ -185,6 +186,7 @@ <h1 id="empty-title">No page open</h1>
stoppedBody: "输入网址可重新打开页面。",
failedTitle: "无法打开此页面",
failedBody: "请检查网址或网络连接,然后重试。",
localFileBody: "只能打开此项目工作区内已存在的文件。",
emptyTitle: "尚未打开页面",
emptyBody: "输入地址,或从对话预览 HTML 文件。",
},
Expand Down Expand Up @@ -232,7 +234,8 @@ <h1 id="empty-title">No page open</h1>
empty.hidden = started && !state?.isLoading;
const phase = state?.loadError === "ERR_ABORTED" ? "stopped" : state?.loadError ? "failed" : state?.isLoading ? "loading" : "empty";
emptyTitle.textContent = t(`${phase}Title`);
emptyBody.textContent = t(`${phase}Body`);
emptyBody.textContent = state?.loadError === "LOCAL_FILE_NOT_ALLOWED"
? t("localFileBody") : t(`${phase}Body`);
back.disabled = !state?.canGoBack;
forward.disabled = !state?.canGoForward;
reload.disabled = !started;
Expand Down
60 changes: 60 additions & 0 deletions apps/desktop/test/browser-local-file-feedback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import test from "node:test";

const html = readFileSync(new URL("../resources/plugins/pi.browser/views/browser.html", import.meta.url), "utf8");
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, "browser chrome script exists");

function render(locale) {
const elements = new Map();
const callbacks = new Map();
const calls = [];
const element = (id) => {
if (!elements.has(id)) elements.set(id, {
id, disabled: false, hidden: false, value: "", textContent: "", title: "",
handlers: new Map(),
addEventListener(name, fn) { this.handlers.set(name, fn); },
setAttribute(name, value) { this[name] = value; },
getBoundingClientRect: () => ({ x: 0, y: 36, width: 400, height: 300 }),
});
return elements.get(id);
};
const bridge = {
on(name, fn) { callbacks.set(name, fn); },
invoke(name, payload) {
calls.push({ name, payload });
if (name === "app.getAppearance") return Promise.resolve({ locale, base: "light" });
if (name === "browser.getState" || name === "browser.navigate") return Promise.resolve(null);
return Promise.resolve();
},
};
runInNewContext(script, {
document: { getElementById: element, documentElement: { dataset: {}, lang: "" } },
window: { pluginBridge: bridge, matchMedia: () => ({ matches: true }), addEventListener() {} },
navigator: { language: locale },
ResizeObserver: class { observe() {} },
});
return { element, callbacks, calls };
}

for (const [locale, expected] of [
["en", "Only existing files inside this project's workspace can be opened."],
["zh-CN", "只能打开此项目工作区内已存在的文件。"],
]) {
for (const url of ["file:///tmp/demo.html", "/tmp/demo.html"]) {
test(`address bar explains ${url} in ${locale}`, async () => {
const view = render(locale);
await new Promise(setImmediate);
view.element("url").value = url;
view.element("form").handlers.get("submit")({ preventDefault() {} });
assert.equal(view.calls.findLast((call) => call.name === "browser.navigate")?.payload.url, url);
view.callbacks.get("browser:state")({ url, loadError: "LOCAL_FILE_NOT_ALLOWED" });
assert.equal(view.element("empty").hidden, false);
assert.equal(view.element("empty-body").textContent, expected);
assert.equal(view.element("url").value, url);
assert.equal(view.calls.at(-1).payload.visible, false);
});
}
}
60 changes: 60 additions & 0 deletions apps/desktop/test/browser-pane-navigation.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { register, registerHooks } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { pathToFileURL } from "node:url";

// Electron is the external boundary; all navigation and timeout logic below
// runs in the production BrowserPane, without opening a native window.
Expand All @@ -23,6 +27,7 @@ const electron = `data:text/javascript,${encodeURIComponent(`
getTitle: () => "fixture",
isLoading: () => false,
isDestroyed: () => false,
close: () => {},
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: () => {},
session: { setPermissionRequestHandler: () => {} },
Expand Down Expand Up @@ -89,6 +94,61 @@ test("an invalid target cannot mark the previous document ready", async (t) => {
await settled();
});

test("outside file URLs and absolute paths explain the workspace boundary without loading", async (t) => {
const root = mkdtempSync(join(tmpdir(), "pi-browser-root-"));
const outside = mkdtempSync(join(tmpdir(), "pi-browser-outside-"));
t.after(() => { pane.dispose(); rmSync(root, { recursive: true }); rmSync(outside, { recursive: true }); });
mkdirSync(join(root, "pages"));
const insideFile = join(root, "pages", "demo.html");
const outsideFile = join(outside, "demo.html");
writeFileSync(insideFile, "<h1>Inside</h1>");
writeFileSync(outsideFile, "<h1>Outside</h1>");
const published = [];
const pane = new BrowserPane((state) => published.push(state));

const allowed = pane.navigateAndWait(pathToFileURL(insideFile).href, root);
const wc = WebContentsView.instances.at(-1).webContents;
assert.equal(wc.pendingLoads.at(-1).url, pathToFileURL(realpathSync(insideFile)).href);
wc.url = pathToFileURL(realpathSync(insideFile)).href;
wc.pendingLoads.shift().resolve();
await allowed;

const allowedAbsolute = pane.navigateAndWait(insideFile, root);
assert.equal(wc.pendingLoads.at(-1).url, pathToFileURL(realpathSync(insideFile)).href);
wc.pendingLoads.shift().resolve();
await allowedAbsolute;

const deniedUrl = pathToFileURL(outsideFile).href;
assert.equal(await pane.navigateAndWait(deniedUrl, root), null);
assert.equal(wc.pendingLoads.length, 0, "outside file must never reach Electron");
assert.equal(published.at(-1).url, deniedUrl);
assert.equal(published.at(-1).loadError, "LOCAL_FILE_NOT_ALLOWED");
assert.equal(published.at(-1).isLoading, false);

assert.equal(await pane.navigateAndWait(outsideFile, root, 10), null);
assert.equal(wc.pendingLoads.at(-1)?.url, undefined, "outside absolute path must never reach Electron");
assert.equal(published.at(-1).url, outsideFile);
assert.equal(published.at(-1).loadError, "LOCAL_FILE_NOT_ALLOWED");
});

test("a denied local file on a blank tab publishes an error without creating a guest", async () => {
const published = [];
const pane = new BrowserPane((state) => published.push(state));
const count = WebContentsView.instances.length;
assert.equal(await pane.navigateAndWait("file:///tmp/demo.html", "/projects/demo"), null);
assert.equal(WebContentsView.instances.length, count);
assert.deepEqual(published.at(-1), {
url: "file:///tmp/demo.html", title: "", isLoading: false,
loadError: "LOCAL_FILE_NOT_ALLOWED", canGoBack: false, canGoForward: false,
});
assert.equal(await pane.navigateAndWait("/tmp/demo.html", "/projects/demo"), null);
assert.equal(WebContentsView.instances.length, count);
assert.deepEqual(published.at(-1), {
url: "/tmp/demo.html", title: "", isLoading: false,
loadError: "LOCAL_FILE_NOT_ALLOWED", canGoBack: false, canGoForward: false,
});
});

test("late native navigation events cannot publish after the session is invalidated", async () => {
const published = [];
const pane = new BrowserPane((state) => published.push(state));
Expand Down
6 changes: 5 additions & 1 deletion docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1061,7 +1061,11 @@ entirely inside the plugin's isolated page:
reveals the page without waiting for images or subframes; those continue to
drive the loading/stop control. A failed switch or one exceeding the 15-second
main-frame wait shows a retryable error, with the old guest hidden. Superseded
and cancelled requests cannot publish over the new navigation.
and cancelled requests cannot publish over the new navigation. A local file
address or absolute path that does not resolve to an existing file inside
the session workspace is rejected before loading and shows a specific
workspace-boundary message in the browser chrome, including on a blank tab.
Existing files inside the workspace remain previewable by absolute path.

- 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
Expand Down
10 changes: 8 additions & 2 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3772,7 +3772,10 @@ identify the platform validation still needed.
prompt). 4) Open global search, then rename a session from the left sidebar;
close it and open Settings. Return to chat
and trigger an inline tool permission card. 5) Switch to another panel tab
and back; close the panel. 6) Use open-external.
and back; close the panel. 6) Use open-external. 7) From both an existing page
and a blank Browser tab, enter an existing HTML file inside the workspace by
`file:` URL and absolute path. Then enter both `file:///tmp/demo.html` and
`/tmp/demo.html`, pointing to an existing file outside the workspace.
- **Expected**: Scheme-less input normalizes to http; nav state (URL bar,
back/forward enablement, load spinner) mirrors the page. Popups open in
the default browser (never in-app) only when the URL parses as http(s) or
Expand All @@ -3787,7 +3790,10 @@ identify the platform validation still needed.
its full surface rect and the plugin body does not shift down.
Open-external launches an http(s) page in the default browser and an in-root
file preview via `openPath`. The view uses an isolated persist partition
(no session bleed from the app shell).
(no session bleed from the app shell). Both in-root forms open; neither
outside form reaches the guest load, the submitted address remains visible,
and the browser explains in the active locale that only existing workspace
files can open.
- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13a, ADR 0019, ADR 0168
- **Acceptance**: Quality, Security
- **Milestone**: M5
Expand Down
3 changes: 3 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 @@ -771,6 +771,9 @@ vendor 进来的 `pi.file-manager` 视图在插件自己的隔离页面内完成
当前主框架导航提交后即可显示页面,不再等待图片或子框架完成加载。
导航失败或主框架等待超过 15 秒时显示可重试的错误,并隐藏旧页面;
被替代或取消的请求不得覆盖新导航。
`file:` 地址或绝对路径若不能解析为当前会话工作区内已存在的文件,
必须在加载前拒绝,并在浏览器栏显示明确的工作区范围提示;空白标签页也一样。
工作区内已存在文件的绝对路径仍可预览。

- 在工作面板中打开 HTTP(S) 链接会新增浏览器资源标签,不覆盖原网址。
每个标签保留自己的 WebContents、地址、标题及浏览历史。切换只隐藏/显示页面,
Expand Down
8 changes: 6 additions & 2 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2276,7 +2276,9 @@ MainChat 弥补了缺口。 Maximized/fullscreen 调用保留最新的
`window.open` 弹出窗口和权限请求页面(例如通知
提示)。 4) 打开全局搜索,然后打开设置。返回聊天
并触发内联工具权限卡。 5) 切换到另一个面板选项卡
然后回来;关闭面板。 6) 使用开放式外部。
然后回来;关闭面板。 6) 使用开放式外部。7) 分别从已有页面和空白浏览器标签页,
使用 `file:` 地址和绝对路径打开工作区内已存在的 HTML 文件;再输入指向工作区外
已存在文件的 `file:///tmp/demo.html` 和 `/tmp/demo.html`。
- **预期**:无方案输入标准化为 http;导航状态(URL 栏,
back/forward 启用、加载微调器)镜像页面。弹出窗口仅在 URL 解析为
http(s) 或 mailto 时打开默认浏览器(绝不在应用程序内);
Expand All @@ -2287,7 +2289,9 @@ MainChat 弥补了缺口。 Maximized/fullscreen 调用保留最新的
并与占位符矩形对齐,不再闪出黑色面板底色。打开工作面板上下文下拉框时,
原生视图在不透明菜单边界下方保持可见,菜单关闭后恢复完整表面矩形。
Open-external 对 http(s) 页走系统浏览器,对根内文件预览走 `openPath`。
视图使用隔离的持久分区(应用程序外壳中没有会话流失)。
视图使用隔离的持久分区(应用程序外壳中没有会话流失)。工作区内的两种地址
都能打开;工作区外的两种地址都不会进入 guest 加载,地址栏保留提交的地址,
浏览器用当前语言说明只能打开工作区内已存在的文件。
- **链接规格**:`03-runtime/01-ipc-protocol.md` §13a、ADR 0019、ADR 0168
- **验收**:质量、安全
- **里程碑**:M5
Expand Down
Loading