diff --git a/src/codex-auth.ts b/src/codex-auth.ts index 23f7077..e0ca007 100644 --- a/src/codex-auth.ts +++ b/src/codex-auth.ts @@ -1,5 +1,5 @@ import * as http from "http"; -import { Notice, Platform, requestUrl } from "obsidian"; +import { App, Notice, Platform, requestUrl } from "obsidian"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; @@ -91,6 +91,32 @@ function randomState(): string { return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); } +export async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to legacy fallback + } + + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + return copied; + } catch { + return false; + } +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -206,7 +232,7 @@ function isPortInUse(port: number): Promise { }); } -async function startCodexDeviceAuthFlow(): Promise { +async function startCodexDeviceAuthFlow(app: App): Promise { const userCodeRes = await requestUrl({ url: DEVICE_USERCODE_URL, method: "POST", @@ -242,64 +268,76 @@ async function startCodexDeviceAuthFlow(): Promise { return null; } - openExternalUrl(DEVICE_VERIFICATION_URL); - new Notice( - `🔐 Codex: enter code ${userCode} in your browser, then return here`, - 15000, + let cancelled = false; + const { CodexDeviceAuthModal } = await import("./codex-device-auth-modal"); + const modal = new CodexDeviceAuthModal( + app, + userCode, + DEVICE_VERIFICATION_URL, + () => { + cancelled = true; + }, ); + modal.open(); - const startedAt = Date.now(); - const pollIntervalMs = intervalSec * 1000 + DEVICE_POLL_SAFETY_MARGIN_MS; + try { + const startedAt = Date.now(); + const pollIntervalMs = intervalSec * 1000 + DEVICE_POLL_SAFETY_MARGIN_MS; - while (Date.now() - startedAt < DEVICE_AUTH_TIMEOUT_MS) { - const pollRes = await requestUrl({ - url: DEVICE_TOKEN_URL, - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - device_auth_id: deviceAuthId, - user_code: userCode, - }), - throw: false, - }); + while (!cancelled && Date.now() - startedAt < DEVICE_AUTH_TIMEOUT_MS) { + const pollRes = await requestUrl({ + url: DEVICE_TOKEN_URL, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: deviceAuthId, + user_code: userCode, + }), + throw: false, + }); - if (pollRes.status >= 200 && pollRes.status < 300) { - const pollJson = pollRes.json as Record; - const authCode = - typeof pollJson.authorization_code === "string" - ? pollJson.authorization_code - : null; - const codeVerifier = - typeof pollJson.code_verifier === "string" - ? pollJson.code_verifier - : null; + if (pollRes.status >= 200 && pollRes.status < 300) { + const pollJson = pollRes.json as Record; + const authCode = + typeof pollJson.authorization_code === "string" + ? pollJson.authorization_code + : null; + const codeVerifier = + typeof pollJson.code_verifier === "string" + ? pollJson.code_verifier + : null; - if (!authCode || !codeVerifier) { - new Notice("❌ Codex: invalid device sign-in response"); + if (!authCode || !codeVerifier) { + new Notice("❌ Codex: invalid device sign-in response"); + return null; + } + + const tokens = await exchangeAuthorizationCode( + authCode, + codeVerifier, + DEVICE_REDIRECT_URI, + ); + if (!tokens) { + new Notice("❌ Codex: failed to exchange auth code for tokens"); + } + return tokens; + } + + if (pollRes.status !== 403 && pollRes.status !== 404) { + new Notice("❌ Codex: device sign-in failed"); return null; } - const tokens = await exchangeAuthorizationCode( - authCode, - codeVerifier, - DEVICE_REDIRECT_URI, - ); - if (!tokens) { - new Notice("❌ Codex: failed to exchange auth code for tokens"); - } - return tokens; + await sleep(pollIntervalMs); } - if (pollRes.status !== 403 && pollRes.status !== 404) { - new Notice("❌ Codex: device sign-in failed"); - return null; + if (!cancelled) { + new Notice("⚠️ Codex: sign-in timed out"); } - - await sleep(pollIntervalMs); + return null; + } finally { + modal.closeByApp(); } - - new Notice("⚠️ Codex: sign-in timed out"); - return null; } async function startCodexDesktopOAuthFlow(): Promise { @@ -399,9 +437,9 @@ async function startCodexDesktopOAuthFlow(): Promise { }); } -export async function startCodexOAuthFlow(): Promise { +export async function startCodexOAuthFlow(app: App): Promise { if (Platform.isMobileApp) { - return startCodexDeviceAuthFlow(); + return startCodexDeviceAuthFlow(app); } return startCodexDesktopOAuthFlow(); } diff --git a/src/codex-device-auth-modal.ts b/src/codex-device-auth-modal.ts new file mode 100644 index 0000000..9b1b1f7 --- /dev/null +++ b/src/codex-device-auth-modal.ts @@ -0,0 +1,66 @@ +import { App, Modal, Notice } from "obsidian"; +import { copyToClipboard, openExternalUrl } from "./codex-auth"; + +export class CodexDeviceAuthModal extends Modal { + private closedByApp = false; + + constructor( + app: App, + private userCode: string, + private verificationUrl: string, + private onCancel: () => void, + ) { + super(app); + } + + closeByApp(): void { + this.closedByApp = true; + this.close(); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.titleEl.setText("Sign in with ChatGPT"); + + contentEl.createEl("p", { + text: "Enter this one-time code on the ChatGPT sign-in page:", + }); + + contentEl.createEl("div", { + cls: "inlineai-codex-device-code", + text: this.userCode, + }); + + const buttonRow = contentEl.createDiv({ cls: "inlineai-codex-device-buttons" }); + + buttonRow.createEl("button", { text: "Copy code" }).addEventListener( + "click", + async () => { + const copied = await copyToClipboard(this.userCode); + new Notice( + copied ? "✅ Code copied to clipboard" : "❌ Could not copy code", + ); + }, + ); + + buttonRow + .createEl("button", { text: "Open sign-in page", cls: "mod-cta" }) + .addEventListener("click", () => { + openExternalUrl(this.verificationUrl); + }); + + contentEl.createEl("p", { + cls: "setting-item-description", + text: "Complete sign-in in your browser, then return here. This dialog closes automatically when sign-in succeeds.", + }); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + if (!this.closedByApp) { + this.onCancel(); + } + } +} diff --git a/src/settings.ts b/src/settings.ts index 298dd55..711653b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -143,10 +143,10 @@ export class InlineAISettingsTab extends PluginSettingTab { } new Notice( Platform.isMobileApp - ? "Starting device sign-in — your browser will open shortly…" + ? "Complete sign-in using the dialog that opens" : "Opening browser for ChatGPT sign-in…", ); - const tokens = await startCodexOAuthFlow(); + const tokens = await startCodexOAuthFlow(this.app); if (tokens) { setCodexTokens(this.app, tokens); new Notice( diff --git a/styles.css b/styles.css index 9671914..89b997a 100644 --- a/styles.css +++ b/styles.css @@ -156,3 +156,24 @@ .cm-slash-command { color: var(--text-accent); } + +/* Codex device sign-in modal */ +.inlineai-codex-device-code { + font-family: var(--font-monospace); + font-size: 1.5em; + font-weight: 600; + letter-spacing: 0.08em; + text-align: center; + padding: 1em; + margin: 1em 0; + background: var(--background-secondary); + border-radius: 8px; + user-select: all; +} + +.inlineai-codex-device-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.75em; + margin-top: 1em; +}