Skip to content
603 changes: 603 additions & 0 deletions .plans/18-browser-panel-shell-and-runtime.md

Large diffs are not rendered by default.

482 changes: 482 additions & 0 deletions apps/desktop/src/browserManager.ts

Large diffs are not rendered by default.

224 changes: 224 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
BrowserClearThreadInput,
BrowserEnsureTabInput,
BrowserEvent,
BrowserNavigateInput,
BrowserSyncHostInput,
BrowserTabTargetInput,
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateState,
Expand All @@ -30,6 +36,7 @@ import { RotatingFileSink } from "@t3tools/shared/logging";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { fixPath } from "./fixPath";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
import { createBrowserManager } from "./browserManager";
import {
createInitialDesktopUpdateState,
reduceDesktopUpdateStateOnCheckFailure,
Expand All @@ -51,6 +58,15 @@ const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const BROWSER_ENSURE_TAB_CHANNEL = "desktop:browser-ensure-tab";
const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate";
const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back";
const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward";
const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload";
const BROWSER_CLOSE_TAB_CHANNEL = "desktop:browser-close-tab";
const BROWSER_SYNC_HOST_CHANNEL = "desktop:browser-sync-host";
const BROWSER_CLEAR_THREAD_CHANNEL = "desktop:browser-clear-thread";
const BROWSER_EVENT_CHANNEL = "desktop:browser-event";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
Expand Down Expand Up @@ -160,6 +176,125 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
return null;
}

function getSafeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

function getSafeBrowserTabTargetInput(rawInput: unknown): BrowserTabTargetInput | null {
if (typeof rawInput !== "object" || rawInput === null) {
return null;
}
const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId"));
const tabId = getSafeNonEmptyString(Reflect.get(rawInput, "tabId"));
if (!threadId || !tabId) {
return null;
}
return {
threadId: threadId as BrowserTabTargetInput["threadId"],
tabId,
} satisfies BrowserTabTargetInput;
}

function getSafeBrowserEnsureTabInput(rawInput: unknown): BrowserEnsureTabInput | null {
const target = getSafeBrowserTabTargetInput(rawInput);
if (!target) {
return null;
}
const urlRaw = Reflect.get(rawInput as object, "url");
const url = urlRaw === undefined ? undefined : getSafeNonEmptyString(urlRaw);
if (urlRaw !== undefined && !url) {
return null;
}
return {
...target,
...(url ? { url } : {}),
};
}

function getSafeBrowserNavigateInput(rawInput: unknown): BrowserNavigateInput | null {
const target = getSafeBrowserTabTargetInput(rawInput);
if (!target) {
return null;
}
const url = getSafeNonEmptyString(Reflect.get(rawInput as object, "url"));
if (!url) {
return null;
}
return {
...target,
url,
};
}

function getSafeBrowserBounds(rawBounds: unknown): BrowserSyncHostInput["bounds"] {
if (rawBounds === null) {
return null;
}
if (typeof rawBounds !== "object" || rawBounds === null) {
return null;
}
const x = Reflect.get(rawBounds, "x");
const y = Reflect.get(rawBounds, "y");
const width = Reflect.get(rawBounds, "width");
const height = Reflect.get(rawBounds, "height");
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height)
) {
return null;
}
return {
x,
y,
width,
height,
} satisfies NonNullable<BrowserSyncHostInput["bounds"]>;
}

function getSafeBrowserSyncHostInput(rawInput: unknown): BrowserSyncHostInput | null {
if (typeof rawInput !== "object" || rawInput === null) {
return null;
}
const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId"));
if (!threadId) {
return null;
}
const rawTabId = Reflect.get(rawInput, "tabId");
const tabId =
rawTabId === null
? null
: typeof rawTabId === "string"
? getSafeNonEmptyString(rawTabId)
: null;
const visible = Reflect.get(rawInput, "visible");
if (typeof visible !== "boolean") {
return null;
}
return {
threadId: threadId as BrowserSyncHostInput["threadId"],
tabId,
visible,
bounds: getSafeBrowserBounds(Reflect.get(rawInput, "bounds")),
};
}

function getSafeBrowserClearThreadInput(rawInput: unknown): BrowserClearThreadInput | null {
if (typeof rawInput !== "object" || rawInput === null) {
return null;
}
const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId"));
if (!threadId) {
return null;
}
return { threadId: threadId as BrowserClearThreadInput["threadId"] };
}

function writeDesktopStreamChunk(
streamName: "stdout" | "stderr",
chunk: unknown,
Expand Down Expand Up @@ -277,6 +412,22 @@ let updateCheckInFlight = false;
let updateDownloadInFlight = false;
let updaterConfigured = false;
let updateState: DesktopUpdateState = initialUpdateState();
const browserManager = createBrowserManager({
emitEvent: (event: BrowserEvent) => {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) continue;
window.webContents.send(BROWSER_EVENT_CHANNEL, event);
}
},
getWindow: () => mainWindow,
openExternal: (url) => {
const externalUrl = getSafeExternalUrl(url);
if (!externalUrl) {
return;
}
void shell.openExternal(externalUrl);
},
});

function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
if (updateDownloadInFlight) return "download";
Expand Down Expand Up @@ -1182,6 +1333,78 @@ function registerIpcHandlers(): void {
}
});

ipcMain.removeHandler(BROWSER_ENSURE_TAB_CHANNEL);
ipcMain.handle(BROWSER_ENSURE_TAB_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserEnsureTabInput(rawInput);
if (!input) {
return;
}
await browserManager.ensureTab(input);
});

ipcMain.removeHandler(BROWSER_NAVIGATE_CHANNEL);
ipcMain.handle(BROWSER_NAVIGATE_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserNavigateInput(rawInput);
if (!input) {
return;
}
await browserManager.navigate(input);
});

