Basic GitHub integration flow
This commit is contained in:
197
src/components/GitHubConnector.tsx
Normal file
197
src/components/GitHubConnector.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [githubVerificationUri, setGithubVerificationUri] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [githubError, setGithubError] = useState<string | null>(null);
|
||||||
|
const [isConnectingToGithub, setIsConnectingToGithub] = useState(false);
|
||||||
|
const [githubStatusMessage, setGithubStatusMessage] = useState<string | null>(
|
||||||
|
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 (
|
||||||
|
<div className="mt-4 w-full">
|
||||||
|
<p>Connected to GitHub!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 w-full">
|
||||||
|
{" "}
|
||||||
|
<Button
|
||||||
|
onClick={handleConnectToGithub}
|
||||||
|
className="cursor-pointer w-full py-6 flex justify-center items-center gap-2 text-lg"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isConnectingToGithub || !appId} // Also disable if appId is null
|
||||||
|
>
|
||||||
|
Connect to GitHub
|
||||||
|
<Github className="h-5 w-5" />
|
||||||
|
{isConnectingToGithub && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-5 w-5 ml-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{/* GitHub Connection Status/Instructions */}
|
||||||
|
{(githubUserCode || githubStatusMessage || githubError) && (
|
||||||
|
<div className="mt-6 p-4 border rounded-md bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600">
|
||||||
|
<h4 className="font-medium mb-2">GitHub Connection</h4>
|
||||||
|
{githubError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 mb-2">
|
||||||
|
Error: {githubError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{githubUserCode && githubVerificationUri && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p>
|
||||||
|
1. Go to:
|
||||||
|
<a
|
||||||
|
href={githubVerificationUri} // Make it a direct link
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
IpcClient.getInstance().openExternalUrl(
|
||||||
|
githubVerificationUri
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-1 text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{githubVerificationUri}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
2. Enter code:
|
||||||
|
<strong className="ml-1 font-mono text-lg tracking-wider bg-gray-200 dark:bg-gray-600 px-2 py-0.5 rounded">
|
||||||
|
{githubUserCode}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{githubStatusMessage && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{githubStatusMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms";
|
import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms";
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
@@ -18,31 +18,30 @@ export function useSettings() {
|
|||||||
const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom);
|
const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(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(() => {
|
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
|
// Only run once on mount, dependencies are stable getters/setters
|
||||||
}, [setSettingsAtom, setEnvVarsAtom]);
|
loadInitialData();
|
||||||
|
}, [loadInitialData]);
|
||||||
|
|
||||||
const updateSettings = async (newSettings: Partial<UserSettings>) => {
|
const updateSettings = async (newSettings: Partial<UserSettings>) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -84,5 +83,8 @@ export function useSettings() {
|
|||||||
isProviderSetup(provider)
|
isProviderSetup(provider)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
refreshSettings: () => {
|
||||||
|
loadInitialData();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
282
src/ipc/handlers/github_handlers.ts
Normal file
282
src/ipc/handlers/github_handlers.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@ export interface AppStreamCallbacks {
|
|||||||
onOutput: (output: AppOutput) => void;
|
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 {
|
export class IpcClient {
|
||||||
private static instance: IpcClient;
|
private static instance: IpcClient;
|
||||||
private ipcRenderer: IpcRenderer;
|
private ipcRenderer: IpcRenderer;
|
||||||
@@ -505,4 +519,58 @@ export class IpcClient {
|
|||||||
throw error;
|
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 {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { registerChatStreamHandlers } from "./handlers/chat_stream_handlers";
|
|||||||
import { registerSettingsHandlers } from "./handlers/settings_handlers";
|
import { registerSettingsHandlers } from "./handlers/settings_handlers";
|
||||||
import { registerShellHandlers } from "./handlers/shell_handler";
|
import { registerShellHandlers } from "./handlers/shell_handler";
|
||||||
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
|
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
|
||||||
|
import { registerGithubHandlers } from "./handlers/github_handlers";
|
||||||
|
|
||||||
export function registerIpcHandlers() {
|
export function registerIpcHandlers() {
|
||||||
// Register all IPC handlers by category
|
// Register all IPC handlers by category
|
||||||
@@ -13,4 +14,5 @@ export function registerIpcHandlers() {
|
|||||||
registerSettingsHandlers();
|
registerSettingsHandlers();
|
||||||
registerShellHandlers();
|
registerShellHandlers();
|
||||||
registerDependencyHandlers();
|
registerDependencyHandlers();
|
||||||
|
registerGithubHandlers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export type ProviderSetting = z.infer<typeof ProviderSettingSchema>;
|
|||||||
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
|
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
|
||||||
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
|
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
|
||||||
|
|
||||||
|
export const GitHubSecretsSchema = z.object({
|
||||||
|
accessToken: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type GitHubSecrets = z.infer<typeof GitHubSecretsSchema>;
|
||||||
|
|
||||||
|
export const GitHubSettingsSchema = z.object({
|
||||||
|
secrets: GitHubSecretsSchema.nullable(),
|
||||||
|
});
|
||||||
|
export type GitHubSettings = z.infer<typeof GitHubSettingsSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod schema for user settings
|
* Zod schema for user settings
|
||||||
*/
|
*/
|
||||||
@@ -71,6 +81,7 @@ export const UserSettingsSchema = z.object({
|
|||||||
selectedModel: LargeLanguageModelSchema,
|
selectedModel: LargeLanguageModelSchema,
|
||||||
providerSettings: z.record(z.string(), ProviderSettingSchema),
|
providerSettings: z.record(z.string(), ProviderSettingSchema),
|
||||||
runtimeMode: RuntimeModeSchema,
|
runtimeMode: RuntimeModeSchema,
|
||||||
|
githubSettings: GitHubSettingsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getUserDataPath } from "../paths/paths";
|
import { getUserDataPath } from "../paths/paths";
|
||||||
import { UserSettingsSchema, type UserSettings } from "../lib/schemas";
|
import { UserSettingsSchema, type UserSettings } from "../lib/schemas";
|
||||||
|
import { safeStorage } from "electron";
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
selectedModel: {
|
selectedModel: {
|
||||||
name: "auto",
|
name: "auto",
|
||||||
@@ -10,6 +10,9 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
},
|
},
|
||||||
providerSettings: {},
|
providerSettings: {},
|
||||||
runtimeMode: "unset",
|
runtimeMode: "unset",
|
||||||
|
githubSettings: {
|
||||||
|
secrets: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SETTINGS_FILE = "user-settings.json";
|
const SETTINGS_FILE = "user-settings.json";
|
||||||
@@ -31,6 +34,13 @@ export function readSettings(): UserSettings {
|
|||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...rawSettings,
|
...rawSettings,
|
||||||
});
|
});
|
||||||
|
if (validatedSettings.githubSettings?.secrets) {
|
||||||
|
const accessToken = validatedSettings.githubSettings.secrets.accessToken;
|
||||||
|
|
||||||
|
validatedSettings.githubSettings.secrets = {
|
||||||
|
accessToken: accessToken ? decrypt(accessToken) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
return validatedSettings;
|
return validatedSettings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading settings:", error);
|
console.error("Error reading settings:", error);
|
||||||
@@ -45,8 +55,28 @@ export function writeSettings(settings: Partial<UserSettings>): void {
|
|||||||
const newSettings = { ...currentSettings, ...settings };
|
const newSettings = { ...currentSettings, ...settings };
|
||||||
// Validate before writing
|
// Validate before writing
|
||||||
const validatedSettings = UserSettingsSchema.parse(newSettings);
|
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));
|
fs.writeFileSync(filePath, JSON.stringify(validatedSettings, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error writing settings:", 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useAtom, useAtomValue } from "jotai";
|
|||||||
import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms";
|
import { appBasePathAtom, appsListAtom } from "@/atoms/appAtoms";
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Github,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { GitHubConnector } from "@/components/GitHubConnector";
|
||||||
|
|
||||||
export default function AppDetailsPage() {
|
export default function AppDetailsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -225,7 +227,7 @@ export default function AppDetailsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex gap-4">
|
<div className="mt-8 flex flex-col gap-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
appId && navigate({ to: "/chat", search: { id: appId } })
|
appId && navigate({ to: "/chat", search: { id: appId } })
|
||||||
@@ -236,6 +238,7 @@ export default function AppDetailsPage() {
|
|||||||
Open in Chat
|
Open in Chat
|
||||||
<MessageCircle className="h-5 w-5" />
|
<MessageCircle className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<GitHubConnector appId={appId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rename Dialog */}
|
{/* Rename Dialog */}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const validInvokeChannels = [
|
|||||||
"open-external-url",
|
"open-external-url",
|
||||||
"reset-all",
|
"reset-all",
|
||||||
"nodejs-status",
|
"nodejs-status",
|
||||||
|
"github:start-flow",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Add valid receive channels
|
// Add valid receive channels
|
||||||
@@ -40,6 +41,9 @@ const validReceiveChannels = [
|
|||||||
"chat:response:end",
|
"chat:response:end",
|
||||||
"chat:response:error",
|
"chat:response:error",
|
||||||
"app:output",
|
"app:output",
|
||||||
|
"github:flow-update",
|
||||||
|
"github:flow-success",
|
||||||
|
"github:flow-error",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ValidInvokeChannel = (typeof validInvokeChannels)[number];
|
type ValidInvokeChannel = (typeof validInvokeChannels)[number];
|
||||||
@@ -76,5 +80,13 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.removeAllListeners(channel);
|
ipcRenderer.removeAllListeners(channel);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
removeListener: (
|
||||||
|
channel: ValidReceiveChannel,
|
||||||
|
listener: (...args: unknown[]) => void
|
||||||
|
) => {
|
||||||
|
if (validReceiveChannels.includes(channel)) {
|
||||||
|
ipcRenderer.removeListener(channel, listener);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user