authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:24:41-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:24:41-08:00
logc643de8ba70beab1f49d9176e8bb745cac807ed5
tree5d2105b39fce000ff1f7dde69fed83e0f58e5e05
parent9accd1ffb758431dd80b8c1b79ceb5dc618abaca

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 <codex@users.noreply.github.com>

11 files changed, 80 insertions(+), 36 deletions(-)

apps/desktop/src/main.ts+27-1
......@@ -5,7 +5,7 @@ import net from "node:net";
55import os from "node:os";
66import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron";
8import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
99
1010import { fixPath } from "./fixPath";
1111
......@@ -13,6 +13,7 @@ fixPath();
1313
1414const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
1515const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
16const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
1617const ROOT_DIR = path.resolve(__dirname, "../../..");
1718const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
1819const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
......@@ -159,6 +160,31 @@ function registerIpcHandlers(): void {
159160 });
160161 });
161162 });
163
164 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
165 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
166 if (typeof rawUrl !== "string" || rawUrl.length === 0) {
167 return false;
168 }
169
170 let parsedUrl: URL;
171 try {
172 parsedUrl = new URL(rawUrl);
173 } catch {
174 return false;
175 }
176
177 if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
178 return false;
179 }
180
181 try {
182 await shell.openExternal(parsedUrl.toString());
183 return true;
184 } catch {
185 return false;
186 }
187 });
162188}
163189
164190function createWindow(): BrowserWindow {
apps/desktop/src/preload.ts+2
......@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
22
33const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
44const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
56const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
67
78contextBridge.exposeInMainWorld("desktopBridge", {
......@@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", {
910 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,
1011 showContextMenu: (items: { id: string; label: string }[]) =>
1112 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
13 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
1214});
apps/web/src/App.tsx+5-5
......@@ -1,18 +1,18 @@
11import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { useEffect, useMemo, useRef } from "react";
2import { useEffect, useRef } from "react";
33
44import ChatView from "./components/ChatView";
55import DiffPanel from "./components/DiffPanel";
66import Sidebar from "./components/Sidebar";
77import { isElectron } from "./env";
88import { DEFAULT_MODEL } from "./model-logic";
9import { readNativeApi } from "./session-logic";
109import { StoreProvider, useStore } from "./store";
1110import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
1211import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";
1313
1414function EventRouter() {
15 const api = useMemo(() => readNativeApi(), []);
15 const api = useNativeApi();
1616 const { dispatch } = useStore();
1717 const activeAssistantItemRef = useRef<string | null>(null);
1818
......@@ -109,7 +109,7 @@ function AutoProjectBootstrap() {
109109}
110110
111111function DesktopProjectBootstrap() {
112 const api = useMemo(() => readNativeApi(), []);
112 const api = useNativeApi();
113113 const { dispatch } = useStore();
114114 const bootstrappedRef = useRef(false);
115115
......@@ -159,7 +159,7 @@ function DesktopProjectBootstrap() {
159159}
160160
161161function Layout() {
162 const api = useMemo(() => readNativeApi(), []);
162 const api = useNativeApi();
163163 const { state } = useStore();
164164
165165 if (!api) {
apps/web/src/components/BranchToolbar.tsx+8-4
......@@ -1,9 +1,9 @@
11import type { GitBranch } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useMemo, useRef, useState } from "react";
3import { useEffect, useRef, useState } from "react";
44
5import { readNativeApi } from "../session-logic";
65import { useStore } from "../store";
6import { useNativeApi } from "../hooks/useNativeApi";
77
88interface BranchToolbarProps {
99 envMode: "local" | "worktree";
......@@ -13,7 +13,7 @@ interface BranchToolbarProps {
1313
1414export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
1515 const { state, dispatch } = useStore();
16 const api = useMemo(() => readNativeApi(), []);
16 const api = useNativeApi();
1717 const queryClient = useQueryClient();
1818
1919 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
......@@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
254254 <span className="truncate">{branch.name}</span>
255255 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
256256 <span className="shrink-0 text-[10px] text-muted-foreground/45">
257 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
257 {branch.current
258 ? "current"
259 : hasSecondaryWorktree
260 ? "worktree"
261 : "default"}
258262 </span>
259263 )}
260264 </button>
apps/web/src/components/ChatView.tsx+3-5
......@@ -31,7 +31,6 @@ import {
3131 formatDuration,
3232 formatElapsed,
3333 formatTimestamp,
34 readNativeApi,
3534} from "../session-logic";
3635import { useStore } from "../store";
3736import BranchToolbar from "./BranchToolbar";
......@@ -39,6 +38,7 @@ import GitActionsControl from "./GitActionsControl";
3938import { isTerminalToggleShortcut } from "../terminal-shortcuts";
4039import ChatMarkdown from "./ChatMarkdown";
4140import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
41import { useNativeApi } from "../hooks/useNativeApi";
4242
4343function formatMessageMeta(createdAt: string, duration: string | null): string {
4444 if (!duration) return formatTimestamp(createdAt);
......@@ -133,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]
133133
134134export default function ChatView() {
135135 const { state, dispatch } = useStore();
136 const api = useMemo(() => readNativeApi(), []);
136 const api = useNativeApi();
137137 const [prompt, setPrompt] = useState("");
138138 const [isSending, setIsSending] = useState(false);
139139 const [isConnecting, setIsConnecting] = useState(false);
......@@ -694,9 +694,7 @@ export default function ChatView() {
694694 </div>
695695 )}
696696 {/* Git actions */}
697 {activeProject && (
698 <GitActionsControl api={api} gitCwd={gitCwd} />
699 )}
697 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
700698 {/* Diff toggle */}
701699 <button
702700 type="button"
apps/web/src/components/GitActionsControl.tsx+10-17
......@@ -151,18 +151,6 @@ function buildGitActionMenuItems(
151151 gitStatus.hasUpstream &&
152152 gitStatus.behindCount === 0;
153153
154 if (!hasChanges && !hasAhead && hasOpenPr) {
155 return [
156 {
157 id: "pr",
158 label: "Open PR",
159 disabled: !canOpenPr,
160 icon: "pr",
161 kind: "open_pr",
162 },
163 ];
164 }
165
166154 return [
167155 {
168156 id: "commit",
......@@ -344,6 +332,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
344332 const openExistingPr = useCallback(() => {
345333 setIsGitMenuOpen(false);
346334 setGitActionError(null);
335 if (!api) {
336 setGitActionError("Link opening is unavailable.");
337 return;
338 }
347339
348340 const prUrl = gitStatus?.openPr?.url ?? null;
349341 if (!prUrl) {
......@@ -351,11 +343,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
351343 return;
352344 }
353345
354 const popup = window.open(prUrl, "_blank", "noopener,noreferrer");
355 if (!popup) {
356 setGitActionError("Unable to open PR. Allow popups and try again.");
357 }
358 }, [gitStatus?.openPr?.url]);
346 void api.shell.openExternal(prUrl).catch((error) => {
347 setGitActionError(
348 error instanceof Error ? error.message : "Unable to open PR link.",
349 );
350 });
351 }, [api, gitStatus?.openPr?.url]);
359352
360353 const runGitAction = useCallback(async () => {
361354 if (!api || !gitCwd || !isGitModalOpen) return;
apps/web/src/components/Sidebar.tsx+3-3
......@@ -1,11 +1,11 @@
11import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";
2import { useCallback, useEffect, useState } from "react";
33import { isElectron } from "../env";
44import { useTheme } from "../hooks/useTheme";
55import { DEFAULT_MODEL } from "../model-logic";
6import { readNativeApi } from "../session-logic";
76import { useStore } from "../store";
87import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
8import { useNativeApi } from "../hooks/useNativeApi";
99
1010const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
1111function formatRelativeTime(iso: string): string {
......@@ -35,7 +35,7 @@ function threadStatusLabel(
3535
3636export default function Sidebar() {
3737 const { state, dispatch } = useStore();
38 const api = useMemo(() => readNativeApi(), []);
38 const api = useNativeApi();
3939 const { theme, setTheme } = useTheme();
4040 const [addingProject, setAddingProject] = useState(false);
4141 const [newCwd, setNewCwd] = useState("");
apps/web/src/hooks/useNativeApi.ts created+6
......@@ -0,0 +1,6 @@
1import { useMemo } from "react";
2import { readNativeApi } from "../session-logic";
3
4export function useNativeApi() {
5 return useMemo(() => readNativeApi(), []);
6}
apps/web/src/vite-env.d.ts+1
......@@ -6,6 +6,7 @@ interface DesktopBridge {
66 getWsUrl: () => string | null;
77 pickFolder: () => Promise<string | null>;
88 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
9 openExternal: (url: string) => Promise<boolean>;
910}
1011
1112declare global {
apps/web/src/wsNativeApi.ts+14
......@@ -96,6 +96,20 @@ export function createWsNativeApi(): NativeApi {
9696 shell: {
9797 openInEditor: (cwd, editor) =>
9898 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
99 openExternal: async (url) => {
100 if (window.desktopBridge) {
101 const opened = await window.desktopBridge.openExternal(url);
102 if (!opened) {
103 throw new Error("Unable to open link.");
104 }
105 return;
106 }
107
108 const popup = window.open(url, "_blank", "noopener,noreferrer");
109 if (!popup) {
110 throw new Error("Unable to open link. Allow popups and try again.");
111 }
112 },
99113 },
100114 git: {
101115 status: (input) => transport.request(WS_METHODS.gitStatus, input),
packages/contracts/src/ipc.ts+1-1
......@@ -89,6 +89,7 @@ export interface NativeApi {
8989 };
9090 shell: {
9191 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
92 openExternal: (url: string) => Promise<void>;
9293 };
9394 git: {
9495 // Existing branch/worktree API
......@@ -111,4 +112,3 @@ export interface NativeApi {
111112 ) => Promise<T | null>;
112113 };
113114}
114