From c643de8ba70beab1f49d9176e8bb745cac807ed5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 14:24:41 -0800 Subject: [PATCH] Enable safe external PR link opening in desktop app - add desktop IPC handler to validate and open http(s) URLs via Electron shell - expose `shell.openExternal` in shared NativeApi contracts and web bridge - route Git PR opening and native API reads through the new `useNativeApi` hook Co-authored-by: codex --- apps/desktop/src/main.ts | 28 ++++++++++++++++++- apps/desktop/src/preload.ts | 2 ++ apps/web/src/App.tsx | 10 +++---- apps/web/src/components/BranchToolbar.tsx | 12 +++++--- apps/web/src/components/ChatView.tsx | 8 ++---- apps/web/src/components/GitActionsControl.tsx | 27 +++++++----------- apps/web/src/components/Sidebar.tsx | 6 ++-- apps/web/src/hooks/useNativeApi.ts | 6 ++++ apps/web/src/vite-env.d.ts | 1 + apps/web/src/wsNativeApi.ts | 14 ++++++++++ packages/contracts/src/ipc.ts | 2 +- 11 files changed, 80 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/hooks/useNativeApi.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index eada6aae7747f54cd6862eb2fc3d34bee0a4de4b..a5f8aa39cffb7437867666ba97f722e26251c89c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,7 +5,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; import { fixPath } from "./fixPath"; @@ -13,6 +13,7 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const ROOT_DIR = path.resolve(__dirname, "../../.."); const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); @@ -159,6 +160,31 @@ function registerIpcHandlers(): void { }); }); }); + + ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); + ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { + if (typeof rawUrl !== "string" || rawUrl.length === 0) { + return false; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(rawUrl); + } catch { + return false; + } + + if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { + return false; + } + + try { + await shell.openExternal(parsedUrl.toString()); + return true; + } catch { + return false; + } + }); } function createWindow(): BrowserWindow { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 47041b5611e93c6665e1c6718d94c17fb4871d57..b1961d5442a5da326e6ccd9977b6062f72c7c6e2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise, showContextMenu: (items: { id: string; label: string }[]) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise, + openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise, }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cec89d05b73c2985cd76d88e01d3203a920e185c..88d2b233e85dc86e14468cfc5bf50914be1d845c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,18 +1,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import ChatView from "./components/ChatView"; import DiffPanel from "./components/DiffPanel"; import Sidebar from "./components/Sidebar"; import { isElectron } from "./env"; import { DEFAULT_MODEL } from "./model-logic"; -import { readNativeApi } from "./session-logic"; import { StoreProvider, useStore } from "./store"; import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; import { onServerWelcome } from "./wsNativeApi"; +import { useNativeApi } from "./hooks/useNativeApi"; function EventRouter() { - const api = useMemo(() => readNativeApi(), []); + const api = useNativeApi(); const { dispatch } = useStore(); const activeAssistantItemRef = useRef(null); @@ -109,7 +109,7 @@ function AutoProjectBootstrap() { } function DesktopProjectBootstrap() { - const api = useMemo(() => readNativeApi(), []); + const api = useNativeApi(); const { dispatch } = useStore(); const bootstrappedRef = useRef(false); @@ -159,7 +159,7 @@ function DesktopProjectBootstrap() { } function Layout() { - const api = useMemo(() => readNativeApi(), []); + const api = useNativeApi(); const { state } = useStore(); if (!api) { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 2379d0b91cc0a75d2d7325ae94e2fe01f712f8b0..2abbae13c81cfd651492392f1990bc0b2847690f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,9 +1,9 @@ import type { GitBranch } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; -import { readNativeApi } from "../session-logic"; import { useStore } from "../store"; +import { useNativeApi } from "../hooks/useNativeApi"; interface BranchToolbarProps { envMode: "local" | "worktree"; @@ -13,7 +13,7 @@ interface BranchToolbarProps { export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { const { state, dispatch } = useStore(); - const api = useMemo(() => readNativeApi(), []); + const api = useNativeApi(); const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); @@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B {branch.name} {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( - {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"} + {branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : "default"} )} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 732b0c6a8817523a8777730930bd44ca60dc2d04..d490f7443a6c5f0127e05b6253a5d60c94b797d3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -31,7 +31,6 @@ import { formatDuration, formatElapsed, formatTimestamp, - readNativeApi, } from "../session-logic"; import { useStore } from "../store"; import BranchToolbar from "./BranchToolbar"; @@ -39,6 +38,7 @@ import GitActionsControl from "./GitActionsControl"; import { isTerminalToggleShortcut } from "../terminal-shortcuts"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import { useNativeApi } from "../hooks/useNativeApi"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -133,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] export default function ChatView() { const { state, dispatch } = useStore(); - const api = useMemo(() => readNativeApi(), []); + const api = useNativeApi(); const [prompt, setPrompt] = useState(""); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); @@ -694,9 +694,7 @@ export default function ChatView() { )} {/* Git actions */} - {activeProject && ( - - )} + {activeProject && } {/* Diff toggle */}