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";...@@ -5,7 +5,7 @@ import net from "node:net";
5import os from "node:os";5import os from "node:os";
6import path from "node:path";6import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron";8import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
99
10import { fixPath } from "./fixPath";10import { fixPath } from "./fixPath";
1111
...@@ -13,6 +13,7 @@ fixPath();...@@ -13,6 +13,7 @@ fixPath();
1313
14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
15const CONTEXT_MENU_CHANNEL = "desktop:context-menu";15const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
16const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
16const ROOT_DIR = path.resolve(__dirname, "../../..");17const ROOT_DIR = path.resolve(__dirname, "../../..");
17const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");18const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
18const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");19const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
...@@ -159,6 +160,31 @@ function registerIpcHandlers(): void {...@@ -159,6 +160,31 @@ function registerIpcHandlers(): void {
159 });160 });
160 });161 });
161 });162 });
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 });
162}188}
163189
164function createWindow(): BrowserWindow {190function createWindow(): BrowserWindow {
apps/desktop/src/preload.ts+2
...@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";...@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
22
3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
5const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;6const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
67
7contextBridge.exposeInMainWorld("desktopBridge", {8contextBridge.exposeInMainWorld("desktopBridge", {
...@@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", {
9 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,10 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,
10 showContextMenu: (items: { id: string; label: string }[]) =>11 showContextMenu: (items: { id: string; label: string }[]) =>
11 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,12 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
13 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
12});14});
apps/web/src/App.tsx+5-5
...@@ -1,18 +1,18 @@...@@ -1,18 +1,18 @@
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { useEffect, useMemo, useRef } from "react";2import { useEffect, useRef } from "react";
33
4import ChatView from "./components/ChatView";4import ChatView from "./components/ChatView";
5import DiffPanel from "./components/DiffPanel";5import DiffPanel from "./components/DiffPanel";
6import Sidebar from "./components/Sidebar";6import Sidebar from "./components/Sidebar";
7import { isElectron } from "./env";7import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";8import { DEFAULT_MODEL } from "./model-logic";
9import { readNativeApi } from "./session-logic";
10import { StoreProvider, useStore } from "./store";9import { StoreProvider, useStore } from "./store";
11import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";10import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
12import { onServerWelcome } from "./wsNativeApi";11import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";
1313
14function EventRouter() {14function EventRouter() {
15 const api = useMemo(() => readNativeApi(), []);15 const api = useNativeApi();
16 const { dispatch } = useStore();16 const { dispatch } = useStore();
17 const activeAssistantItemRef = useRef<string | null>(null);17 const activeAssistantItemRef = useRef<string | null>(null);
1818
...@@ -109,7 +109,7 @@ function AutoProjectBootstrap() {...@@ -109,7 +109,7 @@ function AutoProjectBootstrap() {
109}109}
110110
111function DesktopProjectBootstrap() {111function DesktopProjectBootstrap() {
112 const api = useMemo(() => readNativeApi(), []);112 const api = useNativeApi();
113 const { dispatch } = useStore();113 const { dispatch } = useStore();
114 const bootstrappedRef = useRef(false);114 const bootstrappedRef = useRef(false);
115115
...@@ -159,7 +159,7 @@ function DesktopProjectBootstrap() {...@@ -159,7 +159,7 @@ function DesktopProjectBootstrap() {
159}159}
160160
161function Layout() {161function Layout() {
162 const api = useMemo(() => readNativeApi(), []);162 const api = useNativeApi();
163 const { state } = useStore();163 const { state } = useStore();
164164
165 if (!api) {165 if (!api) {
apps/web/src/components/BranchToolbar.tsx+8-4
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { 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";
6import { useStore } from "../store";5import { useStore } from "../store";
6import { useNativeApi } from "../hooks/useNativeApi";
77
8interface BranchToolbarProps {8interface BranchToolbarProps {
9 envMode: "local" | "worktree";9 envMode: "local" | "worktree";
...@@ -13,7 +13,7 @@ interface BranchToolbarProps {...@@ -13,7 +13,7 @@ interface BranchToolbarProps {
1313
14export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {14export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
15 const { state, dispatch } = useStore();15 const { state, dispatch } = useStore();
16 const api = useMemo(() => readNativeApi(), []);16 const api = useNativeApi();
17 const queryClient = useQueryClient();17 const queryClient = useQueryClient();
1818
19 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);19 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
...@@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
254 <span className="truncate">{branch.name}</span>254 <span className="truncate">{branch.name}</span>
255 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (255 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
256 <span className="shrink-0 text-[10px] text-muted-foreground/45">256 <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"}
258 </span>262 </span>
259 )}263 )}
260 </button>264 </button>
apps/web/src/components/ChatView.tsx+3-5
...@@ -31,7 +31,6 @@ import {...@@ -31,7 +31,6 @@ import {
31 formatDuration,31 formatDuration,
32 formatElapsed,32 formatElapsed,
33 formatTimestamp,33 formatTimestamp,
34 readNativeApi,
35} from "../session-logic";34} from "../session-logic";
36import { useStore } from "../store";35import { useStore } from "../store";
37import BranchToolbar from "./BranchToolbar";36import BranchToolbar from "./BranchToolbar";
...@@ -39,6 +38,7 @@ import GitActionsControl from "./GitActionsControl";...@@ -39,6 +38,7 @@ import GitActionsControl from "./GitActionsControl";
39import { isTerminalToggleShortcut } from "../terminal-shortcuts";38import { isTerminalToggleShortcut } from "../terminal-shortcuts";
40import ChatMarkdown from "./ChatMarkdown";39import ChatMarkdown from "./ChatMarkdown";
41import ThreadTerminalDrawer from "./ThreadTerminalDrawer";40import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
41import { useNativeApi } from "../hooks/useNativeApi";
4242
43function formatMessageMeta(createdAt: string, duration: string | null): string {43function formatMessageMeta(createdAt: string, duration: string | null): string {
44 if (!duration) return formatTimestamp(createdAt);44 if (!duration) return formatTimestamp(createdAt);
...@@ -133,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]...@@ -133,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]
133133
134export default function ChatView() {134export default function ChatView() {
135 const { state, dispatch } = useStore();135 const { state, dispatch } = useStore();
136 const api = useMemo(() => readNativeApi(), []);136 const api = useNativeApi();
137 const [prompt, setPrompt] = useState("");137 const [prompt, setPrompt] = useState("");
138 const [isSending, setIsSending] = useState(false);138 const [isSending, setIsSending] = useState(false);
139 const [isConnecting, setIsConnecting] = useState(false);139 const [isConnecting, setIsConnecting] = useState(false);
...@@ -694,9 +694,7 @@ export default function ChatView() {...@@ -694,9 +694,7 @@ export default function ChatView() {
694 </div>694 </div>
695 )}695 )}
696 {/* Git actions */}696 {/* Git actions */}
697 {activeProject && (697 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
698 <GitActionsControl api={api} gitCwd={gitCwd} />
699 )}
700 {/* Diff toggle */}698 {/* Diff toggle */}
701 <button699 <button
702 type="button"700 type="button"
apps/web/src/components/GitActionsControl.tsx+10-17
...@@ -151,18 +151,6 @@ function buildGitActionMenuItems(...@@ -151,18 +151,6 @@ function buildGitActionMenuItems(
151 gitStatus.hasUpstream &&151 gitStatus.hasUpstream &&
152 gitStatus.behindCount === 0;152 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
166 return [154 return [
167 {155 {
168 id: "commit",156 id: "commit",
...@@ -344,6 +332,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -344,6 +332,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
344 const openExistingPr = useCallback(() => {332 const openExistingPr = useCallback(() => {
345 setIsGitMenuOpen(false);333 setIsGitMenuOpen(false);
346 setGitActionError(null);334 setGitActionError(null);
335 if (!api) {
336 setGitActionError("Link opening is unavailable.");
337 return;
338 }
347339
348 const prUrl = gitStatus?.openPr?.url ?? null;340 const prUrl = gitStatus?.openPr?.url ?? null;
349 if (!prUrl) {341 if (!prUrl) {
...@@ -351,11 +343,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -351,11 +343,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
351 return;343 return;
352 }344 }
353345
354 const popup = window.open(prUrl, "_blank", "noopener,noreferrer");346 void api.shell.openExternal(prUrl).catch((error) => {
355 if (!popup) {347 setGitActionError(
356 setGitActionError("Unable to open PR. Allow popups and try again.");348 error instanceof Error ? error.message : "Unable to open PR link.",
357 }349 );
358 }, [gitStatus?.openPr?.url]);350 });
351 }, [api, gitStatus?.openPr?.url]);
359352
360 const runGitAction = useCallback(async () => {353 const runGitAction = useCallback(async () => {
361 if (!api || !gitCwd || !isGitModalOpen) return;354 if (!api || !gitCwd || !isGitModalOpen) return;
apps/web/src/components/Sidebar.tsx+3-3
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";1import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useState } from "react";
3import { isElectron } from "../env";3import { isElectron } from "../env";
4import { useTheme } from "../hooks/useTheme";4import { useTheme } from "../hooks/useTheme";
5import { DEFAULT_MODEL } from "../model-logic";5import { DEFAULT_MODEL } from "../model-logic";
6import { readNativeApi } from "../session-logic";
7import { useStore } from "../store";6import { useStore } from "../store";
8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";7import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
8import { useNativeApi } from "../hooks/useNativeApi";
99
10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
11function formatRelativeTime(iso: string): string {11function formatRelativeTime(iso: string): string {
...@@ -35,7 +35,7 @@ function threadStatusLabel(...@@ -35,7 +35,7 @@ function threadStatusLabel(
3535
36export default function Sidebar() {36export default function Sidebar() {
37 const { state, dispatch } = useStore();37 const { state, dispatch } = useStore();
38 const api = useMemo(() => readNativeApi(), []);38 const api = useNativeApi();
39 const { theme, setTheme } = useTheme();39 const { theme, setTheme } = useTheme();
40 const [addingProject, setAddingProject] = useState(false);40 const [addingProject, setAddingProject] = useState(false);
41 const [newCwd, setNewCwd] = useState("");41 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 {...@@ -6,6 +6,7 @@ interface DesktopBridge {
6 getWsUrl: () => string | null;6 getWsUrl: () => string | null;
7 pickFolder: () => Promise<string | null>;7 pickFolder: () => Promise<string | null>;
8 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;8 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
9 openExternal: (url: string) => Promise<boolean>;
9}10}
1011
11declare global {12declare global {
apps/web/src/wsNativeApi.ts+14
...@@ -96,6 +96,20 @@ export function createWsNativeApi(): NativeApi {...@@ -96,6 +96,20 @@ export function createWsNativeApi(): NativeApi {
96 shell: {96 shell: {
97 openInEditor: (cwd, editor) =>97 openInEditor: (cwd, editor) =>
98 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),98 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 },
99 },113 },
100 git: {114 git: {
101 status: (input) => transport.request(WS_METHODS.gitStatus, input),115 status: (input) => transport.request(WS_METHODS.gitStatus, input),
packages/contracts/src/ipc.ts+1-1
...@@ -89,6 +89,7 @@ export interface NativeApi {...@@ -89,6 +89,7 @@ export interface NativeApi {
89 };89 };
90 shell: {90 shell: {
91 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;91 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
92 openExternal: (url: string) => Promise<void>;
92 };93 };
93 git: {94 git: {
94 // Existing branch/worktree API95 // Existing branch/worktree API
...@@ -111,4 +112,3 @@ export interface NativeApi {...@@ -111,4 +112,3 @@ export interface NativeApi {
111 ) => Promise<T | null>;112 ) => Promise<T | null>;
112 };113 };
113}114}
114