mirror of
https://github.com/fbarrca/obsidian-inlineAI.git
synced 2026-07-22 05:34:48 +00:00
feat: Codex device sign-in modal with copy and open page buttons
On mobile, show a modal with the one-time code, copy-to-clipboard, and open sign-in page actions instead of auto-opening the browser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d900ef1ca6
commit
86601be0f0
4 changed files with 177 additions and 52 deletions
|
|
@ -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<boolean> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -206,7 +232,7 @@ function isPortInUse(port: number): Promise<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
async function startCodexDeviceAuthFlow(): Promise<CodexTokens | null> {
|
||||
async function startCodexDeviceAuthFlow(app: App): Promise<CodexTokens | null> {
|
||||
const userCodeRes = await requestUrl({
|
||||
url: DEVICE_USERCODE_URL,
|
||||
method: "POST",
|
||||
|
|
@ -242,64 +268,76 @@ async function startCodexDeviceAuthFlow(): Promise<CodexTokens | null> {
|
|||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<CodexTokens | null> {
|
||||
|
|
@ -399,9 +437,9 @@ async function startCodexDesktopOAuthFlow(): Promise<CodexTokens | null> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function startCodexOAuthFlow(): Promise<CodexTokens | null> {
|
||||
export async function startCodexOAuthFlow(app: App): Promise<CodexTokens | null> {
|
||||
if (Platform.isMobileApp) {
|
||||
return startCodexDeviceAuthFlow();
|
||||
return startCodexDeviceAuthFlow(app);
|
||||
}
|
||||
return startCodexDesktopOAuthFlow();
|
||||
}
|
||||
|
|
|
|||
66
src/codex-device-auth-modal.ts
Normal file
66
src/codex-device-auth-modal.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
21
styles.css
21
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue