Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
432 changes: 432 additions & 0 deletions desktop/src/main/codexAuth.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ConnectorAuthPayload,
type ConnectorName,
} from "./connectors";
import { registerCodexAuthIPCHandlers, stopCodexAuthServer } from "./codexAuth";
import {
checkOctopalUpdateSafely,
ensureWorkspaceBootstrap,
Expand Down Expand Up @@ -984,6 +985,7 @@ ipcMain.handle(
ipcMain.handle("desktop:start-whatsapp-link", async (_event, installDir: string) => startWhatsAppLink(installDir));
ipcMain.handle("desktop:get-whatsapp-link-status", async (_event, installDir: string) => getWhatsAppLinkStatus(installDir));
ipcMain.handle("desktop:stop-whatsapp-link", async (_event, installDir: string) => stopWhatsAppLink(installDir));
registerCodexAuthIPCHandlers();

void app.whenReady().then(async () => {
nativeTheme.themeSource = (await readSettings()).theme;
Expand All @@ -998,6 +1000,7 @@ void app.whenReady().then(async () => {
});

app.on("window-all-closed", () => {
stopCodexAuthServer();
if (process.platform !== "darwin") {
app.quit();
}
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,33 @@ type DesktopConnectorActionResult = {
detail: string;
};

type DesktopCodexAuthStatus = {
available: boolean;
connected: boolean;
accountLabel?: string;
accountType?: string;
requiresOpenAIAuth?: boolean;
error?: string;
};

type DesktopCodexAuthStartResult = {
success: boolean;
authUrl?: string;
loginId?: string;
error?: string;
};

type DesktopCodexModelListResult = {
success: boolean;
models?: Array<{
id: string;
model: string;
displayName: string;
hidden?: boolean;
}>;
error?: string;
};

type DesktopDashboardSnapshot = {
ok: boolean;
detail: string;
Expand Down Expand Up @@ -264,6 +291,10 @@ contextBridge.exposeInMainWorld("octopalDesktop", {
ipcRenderer.invoke("desktop:authorize-connector", installDir, payload) as Promise<DesktopConnectorActionResult>,
disconnectConnector: (installDir: string, name: DesktopConnectorName, forgetCredentials = false) =>
ipcRenderer.invoke("desktop:disconnect-connector", installDir, name, forgetCredentials) as Promise<DesktopConnectorActionResult>,
getCodexAuthStatus: () => ipcRenderer.invoke("codex-auth:status") as Promise<DesktopCodexAuthStatus>,
startCodexAuth: () => ipcRenderer.invoke("codex-auth:start-login") as Promise<DesktopCodexAuthStartResult>,
disconnectCodexAuth: () => ipcRenderer.invoke("codex-auth:disconnect") as Promise<{ success: boolean; error?: string }>,
listCodexModels: () => ipcRenderer.invoke("codex-models:list") as Promise<DesktopCodexModelListResult>,
startWhatsAppLink: (installDir: string) =>
ipcRenderer.invoke("desktop:start-whatsapp-link", installDir) as Promise<DesktopWhatsAppLinkStatus>,
getWhatsAppLinkStatus: (installDir: string) =>
Expand All @@ -280,4 +311,18 @@ contextBridge.exposeInMainWorld("octopalDesktop", {
ipcRenderer.on("desktop:app-update-status", handler);
return () => ipcRenderer.removeListener("desktop:app-update-status", handler);
},
onCodexAuthStatus: (callback: (status: DesktopCodexAuthStatus) => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: DesktopCodexAuthStatus) => callback(status);
ipcRenderer.on("codex-auth-status-changed", handler);
return () => ipcRenderer.removeListener("codex-auth-status-changed", handler);
},
onCodexAuthUpdated: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("codex-auth-login-completed", handler);
ipcRenderer.on("codex-auth-updated", handler);
return () => {
ipcRenderer.removeListener("codex-auth-login-completed", handler);
ipcRenderer.removeListener("codex-auth-updated", handler);
};
},
});
103 changes: 103 additions & 0 deletions desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function App() {
const [connectorMessage, setConnectorMessage] = useState("");
const [connectorMessageTone, setConnectorMessageTone] = useState<"success" | "error" | "info">("info");
const [selectedConnector, setSelectedConnector] = useState<DesktopConnectorName>("google");
const [codexAuthStatus, setCodexAuthStatus] = useState<DesktopCodexAuthStatus | null>(null);
const [codexAuthBusy, setCodexAuthBusy] = useState(false);
const [configurationMode, setConfigurationMode] = useState<"install" | "edit">("install");
const [loadedConfigChannel, setLoadedConfigChannel] = useState<InstallForm["channel"] | null>(null);
const [installState, setInstallState] = useState<DesktopInstallState>({
Expand Down Expand Up @@ -276,6 +278,17 @@ export function App() {
setConnectorStatus(result);
}, [installState.installed, runtimeInstallDir]);

const refreshCodexAuthStatus = useCallback(async () => {
if (!window.octopalDesktop) {
setCodexAuthStatus(null);
return null;
}

const result = await window.octopalDesktop.getCodexAuthStatus();
setCodexAuthStatus(result);
return result;
}, []);

useEffect(() => {
void loadSettings().then(async (settings) => {
setLanguage(settings.language);
Expand Down Expand Up @@ -379,6 +392,32 @@ export function App() {
void refreshConnectorStatus();
}, [refreshConnectorStatus, screen, settingsLoaded, step]);

useEffect(() => {
if (!settingsLoaded || screen !== "wizard" || (values.providerId !== "codex" && values.workerProviderId !== "codex")) {
return;
}

void refreshCodexAuthStatus();
}, [refreshCodexAuthStatus, screen, settingsLoaded, values.providerId, values.workerProviderId]);

useEffect(() => {
if (!settingsLoaded || !window.octopalDesktop) {
return;
}

const unsubscribeStatus = window.octopalDesktop.onCodexAuthStatus((status) => {
setCodexAuthStatus(status);
setCodexAuthBusy(false);
});
const unsubscribeUpdated = window.octopalDesktop.onCodexAuthUpdated(() => {
void refreshCodexAuthStatus().finally(() => setCodexAuthBusy(false));
});
return () => {
unsubscribeStatus();
unsubscribeUpdated();
};
}, [refreshCodexAuthStatus, settingsLoaded]);

useEffect(() => {
if (!settingsLoaded || screen !== "wizard" || step !== "review") {
return;
Expand Down Expand Up @@ -595,6 +634,65 @@ export function App() {
}
}

async function authorizeCodex() {
if (!window.octopalDesktop) {
return;
}

setCodexAuthBusy(true);
try {
const result = await window.octopalDesktop.startCodexAuth();
if (!result.success) {
setCodexAuthStatus({
available: true,
connected: false,
error: result.error || copy("codexAuthorizeFailed"),
});
setCodexAuthBusy(false);
return;
}
window.setTimeout(() => {
void refreshCodexAuthStatus().finally(() => setCodexAuthBusy(false));
}, 1500);
} catch (error) {
setCodexAuthStatus({
available: false,
connected: false,
error: error instanceof Error ? error.message : copy("codexAuthorizeFailed"),
});
setCodexAuthBusy(false);
}
}

async function disconnectCodex() {
if (!window.octopalDesktop) {
return;
}

setCodexAuthBusy(true);
try {
const result = await window.octopalDesktop.disconnectCodexAuth();
if (!result.success) {
setCodexAuthStatus({
available: true,
connected: codexAuthStatus?.connected === true,
accountLabel: codexAuthStatus?.accountLabel,
error: result.error || copy("codexDisconnectFailed"),
});
return;
}
await refreshCodexAuthStatus();
} catch (error) {
setCodexAuthStatus({
available: false,
connected: false,
error: error instanceof Error ? error.message : copy("codexDisconnectFailed"),
});
} finally {
setCodexAuthBusy(false);
}
}

async function chooseInstallDir() {
try {
const selected = window.octopalDesktop ? await window.octopalDesktop.chooseInstallDir() : "C:\\Octopal";
Expand Down Expand Up @@ -993,6 +1091,11 @@ export function App() {
connectorMessageTone={connectorMessageTone}
selectedConnector={selectedConnector}
canAuthorizeConnectors={installState.installed && configurationMode === "edit"}
codexAuthStatus={codexAuthStatus}
codexAuthBusy={codexAuthBusy}
onCodexAuthorize={() => void authorizeCodex()}
onCodexRefresh={() => void refreshCodexAuthStatus()}
onCodexDisconnect={() => void disconnectCodex()}
/>
) : null}

Expand Down
63 changes: 63 additions & 0 deletions desktop/src/renderer/src/components/CodexAuthPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { AlertCircle, CheckCircle2, LogIn, LogOut, RefreshCw } from "lucide-react";

import type { CopyFn } from "../lib/appTypes";
import { Button } from "./Button";

export function CodexAuthPanel({
copy,
status,
busy,
onAuthorize,
onRefresh,
onDisconnect,
}: {
copy: CopyFn;
status: DesktopCodexAuthStatus | null;
busy: boolean;
onAuthorize: () => void;
onRefresh: () => void;
onDisconnect: () => void;
}) {
const connected = status?.connected === true;
const unavailable = status?.available === false;
const title = unavailable
? copy("codexUnavailable")
: connected
? status?.accountLabel || copy("codexConnected")
: copy("codexNotConnected");
const detail = status?.error || (unavailable ? copy("codexUnavailableBody") : copy("codexAuthBody"));

const toneClass = connected
? "codex-auth-panel codex-auth-ready"
: unavailable || status?.error
? "codex-auth-panel codex-auth-error"
: "codex-auth-panel";

return (
<div className={toneClass}>
<div className="codex-auth-status">
{connected ? <CheckCircle2 /> : <AlertCircle />}
<div>
<strong>{title}</strong>
<span>{detail}</span>
</div>
</div>
<div className="codex-auth-actions">
<Button type="button" variant={connected ? "secondary" : "primary"} disabled={busy || unavailable} onClick={onAuthorize}>
<LogIn data-icon="inline-start" />
{connected ? copy("codexReauthorize") : copy("codexAuthorize")}
</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={onRefresh}>
<RefreshCw data-icon="inline-start" />
{copy("refresh")}
</Button>
{connected ? (
<Button type="button" variant="ghost" disabled={busy} onClick={onDisconnect}>
<LogOut data-icon="inline-start" />
{copy("codexDisconnect")}
</Button>
) : null}
</div>
</div>
);
}
20 changes: 14 additions & 6 deletions desktop/src/renderer/src/components/LlmForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export function LlmForm({
apiBaseLabel,
apiKeyHint,
apiBaseHint,
showApiKey = true,
showApiBase = true,
modelInvalid,
apiKeyInvalid,
apiBaseInvalid,
Expand All @@ -21,6 +23,8 @@ export function LlmForm({
apiBaseLabel: string;
apiKeyHint: string;
apiBaseHint: string;
showApiKey?: boolean;
showApiBase?: boolean;
modelInvalid?: boolean;
apiKeyInvalid?: boolean;
apiBaseInvalid?: boolean;
Expand All @@ -33,12 +37,16 @@ export function LlmForm({
<Field label={modelLabel} invalid={modelInvalid}>
<Input {...modelRegistration} />
</Field>
<Field label={apiKeyLabel} hint={apiKeyHint} invalid={apiKeyInvalid}>
<Input {...apiKeyRegistration} type="password" />
</Field>
<Field label={apiBaseLabel} hint={apiBaseHint} invalid={apiBaseInvalid}>
<Input {...apiBaseRegistration} placeholder="https://api.example.com/v1" />
</Field>
{showApiKey ? (
<Field label={apiKeyLabel} hint={apiKeyHint} invalid={apiKeyInvalid}>
<Input {...apiKeyRegistration} type="password" />
</Field>
) : null}
{showApiBase ? (
<Field label={apiBaseLabel} hint={apiBaseHint} invalid={apiBaseInvalid}>
<Input {...apiBaseRegistration} placeholder="https://api.example.com/v1" />
</Field>
) : null}
</motion.div>
);
}
20 changes: 20 additions & 0 deletions desktop/src/renderer/src/components/WizardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export function WizardScreen({
connectorMessageTone,
selectedConnector,
canAuthorizeConnectors,
codexAuthStatus,
codexAuthBusy,
onCodexAuthorize,
onCodexRefresh,
onCodexDisconnect,
}: {
copy: CopyFn;
language: Language;
Expand Down Expand Up @@ -85,6 +90,11 @@ export function WizardScreen({
connectorMessageTone: "success" | "error" | "info";
selectedConnector: DesktopConnectorName;
canAuthorizeConnectors: boolean;
codexAuthStatus: DesktopCodexAuthStatus | null;
codexAuthBusy: boolean;
onCodexAuthorize: () => void;
onCodexRefresh: () => void;
onCodexDisconnect: () => void;
}) {
return (
<motion.section
Expand Down Expand Up @@ -116,6 +126,11 @@ export function WizardScreen({
form={form}
errors={errors}
onProviderChange={(providerId) => onProviderChange(providerId, "octo")}
codexAuthStatus={codexAuthStatus}
codexAuthBusy={codexAuthBusy}
onCodexAuthorize={onCodexAuthorize}
onCodexRefresh={onCodexRefresh}
onCodexDisconnect={onCodexDisconnect}
/>
) : null}
{step === "worker-llm" ? (
Expand All @@ -125,6 +140,11 @@ export function WizardScreen({
form={form}
errors={errors}
onProviderChange={(providerId) => onProviderChange(providerId, "worker")}
codexAuthStatus={codexAuthStatus}
codexAuthBusy={codexAuthBusy}
onCodexAuthorize={onCodexAuthorize}
onCodexRefresh={onCodexRefresh}
onCodexDisconnect={onCodexDisconnect}
/>
) : null}
{step === "search" ? (
Expand Down
Loading