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