Skip to content
Open
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
52 changes: 49 additions & 3 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ const appIconPath = app.isPackaged
: path.join(__dirname, "..", "..", "resources", "icon.png");
const appIcon = nativeImage.createFromPath(appIconPath);

function parseWebUrl(url: string): URL | null {
try {
const parsed = new URL(url);
return ["http:", "https:"].includes(parsed.protocol) ? parsed : null;
} catch {
return null;
}
}

function isInAppUrl(url: string): boolean {
try {
const parsed = new URL(url);
if (isDev && process.env.ELECTRON_RENDERER_URL) {
return parsed.origin === new URL(process.env.ELECTRON_RENDERER_URL).origin;
}
return parsed.protocol === "file:";
} catch {
return false;
}
}

function openExternalWebUrl(url: string): boolean {
const parsed = parseWebUrl(url);
if (!parsed) {
return false;
}
void shell.openExternal(parsed.toString()).catch((error) => {
console.error(`Failed to open external URL: ${parsed.toString()}`, error);
});
return true;
}

function readClipboardImageAttachment(): ComposerImageAttachment | null {
const image = clipboard.readImage();
if (image.isEmpty()) {
Expand Down Expand Up @@ -141,6 +173,20 @@ function createWindow(): BrowserWindow {
},
});

window.webContents.setWindowOpenHandler(({ url }) => {
if (!isInAppUrl(url)) {
openExternalWebUrl(url);
}
return { action: "deny" };
});
window.webContents.on("will-navigate", (event, url) => {
if (isInAppUrl(url)) {
return;
}
event.preventDefault();
openExternalWebUrl(url);
});

window.once("ready-to-show", () => {
if (!backgroundTestMode) {
window.show();
Expand Down Expand Up @@ -488,11 +534,11 @@ app.whenReady().then(async () => {
return mode;
});
ipcMain.handle(desktopIpc.openExternal, (_event, url: string) => {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
const parsed = parseWebUrl(url);
if (!parsed) {
throw new Error(`Refusing to open unsupported URL: ${url}`);
}
return shell.openExternal(url);
return shell.openExternal(parsed.toString());
});
ipcMain.handle(desktopIpc.stateRequest, () => store.getState());
ipcMain.handle(desktopIpc.selectedTranscriptRequest, () => store.getSelectedTranscript());
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/tests/core/external-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { join } from "node:path";
import { expect, test } from "@playwright/test";
import {
launchDesktop,
makeUserDataDir,
makeWorkspace,
seedAgentDir,
seedExternalLinkSessionFixture,
selectSession,
} from "../helpers/electron-app";

test("opens markdown web links externally without leaving the current session", async () => {
test.setTimeout(60_000);
const userDataDir = await makeUserDataDir();
const agentDir = join(userDataDir, "agent");
const workspacePath = await makeWorkspace("external-links-workspace");
const targetUrl = "https://github.com/minghinmatthewlam/pi-gui/issues/20";
await seedAgentDir(agentDir);
await seedExternalLinkSessionFixture(agentDir, workspacePath);

const harness = await launchDesktop(userDataDir, {
agentDir,
initialWorkspaces: [workspacePath],
testMode: "background",
});

try {
const window = await harness.firstWindow();
await selectSession(window, "External link fixture session");
const appUrl = window.url();

await harness.electronApp.evaluate(({ shell }) => {
const globals = globalThis as typeof globalThis & { __piGuiOpenedExternalUrls?: string[] };
globals.__piGuiOpenedExternalUrls = [];
shell.openExternal = async (url: string) => {
globals.__piGuiOpenedExternalUrls?.push(url);
};
});

await window.getByRole("link", { name: "GitHub issue" }).click();

await expect
.poll(() =>
harness.electronApp.evaluate(
() =>
(globalThis as typeof globalThis & { __piGuiOpenedExternalUrls?: string[] })
.__piGuiOpenedExternalUrls ?? [],
),
)
.toEqual([targetUrl]);
await expect
.poll(() => harness.electronApp.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length))
.toBe(1);
expect(window.url()).toBe(appUrl);
await expect(window.getByTestId("transcript")).toContainText("GitHub issue");
} finally {
await harness.close();
}
});
46 changes: 46 additions & 0 deletions apps/desktop/tests/helpers/electron-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,52 @@ export async function seedToolResultTreeSessionFixture(
});
}

export async function seedExternalLinkSessionFixture(
agentDir: string,
workspacePath: string,
): Promise<{
readonly sessionId: string;
readonly title: "External link fixture session";
}> {
const { SessionManager } = (await import(
"../../../../node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js"
)) as {
SessionManager: {
create(cwd: string): {
appendMessage(message: { role: "user" | "assistant"; content: string; timestamp: number }): string;
appendSessionInfo(name: string): string;
getSessionId(): string;
};
};
};

return withAgentDirEnv(agentDir, async () => {
const sessionManager = SessionManager.create(workspacePath);
let timestamp = Date.now();
const nextTimestamp = () => {
timestamp += 1_000;
return timestamp;
};

sessionManager.appendMessage({
role: "user",
content: "Show me the related issue.",
timestamp: nextTimestamp(),
});
sessionManager.appendMessage({
role: "assistant",
content: "Track this in [GitHub issue](https://github.com/minghinmatthewlam/pi-gui/issues/20).",
timestamp: nextTimestamp(),
});
sessionManager.appendSessionInfo("External link fixture session");

return {
sessionId: sessionManager.getSessionId(),
title: "External link fixture session",
};
});
}

async function withAgentDirEnv<T>(agentDir: string, action: () => Promise<T>): Promise<T> {
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
Expand Down