Skip to content
Closed
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 @@ -1160,6 +1311,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 @@ -1314,6 +1537,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
19 changes: 19 additions & 0 deletions apps/web/src/blockingOverlayStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { create } from "zustand";

interface BlockingOverlayStoreState {
blockingOverlayCount: number;
incrementBlockingOverlayCount: () => void;
decrementBlockingOverlayCount: () => void;
}

export const useBlockingOverlayStore = create<BlockingOverlayStoreState>()((set) => ({
blockingOverlayCount: 0,
incrementBlockingOverlayCount: () =>
set((state) => ({
blockingOverlayCount: state.blockingOverlayCount + 1,
})),
decrementBlockingOverlayCount: () =>
set((state) => ({
blockingOverlayCount: Math.max(0, state.blockingOverlayCount - 1),
})),
}));
Loading
Loading