fix(desktop): Workspace HTML previews (render view and new window) (#189)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d312d368b8
commit
e015d9e591
7 files changed
+108
-4
No files matched your search
@@ -20,7 +20,13 @@ import { resolveRoot } from "@prismshadow/penguin-core";
|
|||||||
import { liveServerLock } from "@prismshadow/penguin-server/lock";
|
import { liveServerLock } from "@prismshadow/penguin-server/lock";
|
||||||
import { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js";
|
import { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js";
|
||||||
import type { EmbeddedServer } from "./server-process.js";
|
import type { EmbeddedServer } from "./server-process.js";
|
||||||
import { desktopLoginUrl, isAppUrl, MAX_SERVER_RESTARTS, restartDelayMs } from "./util.js";
|
import {
|
||||||
|
desktopLoginUrl,
|
||||||
|
isAppUrl,
|
||||||
|
isLocalSurfaceUrl,
|
||||||
|
MAX_SERVER_RESTARTS,
|
||||||
|
restartDelayMs,
|
||||||
|
} from "./util.js";
|
||||||
|
|
||||||
app.setName("PenguinHarness");
|
app.setName("PenguinHarness");
|
||||||
|
|
||||||
@@ -55,12 +61,44 @@ function createWindow(url: string): void {
|
|||||||
win.on("closed", () => {
|
win.on("closed", () => {
|
||||||
win = null;
|
win = null;
|
||||||
});
|
});
|
||||||
// Everything off the app origin (external links, Workspace previews on the 127.0.0.1
|
// "Open in a new tab" (Workspace HTML previews) is an app-origin link that mints a
|
||||||
// counterpart host) opens in the system browser; the window never leaves the app.
|
// token and 302s to the preview origin — it needs the session cookie, so it must open
|
||||||
|
// in a window of this app; handing it to the system browser would land on a 401.
|
||||||
|
// Denying it outright (as this did at first) made the entry silently do nothing.
|
||||||
|
// Genuinely external links still go to the system browser.
|
||||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||||
if (!isAppUrl(target, appOrigin)) void shell.openExternal(target);
|
if (isLocalSurfaceUrl(target, appOrigin)) {
|
||||||
|
return {
|
||||||
|
action: "allow",
|
||||||
|
overrideBrowserWindowOptions: {
|
||||||
|
width: 1100,
|
||||||
|
height: 800,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
// Same hardening as the main window: the preview is Agent-written, untrusted
|
||||||
|
// HTML and must never get Node.
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
void shell.openExternal(target);
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
// The child lands on the preview origin after the redirect, so its policy is "stay
|
||||||
|
// within this instance's loopback surface, everything else to the system browser" —
|
||||||
|
// the main window's stricter app-origin-only rule would bounce the preview itself out.
|
||||||
|
win.webContents.on("did-create-window", (child) => {
|
||||||
|
child.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||||
|
if (isLocalSurfaceUrl(target, appOrigin)) return { action: "allow" };
|
||||||
|
void shell.openExternal(target);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
child.webContents.on("will-navigate", (event, target) => {
|
||||||
|
if (!isLocalSurfaceUrl(target, appOrigin)) {
|
||||||
|
event.preventDefault();
|
||||||
|
void shell.openExternal(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
win.webContents.on("will-navigate", (event, target) => {
|
win.webContents.on("will-navigate", (event, target) => {
|
||||||
if (!isAppUrl(target, appOrigin)) {
|
if (!isAppUrl(target, appOrigin)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ export function isAppUrl(url: string, origin: string | null): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a URL belongs to this instance's local surface: the app origin itself or its
|
||||||
|
* loopback counterpart on the same port, which is where Workspace previews are served
|
||||||
|
* (see design § "Workspace 文件预览"). Preview windows navigate freely within it; anything
|
||||||
|
* else is external and belongs in the system browser.
|
||||||
|
*/
|
||||||
|
export function isLocalSurfaceUrl(url: string, origin: string | null): boolean {
|
||||||
|
if (origin === null) return false;
|
||||||
|
let target: URL;
|
||||||
|
let app: URL;
|
||||||
|
try {
|
||||||
|
target = new URL(url);
|
||||||
|
app = new URL(origin);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target.protocol !== app.protocol || target.port !== app.port) return false;
|
||||||
|
const loopback = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
||||||
|
return loopback.has(target.hostname) && loopback.has(app.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
/** Max automatic server restarts before giving up with an error dialog. */
|
/** Max automatic server restarts before giving up with an error dialog. */
|
||||||
export const MAX_SERVER_RESTARTS = 3;
|
export const MAX_SERVER_RESTARTS = 3;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
appOriginFor,
|
appOriginFor,
|
||||||
desktopLoginUrl,
|
desktopLoginUrl,
|
||||||
isAppUrl,
|
isAppUrl,
|
||||||
|
isLocalSurfaceUrl,
|
||||||
parsePortFile,
|
parsePortFile,
|
||||||
restartDelayMs,
|
restartDelayMs,
|
||||||
} from "../src/util.js";
|
} from "../src/util.js";
|
||||||
@@ -42,6 +43,23 @@ describe("isAppUrl", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isLocalSurfaceUrl", () => {
|
||||||
|
const origin = "http://localhost:7364";
|
||||||
|
it("accepts the app origin and its loopback counterpart on the same port", () => {
|
||||||
|
// The counterpart is where Workspace previews are served: a preview window must be
|
||||||
|
// able to reach it, which the stricter app-origin rule would deny.
|
||||||
|
expect(isLocalSurfaceUrl("http://localhost:7364/chat", origin)).toBe(true);
|
||||||
|
expect(isLocalSurfaceUrl("http://127.0.0.1:7364/preview/tok/x.html", origin)).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects other ports, other hosts, other schemes, and junk", () => {
|
||||||
|
expect(isLocalSurfaceUrl("http://127.0.0.1:7365/preview/x", origin)).toBe(false);
|
||||||
|
expect(isLocalSurfaceUrl("http://example.com:7364/", origin)).toBe(false);
|
||||||
|
expect(isLocalSurfaceUrl("https://localhost:7364/", origin)).toBe(false);
|
||||||
|
expect(isLocalSurfaceUrl("not a url", origin)).toBe(false);
|
||||||
|
expect(isLocalSurfaceUrl("http://localhost:7364/", null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("restartDelayMs", () => {
|
describe("restartDelayMs", () => {
|
||||||
it("doubles from 1s and caps at 8s", () => {
|
it("doubles from 1s and caps at 8s", () => {
|
||||||
expect([0, 1, 2, 3, 4].map(restartDelayMs)).toEqual([1000, 2000, 4000, 8000, 8000]);
|
expect([0, 1, 2, 3, 4].map(restartDelayMs)).toEqual([1000, 2000, 4000, 8000, 8000]);
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export interface ServerConfig {
|
|||||||
root: string;
|
root: string;
|
||||||
/** HTTP listen address and port (defaults to 127.0.0.1:7364, deliberately avoiding common ports like 3000/8080). */
|
/** HTTP listen address and port (defaults to 127.0.0.1:7364, deliberately avoiding common ports like 3000/8080). */
|
||||||
host: string;
|
host: string;
|
||||||
|
/**
|
||||||
|
* Listen port. `0` asks the OS for an ephemeral port (the desktop shell always does),
|
||||||
|
* in which case the value is only a request: index.ts writes the ACTUAL bound port back
|
||||||
|
* here once listening, because preview URLs are built from the server's own port rather
|
||||||
|
* than the browser's (dev serves the SPA on a different port; see resolvePreviewTarget).
|
||||||
|
*/
|
||||||
port: number;
|
port: number;
|
||||||
/** SQLite database path; ":memory:" for test injection. */
|
/** SQLite database path; ":memory:" for test injection. */
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ const server = serve({ fetch: app.fetch, hostname: config.host, port: config.por
|
|||||||
console.log(`Data root: ${config.root}`);
|
console.log(`Data root: ${config.root}`);
|
||||||
console.log(`SQLite: ${config.dbPath}`);
|
console.log(`SQLite: ${config.dbPath}`);
|
||||||
if (config.desktopToken !== null) console.log("Desktop mode: enabled");
|
if (config.desktopToken !== null) console.log("Desktop mode: enabled");
|
||||||
|
// PORT=0 asked for an ephemeral port: record the real one so everything derived from
|
||||||
|
// the server's own port is correct — Workspace preview URLs above all, which are built
|
||||||
|
// from the bind port on purpose (see resolvePreviewTarget) and would otherwise point at
|
||||||
|
// port 0 and fail to load. deps.config is this same object, so both route call sites
|
||||||
|
// (me.ts, sessions.ts) observe the update.
|
||||||
|
config.port = info.port;
|
||||||
// The root exists by now (openDatabase created it), and the pre-start check found no
|
// The root exists by now (openDatabase created it), and the pre-start check found no
|
||||||
// live owner — record ourselves as this root's server.
|
// live owner — record ourselves as this root's server.
|
||||||
acquireServerLock(config.root, {
|
acquireServerLock(config.root, {
|
||||||
|
|||||||
@@ -189,6 +189,12 @@ export function resolvePreviewTarget(
|
|||||||
const counterpart = loopbackCounterpart(requestHost);
|
const counterpart = loopbackCounterpart(requestHost);
|
||||||
if (!counterpart) return null;
|
if (!counterpart) return null;
|
||||||
if (!loopbackHostRoles(serverBind.host)) return null;
|
if (!loopbackHostRoles(serverBind.host)) return null;
|
||||||
|
// Port 0 means "the listener has not reported its actual port yet" (PORT=0 picks an
|
||||||
|
// ephemeral one; index.ts writes it back once bound). Emitting `:0` would produce a URL
|
||||||
|
// no browser will load — Chromium rejects it outright with ERR_UNSAFE_PORT — so degrade
|
||||||
|
// to "no isolated preview" instead, which the UI already handles by falling back to the
|
||||||
|
// sandboxed same-origin preview.
|
||||||
|
if (serverBind.port === 0) return null;
|
||||||
|
|
||||||
let protocol: string;
|
let protocol: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ describe("preview origin derivation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives up while the bind port is still 0, rather than emitting an unloadable :0 URL", () => {
|
||||||
|
// PORT=0 (the desktop shell) before index.ts writes the actual port back: a `:0` URL
|
||||||
|
// is rejected by Chromium as ERR_UNSAFE_PORT, so previewIsolated reports false and the
|
||||||
|
// UI falls back to the sandboxed same-origin preview.
|
||||||
|
expect(
|
||||||
|
resolvePreviewTarget("http://localhost:0/x", "localhost:0", null, { ...bind, port: 0 }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("gives up when the loopback counterpart is not reachable from the bind address", () => {
|
it("gives up when the loopback counterpart is not reachable from the bind address", () => {
|
||||||
expect(
|
expect(
|
||||||
resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, {
|
resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, {
|
||||||
|
|||||||
Reference in new issue
Block a user