ipcMain.removeHandler(BROWSER_GO_BACK_CHANNEL);
ipcMain.handle(BROWSER_GO_BACK_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserTabTargetInput(rawInput);
if (!input) {
return;
}
await browserManager.goBack(input);
});

ipcMain.removeHandler(BROWSER_GO_FORWARD_CHANNEL);
ipcMain.handle(BROWSER_GO_FORWARD_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserTabTargetInput(rawInput);
if (!input) {
return;
}
await browserManager.goForward(input);
});

ipcMain.removeHandler(BROWSER_RELOAD_CHANNEL);
ipcMain.handle(BROWSER_RELOAD_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserTabTargetInput(rawInput);
if (!input) {
return;
}
await browserManager.reload(input);
});

ipcMain.removeHandler(BROWSER_CLOSE_TAB_CHANNEL);
ipcMain.handle(BROWSER_CLOSE_TAB_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserTabTargetInput(rawInput);
if (!input) {
return;
}
await browserManager.closeTab(input);
});

ipcMain.removeHandler(BROWSER_SYNC_HOST_CHANNEL);
ipcMain.handle(BROWSER_SYNC_HOST_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserSyncHostInput(rawInput);
if (!input) {
return;
}
browserManager.syncHost(input);
});

ipcMain.removeHandler(BROWSER_CLEAR_THREAD_CHANNEL);
ipcMain.handle(BROWSER_CLEAR_THREAD_CHANNEL, async (_event, rawInput: unknown) => {
const input = getSafeBrowserClearThreadInput(rawInput);
if (!input) {
return;
}
browserManager.clearThread(input);
});

ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);

Expand Down Expand Up @@ -1336,6 +1559,7 @@ app.on("before-quit", () => {
isQuitting = true;
writeDesktopLogHeader("before-quit received");
clearUpdatePollTimer();
browserManager.destroyAll();
stopBackend();
restoreStdIoCapture?.();
});
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const BROWSER_ENSURE_TAB_CHANNEL = "desktop:browser-ensure-tab";
const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate";
const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back";
const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward";
const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload";
const BROWSER_CLOSE_TAB_CHANNEL = "desktop:browser-close-tab";
const BROWSER_SYNC_HOST_CHANNEL = "desktop:browser-sync-host";
const BROWSER_CLEAR_THREAD_CHANNEL = "desktop:browser-clear-thread";
const BROWSER_EVENT_CHANNEL = "desktop:browser-event";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
Expand All @@ -20,6 +29,25 @@ contextBridge.exposeInMainWorld("desktopBridge", {
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
browserEnsureTab: (input) => ipcRenderer.invoke(BROWSER_ENSURE_TAB_CHANNEL, input),
browserNavigate: (input) => ipcRenderer.invoke(BROWSER_NAVIGATE_CHANNEL, input),
browserGoBack: (input) => ipcRenderer.invoke(BROWSER_GO_BACK_CHANNEL, input),
browserGoForward: (input) => ipcRenderer.invoke(BROWSER_GO_FORWARD_CHANNEL, input),
browserReload: (input) => ipcRenderer.invoke(BROWSER_RELOAD_CHANNEL, input),
browserCloseTab: (input) => ipcRenderer.invoke(BROWSER_CLOSE_TAB_CHANNEL, input),
browserSyncHost: (input) => ipcRenderer.invoke(BROWSER_SYNC_HOST_CHANNEL, input),
browserClearThread: (input) => ipcRenderer.invoke(BROWSER_CLEAR_THREAD_CHANNEL, input),
onBrowserEvent: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, payload: unknown) => {
if (typeof payload !== "object" || payload === null) return;
listener(payload as Parameters<typeof listener>[0]);
};

ipcRenderer.on(BROWSER_EVENT_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(BROWSER_EVENT_CHANNEL, wrappedListener);
};
},
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+b", command: "browser.toggle", when: "!terminalFocus" },
{ key: "mod+t", command: "browser.newTab", when: "!terminalFocus" },
{ key: "mod+w", command: "browser.closeTab", when: "!terminalFocus" },
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { randomUUID } from "./lib/utils";

export type BrowserTab = {
id: string;
url: string;
title?: string | null;
faviconUrl?: string | null;
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
lastError?: string | null;
};

export type BrowserUrlParseResult = { ok: true; url: string } | { ok: false; error: string };

const EXPLICIT_SCHEME_PATTERN = /^[A-Za-z][A-Za-z\d+.-]*:\/\//;

export function createBrowserTab(url = "about:blank"): BrowserTab {
return {
id: `browser-tab-${randomUUID()}`,
url,
title: null,
faviconUrl: null,
isLoading: false,
canGoBack: false,
canGoForward: false,
lastError: null,
};
}

export function normalizeBrowserDisplayUrl(url: string | null | undefined): string {
if (!url || url === "about:blank") {
return "";
}
return url;
}

export function getBrowserTabLabel(tab: Pick<BrowserTab, "title" | "url">): string {
const title = tab.title?.trim();
if (title) {
return title;
}
if (tab.url === "about:blank") {
return "New tab";
}

try {
const parsed = new URL(tab.url);
return parsed.host || parsed.href;
} catch {
return tab.url;
}
}

export function parseSubmittedBrowserUrl(rawValue: string): BrowserUrlParseResult {
const trimmed = rawValue.trim();
if (!trimmed) {
return { ok: true, url: "about:blank" };
}

if (trimmed === "about:blank") {
return { ok: true, url: trimmed };
}

const candidate = EXPLICIT_SCHEME_PATTERN.test(trimmed) ? trimmed : `http://${trimmed}`;

try {
return { ok: true, url: new URL(candidate).toString() };
} catch {
return { ok: false, error: "Enter a valid URL." };
}
}
Loading