From 7ad83a2bdccb3f2194c8b002fd8a507cc98b359c Mon Sep 17 00:00:00 2001 From: Will Chen Date: Mon, 14 Apr 2025 18:12:43 -0700 Subject: [PATCH] Basic GitHub integration flow --- src/components/GitHubConnector.tsx | 197 +++++++++++++++++++ src/hooks/useSettings.ts | 48 ++--- src/ipc/handlers/github_handlers.ts | 282 ++++++++++++++++++++++++++++ src/ipc/ipc_client.ts | 68 +++++++ src/ipc/ipc_host.ts | 2 + src/lib/schemas.ts | 11 ++ src/main/settings.ts | 32 +++- src/pages/app-details.tsx | 7 +- src/preload.ts | 12 ++ 9 files changed, 633 insertions(+), 26 deletions(-) create mode 100644 src/components/GitHubConnector.tsx create mode 100644 src/ipc/handlers/github_handlers.ts diff --git a/src/components/GitHubConnector.tsx b/src/components/GitHubConnector.tsx new file mode 100644 index 0000000..6077a14 --- /dev/null +++ b/src/components/GitHubConnector.tsx @@ -0,0 +1,197 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Github } from "lucide-react"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useSettings } from "@/hooks/useSettings"; + +interface GitHubConnectorProps { + appId: number | null; +} + +export function GitHubConnector({ appId }: GitHubConnectorProps) { + // --- GitHub Device Flow State --- + const { settings, refreshSettings } = useSettings(); + const [githubUserCode, setGithubUserCode] = useState(null); + const [githubVerificationUri, setGithubVerificationUri] = useState< + string | null + >(null); + const [githubError, setGithubError] = useState(null); + const [isConnectingToGithub, setIsConnectingToGithub] = useState(false); + const [githubStatusMessage, setGithubStatusMessage] = useState( + null + ); + // --- --- + + const handleConnectToGithub = async () => { + if (!appId) return; + setIsConnectingToGithub(true); + setGithubError(null); + setGithubUserCode(null); + setGithubVerificationUri(null); + setGithubStatusMessage("Requesting device code from GitHub..."); + + // Send IPC message to main process to start the flow + IpcClient.getInstance().startGithubDeviceFlow(appId); + }; + + useEffect(() => { + if (!appId) return; // Don't set up listeners if appId is null initially + + const cleanupFunctions: (() => void)[] = []; + + // Listener for updates (user code, verification uri, status messages) + const removeUpdateListener = + IpcClient.getInstance().onGithubDeviceFlowUpdate((data) => { + console.log("Received github:flow-update", data); + if (data.userCode) { + setGithubUserCode(data.userCode); + } + if (data.verificationUri) { + setGithubVerificationUri(data.verificationUri); + } + if (data.message) { + setGithubStatusMessage(data.message); + } + + setGithubError(null); // Clear previous errors on new update + if (!data.userCode && !data.verificationUri && data.message) { + // Likely just a status message, keep connecting state + setIsConnectingToGithub(true); + } + if (data.userCode && data.verificationUri) { + setIsConnectingToGithub(true); // Still connecting until success/error + } + }); + cleanupFunctions.push(removeUpdateListener); + + // Listener for success + const removeSuccessListener = + IpcClient.getInstance().onGithubDeviceFlowSuccess((data) => { + console.log("Received github:flow-success", data); + setGithubStatusMessage("Successfully connected to GitHub!"); + setGithubUserCode(null); // Clear user-facing info + setGithubVerificationUri(null); + setGithubError(null); + setIsConnectingToGithub(false); + refreshSettings(); + // TODO: Maybe update parent UI to show "Connected" state or trigger next action + }); + cleanupFunctions.push(removeSuccessListener); + + // Listener for errors + const removeErrorListener = IpcClient.getInstance().onGithubDeviceFlowError( + (data) => { + console.log("Received github:flow-error", data); + setGithubError(data.error || "An unknown error occurred."); + setGithubStatusMessage(null); + setGithubUserCode(null); + setGithubVerificationUri(null); + setIsConnectingToGithub(false); + } + ); + cleanupFunctions.push(removeErrorListener); + + // Cleanup function to remove all listeners when component unmounts or appId changes + return () => { + cleanupFunctions.forEach((cleanup) => cleanup()); + // Optional: Send a message to main process to cancel polling if component unmounts + // Only cancel if we were actually connecting for this specific appId + // IpcClient.getInstance().cancelGithubDeviceFlow(appId); + // Reset state when appId changes or component unmounts + setGithubUserCode(null); + setGithubVerificationUri(null); + setGithubError(null); + setIsConnectingToGithub(false); + setGithubStatusMessage(null); + }; + }, [appId]); // Re-run effect if appId changes + + if (settings?.githubSettings.secrets) { + return ( +
+

Connected to GitHub!

+
+ ); + } + + return ( +
+ {" "} + + {/* GitHub Connection Status/Instructions */} + {(githubUserCode || githubStatusMessage || githubError) && ( +
+

GitHub Connection

+ {githubError && ( +

+ Error: {githubError} +

+ )} + {githubUserCode && githubVerificationUri && ( + + )} + {githubStatusMessage && ( +

+ {githubStatusMessage} +

+ )} +
+ )} +
+ ); +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index f2ae3a2..3825d51 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useAtom } from "jotai"; import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms"; import { IpcClient } from "@/ipc/ipc_client"; @@ -18,31 +18,30 @@ export function useSettings() { const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const loadInitialData = useCallback(async () => { + setLoading(true); + try { + const ipcClient = IpcClient.getInstance(); + // Fetch settings and env vars concurrently + const [userSettings, fetchedEnvVars] = await Promise.all([ + ipcClient.getUserSettings(), + ipcClient.getEnvVars(), + ]); + setSettingsAtom(userSettings); + setEnvVarsAtom(fetchedEnvVars); + setError(null); + } catch (error) { + console.error("Error loading initial data:", error); + setError(error instanceof Error ? error : new Error(String(error))); + } finally { + setLoading(false); + } + }, [setSettingsAtom, setEnvVarsAtom]); useEffect(() => { - const loadInitialData = async () => { - setLoading(true); - try { - const ipcClient = IpcClient.getInstance(); - // Fetch settings and env vars concurrently - const [userSettings, fetchedEnvVars] = await Promise.all([ - ipcClient.getUserSettings(), - ipcClient.getEnvVars(), - ]); - setSettingsAtom(userSettings); - setEnvVarsAtom(fetchedEnvVars); - setError(null); - } catch (error) { - console.error("Error loading initial data:", error); - setError(error instanceof Error ? error : new Error(String(error))); - } finally { - setLoading(false); - } - }; - - loadInitialData(); // Only run once on mount, dependencies are stable getters/setters - }, [setSettingsAtom, setEnvVarsAtom]); + loadInitialData(); + }, [loadInitialData]); const updateSettings = async (newSettings: Partial) => { setLoading(true); @@ -84,5 +83,8 @@ export function useSettings() { isProviderSetup(provider) ); }, + refreshSettings: () => { + loadInitialData(); + }, }; } diff --git a/src/ipc/handlers/github_handlers.ts b/src/ipc/handlers/github_handlers.ts new file mode 100644 index 0000000..4b482e5 --- /dev/null +++ b/src/ipc/handlers/github_handlers.ts @@ -0,0 +1,282 @@ +import { + ipcMain, + IpcMainEvent, + BrowserWindow, + IpcMainInvokeEvent, +} from "electron"; +import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process +import { writeSettings } from "../../main/settings"; + +// --- GitHub Device Flow Constants --- +// TODO: Fetch this securely, e.g., from environment variables or a config file +const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || "Ov23liWV2HdC0RBLecWx"; +const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; +const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const GITHUB_SCOPES = "repo,user"; // Define the scopes needed + +// --- State Management (Simple in-memory, consider alternatives for robustness) --- +interface DeviceFlowState { + deviceCode: string; + interval: number; + timeoutId: NodeJS.Timeout | null; + isPolling: boolean; + window: BrowserWindow | null; // Reference to the window that initiated the flow +} + +// Simple map to track ongoing flows (key could be appId or a unique flow ID if needed) +// For simplicity, let's assume only one flow can happen at a time for now. +let currentFlowState: DeviceFlowState | null = null; + +// --- Helper Functions --- + +// function event.sender.send(channel: string, data: any) { +// if (currentFlowState?.window && !currentFlowState.window.isDestroyed()) { +// currentFlowState.window.webContents.send(channel, data); +// } +// } + +async function pollForAccessToken(event: IpcMainInvokeEvent) { + if (!currentFlowState || !currentFlowState.isPolling) { + console.log("[GitHub Handler] Polling stopped or no active flow."); + return; + } + + const { deviceCode, interval } = currentFlowState; + + console.log( + `[GitHub Handler] Polling for token with device code: ${deviceCode}` + ); + event.sender.send("github:flow-update", { + message: "Polling GitHub for authorization...", + }); + + try { + const response = await fetch(GITHUB_ACCESS_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = await response.json(); + + if (response.ok && data.access_token) { + // --- SUCCESS --- + console.log( + "[GitHub Handler] Successfully obtained GitHub Access Token." + ); // TODO: Store this token securely! + event.sender.send("github:flow-success", { + message: "Successfully connected!", + }); + writeSettings({ + githubSettings: { + secrets: { + accessToken: data.access_token, + }, + }, + }); + // TODO: Associate token with appId if provided + stopPolling(); + return; + } else if (data.error) { + switch (data.error) { + case "authorization_pending": + console.log("[GitHub Handler] Authorization pending..."); + event.sender.send("github:flow-update", { + message: "Waiting for user authorization...", + }); + // Schedule next poll + currentFlowState.timeoutId = setTimeout( + () => pollForAccessToken(event), + interval * 1000 + ); + break; + case "slow_down": + const newInterval = interval + 5; + console.log( + `[GitHub Handler] Slow down requested. New interval: ${newInterval}s` + ); + currentFlowState.interval = newInterval; // Update interval + event.sender.send("github:flow-update", { + message: `GitHub asked to slow down. Retrying in ${newInterval}s...`, + }); + currentFlowState.timeoutId = setTimeout( + () => pollForAccessToken(event), + newInterval * 1000 + ); + break; + case "expired_token": + console.error("[GitHub Handler] Device code expired."); + event.sender.send("github:flow-error", { + error: "Verification code expired. Please try again.", + }); + stopPolling(); + break; + case "access_denied": + console.error("[GitHub Handler] Access denied by user."); + event.sender.send("github:flow-error", { + error: "Authorization denied by user.", + }); + stopPolling(); + break; + default: + console.error( + `[GitHub Handler] Unknown GitHub error: ${ + data.error_description || data.error + }` + ); + event.sender.send("github:flow-error", { + error: `GitHub authorization error: ${ + data.error_description || data.error + }`, + }); + stopPolling(); + break; + } + } else { + throw new Error(`Unknown response structure: ${JSON.stringify(data)}`); + } + } catch (error) { + console.error( + "[GitHub Handler] Error polling for GitHub access token:", + error + ); + event.sender.send("github:flow-error", { + error: `Network or unexpected error during polling: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + stopPolling(); + } +} + +function stopPolling() { + if (currentFlowState) { + if (currentFlowState.timeoutId) { + clearTimeout(currentFlowState.timeoutId); + } + currentFlowState.isPolling = false; + currentFlowState.timeoutId = null; + // Maybe keep window reference for a bit if needed, or clear it + // currentFlowState.window = null; + console.log("[GitHub Handler] Polling stopped."); + } + // Setting to null signifies no active flow + // currentFlowState = null; // Decide if you want to clear immediately or allow potential restart +} + +// --- IPC Handlers --- + +function handleStartGithubFlow( + event: IpcMainInvokeEvent, + args: { appId: number | null } +) { + console.log( + `[GitHub Handler] Received github:start-flow for appId: ${args.appId}` + ); + + // If a flow is already in progress, maybe cancel it or send an error + if (currentFlowState && currentFlowState.isPolling) { + console.warn( + "[GitHub Handler] Another GitHub flow is already in progress." + ); + event.sender.send("github:flow-error", { + error: "Another connection process is already active.", + }); + return; + } + + // Store the window that initiated the request + const window = BrowserWindow.fromWebContents(event.sender); + if (!window) { + console.error("[GitHub Handler] Could not get BrowserWindow instance."); + return; + } + + currentFlowState = { + deviceCode: "", + interval: 5, // Default interval + timeoutId: null, + isPolling: false, + window: window, + }; + + event.sender.send("github:flow-update", { + message: "Requesting device code from GitHub...", + }); + + fetch(GITHUB_DEVICE_CODE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + scope: GITHUB_SCOPES, + }), + }) + .then((res) => { + if (!res.ok) { + return res.json().then((errData) => { + throw new Error( + `GitHub API Error: ${errData.error_description || res.statusText}` + ); + }); + } + return res.json(); + }) + .then((data) => { + console.log("[GitHub Handler] Received device code response:", data); + if (!currentFlowState) return; // Flow might have been cancelled + + currentFlowState.deviceCode = data.device_code; + currentFlowState.interval = data.interval || 5; + currentFlowState.isPolling = true; + + // Send user code and verification URI to renderer + event.sender.send("github:flow-update", { + userCode: data.user_code, + verificationUri: data.verification_uri, + message: "Please authorize in your browser.", + }); + + // Start polling after the initial interval + currentFlowState.timeoutId = setTimeout( + () => pollForAccessToken(event), + currentFlowState.interval * 1000 + ); + }) + .catch((error) => { + console.error( + "[GitHub Handler] Error initiating GitHub device flow:", + error + ); + event.sender.send("github:flow-error", { + error: `Failed to start GitHub connection: ${error.message}`, + }); + stopPolling(); // Ensure polling stops on initial error + currentFlowState = null; // Clear state on initial error + }); +} + +// Optional: Handle cancellation from renderer +// function handleCancelGithubFlow(event: IpcMainEvent) { +// console.log('[GitHub Handler] Received github:cancel-flow'); +// stopPolling(); +// currentFlowState = null; // Clear state on cancel +// // Optionally send confirmation back +// event.sender.send('github:flow-cancelled', { message: 'GitHub flow cancelled.' }); +// } + +// --- Registration --- +export function registerGithubHandlers() { + ipcMain.handle("github:start-flow", handleStartGithubFlow); + // ipcMain.on('github:cancel-flow', handleCancelGithubFlow); // Uncomment if you add cancellation +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 5187b39..795c547 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -29,6 +29,20 @@ export interface AppStreamCallbacks { onOutput: (output: AppOutput) => void; } +export interface GitHubDeviceFlowUpdateData { + userCode?: string; + verificationUri?: string; + message?: string; +} + +export interface GitHubDeviceFlowSuccessData { + message?: string; +} + +export interface GitHubDeviceFlowErrorData { + error: string; +} + export class IpcClient { private static instance: IpcClient; private ipcRenderer: IpcRenderer; @@ -505,4 +519,58 @@ export class IpcClient { throw error; } } + + // --- GitHub Device Flow --- + public startGithubDeviceFlow(appId: number | null): void { + this.ipcRenderer.invoke("github:start-flow", { appId }); + } + + public onGithubDeviceFlowUpdate( + callback: (data: GitHubDeviceFlowUpdateData) => void + ): () => void { + const listener = (data: any) => { + console.log("github:flow-update", data); + callback(data as GitHubDeviceFlowUpdateData); + }; + this.ipcRenderer.on("github:flow-update", listener); + // Return a function to remove the listener + return () => { + this.ipcRenderer.removeListener("github:flow-update", listener); + }; + } + + public onGithubDeviceFlowSuccess( + callback: (data: GitHubDeviceFlowSuccessData) => void + ): () => void { + const listener = (data: any) => { + console.log("github:flow-success", data); + callback(data as GitHubDeviceFlowSuccessData); + }; + this.ipcRenderer.on("github:flow-success", listener); + return () => { + this.ipcRenderer.removeListener("github:flow-success", listener); + }; + } + + public onGithubDeviceFlowError( + callback: (data: GitHubDeviceFlowErrorData) => void + ): () => void { + const listener = (data: any) => { + console.log("github:flow-error", data); + callback(data as GitHubDeviceFlowErrorData); + }; + this.ipcRenderer.on("github:flow-error", listener); + return () => { + this.ipcRenderer.removeListener("github:flow-error", listener); + }; + } + + // TODO: Implement cancel method if needed + // public cancelGithubDeviceFlow(): void { + // this.ipcRenderer.sendMessage("github:cancel-flow"); + // } + // --- End GitHub Device Flow --- + + // Example methods for listening to events (if needed) + // public on(channel: string, func: (...args: any[]) => void): void { } diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index b34a1f6..a56157f 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -4,6 +4,7 @@ import { registerChatStreamHandlers } from "./handlers/chat_stream_handlers"; import { registerSettingsHandlers } from "./handlers/settings_handlers"; import { registerShellHandlers } from "./handlers/shell_handler"; import { registerDependencyHandlers } from "./handlers/dependency_handlers"; +import { registerGithubHandlers } from "./handlers/github_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -13,4 +14,5 @@ export function registerIpcHandlers() { registerSettingsHandlers(); registerShellHandlers(); registerDependencyHandlers(); + registerGithubHandlers(); } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 2c98310..a5c1c7e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -64,6 +64,16 @@ export type ProviderSetting = z.infer; export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]); export type RuntimeMode = z.infer; +export const GitHubSecretsSchema = z.object({ + accessToken: z.string().nullable(), +}); +export type GitHubSecrets = z.infer; + +export const GitHubSettingsSchema = z.object({ + secrets: GitHubSecretsSchema.nullable(), +}); +export type GitHubSettings = z.infer; + /** * Zod schema for user settings */ @@ -71,6 +81,7 @@ export const UserSettingsSchema = z.object({ selectedModel: LargeLanguageModelSchema, providerSettings: z.record(z.string(), ProviderSettingSchema), runtimeMode: RuntimeModeSchema, + githubSettings: GitHubSettingsSchema, }); /** diff --git a/src/main/settings.ts b/src/main/settings.ts index d798925..6005270 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { getUserDataPath } from "../paths/paths"; import { UserSettingsSchema, type UserSettings } from "../lib/schemas"; - +import { safeStorage } from "electron"; const DEFAULT_SETTINGS: UserSettings = { selectedModel: { name: "auto", @@ -10,6 +10,9 @@ const DEFAULT_SETTINGS: UserSettings = { }, providerSettings: {}, runtimeMode: "unset", + githubSettings: { + secrets: null, + }, }; const SETTINGS_FILE = "user-settings.json"; @@ -31,6 +34,13 @@ export function readSettings(): UserSettings { ...DEFAULT_SETTINGS, ...rawSettings, }); + if (validatedSettings.githubSettings?.secrets) { + const accessToken = validatedSettings.githubSettings.secrets.accessToken; + + validatedSettings.githubSettings.secrets = { + accessToken: accessToken ? decrypt(accessToken) : null, + }; + } return validatedSettings; } catch (error) { console.error("Error reading settings:", error); @@ -45,8 +55,28 @@ export function writeSettings(settings: Partial): void { const newSettings = { ...currentSettings, ...settings }; // Validate before writing const validatedSettings = UserSettingsSchema.parse(newSettings); + if (validatedSettings.githubSettings?.secrets) { + const accessToken = validatedSettings.githubSettings.secrets.accessToken; + validatedSettings.githubSettings.secrets = { + accessToken: accessToken ? encrypt(accessToken) : null, + }; + } fs.writeFileSync(filePath, JSON.stringify(validatedSettings, null, 2)); } catch (error) { console.error("Error writing settings:", error); } } + +export function encrypt(data: string): string { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(data).toString("base64"); + } + return data; +} + +export function decrypt(data: string): string { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.decryptString(Buffer.from(data, "base64")); + } + return data; +} diff --git a/src/pages/app-details.tsx b/src/pages/app-details.tsx index 688ccf4..d507d4c 100644 --- a/src/pages/app-details.tsx +++ b/src/pages/app-details.tsx @@ -3,7 +3,7 @@ import { useAtom, useAtomValue } from "jotai"; import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms"; import { IpcClient } from "@/ipc/ipc_client"; import { useLoadApps } from "@/hooks/useLoadApps"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft, @@ -11,6 +11,7 @@ import { ArrowRight, MessageCircle, Pencil, + Github, } from "lucide-react"; import { Popover, @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { GitHubConnector } from "@/components/GitHubConnector"; export default function AppDetailsPage() { const navigate = useNavigate(); @@ -225,7 +227,7 @@ export default function AppDetailsPage() { -
+
+
{/* Rename Dialog */} diff --git a/src/preload.ts b/src/preload.ts index 6f2151a..ac096dc 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -32,6 +32,7 @@ const validInvokeChannels = [ "open-external-url", "reset-all", "nodejs-status", + "github:start-flow", ] as const; // Add valid receive channels @@ -40,6 +41,9 @@ const validReceiveChannels = [ "chat:response:end", "chat:response:error", "app:output", + "github:flow-update", + "github:flow-success", + "github:flow-error", ] as const; type ValidInvokeChannel = (typeof validInvokeChannels)[number]; @@ -76,5 +80,13 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.removeAllListeners(channel); } }, + removeListener: ( + channel: ValidReceiveChannel, + listener: (...args: unknown[]) => void + ) => { + if (validReceiveChannels.includes(channel)) { + ipcRenderer.removeListener(channel, listener); + } + }, }, });