diff --git a/scripts/fixtures/oauth-ipc.mjs b/scripts/fixtures/oauth-ipc.mjs
new file mode 100644
index 0000000..d824360
--- /dev/null
+++ b/scripts/fixtures/oauth-ipc.mjs
@@ -0,0 +1,28 @@
+// Loaded only by oauth-link-smoke.mjs; no real account, clipboard or OAuth request.
+const responses = { get_accounts: [], get_current_account_id: null, get_settings: {}, check_codex_login: false };
+const state = window.__compat = { calls: [], denied: [], clipboard: null, copyFailure: false, loginDelay: 0,
+ url: 'https://example.invalid/authorize?response_type=code&state=unchanged&code_challenge=' + 'a'.repeat(1800) + '&extra=%25%26%22%3C' };
+export async function invoke(command, args = {}) {
+ state.calls.push({ command, args });
+ if (command === 'start_oauth_login' || command === 'start_antigravity_oauth_login') {
+ await new Promise(resolve => setTimeout(resolve, state.loginDelay));
+ return state.url;
+ }
+ if (command === 'copy_to_clipboard') {
+ if (state.copyFailure) throw 'Mock clipboard unavailable';
+ state.clipboard = args.text;
+ return null;
+ }
+ if (Object.hasOwn(responses, command)) return structuredClone(responses[command]);
+ state.denied.push(command);
+ throw new Error('MOCK_DENIED: ' + command);
+}
+const handlers = new Map();
+export async function listen(name, callback) {
+ const callbacks = handlers.get(name) ?? new Set();
+ callbacks.add(callback); handlers.set(name, callbacks);
+ return () => callbacks.delete(callback);
+}
+export async function emit(name, payload) { for (const callback of handlers.get(name) ?? []) callback({ payload }); }
+export const open = async () => { throw new Error('Fixture native dialog denied'); };
+export const readFile = async () => { throw new Error('Fixture filesystem access denied'); };
diff --git a/scripts/fixtures/oauth-link.html b/scripts/fixtures/oauth-link.html
new file mode 100644
index 0000000..d30bfd3
--- /dev/null
+++ b/scripts/fixtures/oauth-link.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/scripts/fixtures/oauth-link.tsx b/scripts/fixtures/oauth-link.tsx
new file mode 100644
index 0000000..66befed
--- /dev/null
+++ b/scripts/fixtures/oauth-link.tsx
@@ -0,0 +1,11 @@
+import React, { useState } from 'react';
+import ReactDOM from 'react-dom/client';
+import { AddAccountModal } from '../../src/components/AddAccountModal';
+import '../../src/App.css';
+
+function Fixture() {
+ const [open, setOpen] = useState(true);
+ return <>
+ setOpen(false)} onAdd={async () => {}} />>;
+}
+ReactDOM.createRoot(document.getElementById('root')!).render();
diff --git a/scripts/oauth-link-smoke.mjs b/scripts/oauth-link-smoke.mjs
new file mode 100644
index 0000000..cb1e1eb
--- /dev/null
+++ b/scripts/oauth-link-smoke.mjs
@@ -0,0 +1,144 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { setTimeout as delay } from 'node:timers/promises';
+import { createServer } from 'vite';
+
+const browser = process.env.BROWSER_PATH || [
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/usr/bin/chromium', '/usr/bin/google-chrome',
+].find(file => fs.existsSync(file));
+assert.ok(browser, 'Set BROWSER_PATH to a Chromium-compatible browser');
+const output = path.resolve('.oauth-smoke.local');
+fs.mkdirSync(output, { recursive: true });
+const profile = fs.mkdtempSync(path.join(output, 'profile-'));
+const fixture = path.resolve('scripts/fixtures/oauth-ipc.mjs');
+const server = await createServer({
+ logLevel: 'error', optimizeDeps: { entries: ['scripts/fixtures/oauth-link.html'] },
+ server: { host: '127.0.0.1', port: 0, strictPort: false, open: false, watch: { ignored: ['**/*.local/**'] } },
+ plugins: [{ name: 'oauth-link-fixture', enforce: 'pre', resolveId(id) { return id.startsWith('@tauri-apps/') ? fixture : undefined; } }],
+});
+let child;
+let socket;
+let sequence = 0;
+const pending = new Map();
+const errors = [];
+const scenes = [];
+try {
+ await server.listen();
+ const base = server.resolvedUrls.local[0] + 'scripts/fixtures/oauth-link.html';
+ child = spawn(browser, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
+ '--disable-extensions', '--disable-background-networking', '--remote-debugging-port=0',
+ `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
+ child.on('error', error => errors.push(String(error)));
+ child.stderr.on('data', () => {});
+ const portFile = path.join(profile, 'DevToolsActivePort');
+ for (let i = 0; !fs.existsSync(portFile) && i < 100; i++) await delay(100);
+ assert.ok(fs.existsSync(portFile), 'Isolated browser did not start');
+ const port = fs.readFileSync(portFile, 'utf8').split('\n')[0];
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+ socket = new WebSocket(targets.find(target => target.type === 'page').webSocketDebuggerUrl);
+ await once(socket, 'open');
+ socket.addEventListener('message', event => {
+ const data = JSON.parse(event.data);
+ if (data.id) {
+ const task = pending.get(data.id); pending.delete(data.id);
+ if (data.error) task?.reject(new Error(JSON.stringify(data.error)));
+ else task?.resolve(data.result);
+ } else if (data.method === 'Runtime.exceptionThrown') errors.push(data.params.exceptionDetails.exception?.description ?? data.params.exceptionDetails.text);
+ });
+ function call(method, params = {}) {
+ return new Promise((resolve, reject) => {
+ const id = ++sequence;
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 20000);
+ pending.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
+ socket.send(JSON.stringify({ id, method, params }));
+ });
+ }
+ async function evaluate(expression) {
+ const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
+ return result.result.value;
+ }
+ async function waitFor(expression) {
+ for (let i = 0; i < 100; i++) { if (await evaluate(expression)) return; await delay(100); }
+ throw new Error(`Condition not met: ${expression}`);
+ }
+ async function click(selector) {
+ await evaluate(`(() => {const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing '+${JSON.stringify(selector)});node.click();})()`);
+ }
+ async function capture(name) {
+ await delay(150);
+ const state = await evaluate(`({ denied: window.__compat.denied, text: document.body.innerText,
+ overflow: ['html','.modal-content','.modal-body','.oauth-content','.oauth-status','.oauth-link-panel'].filter(selector=>{
+ const e=document.querySelector(selector);return e && e.clientWidth>0 && e.scrollWidth>e.clientWidth+2;
+ }) })`);
+ assert.deepEqual(state.denied, [], `${name}: unmocked operation`);
+ assert.deepEqual(state.overflow, [], `${name}: horizontal overflow`);
+ const image = await call('Page.captureScreenshot', { format: 'png' });
+ fs.writeFileSync(path.join(output, name + '.png'), Buffer.from(image.data, 'base64'));
+ scenes.push(name);
+ }
+ async function navigate(query = '') {
+ await call('Page.navigate', { url: base + query });
+ await waitFor('!!window.__compat && !!document.querySelector("#root > *")');
+ }
+ async function metrics(width, height, scale = 1) {
+ await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: scale, mobile: false });
+ }
+ async function prepareCopy(failure) {
+ await evaluate(`window.__compat.copyFailure=${failure}`);
+ await click('.oauth-content > button[title]');
+ await waitFor('!!document.querySelector(".oauth-link-value") && !document.querySelector(".oauth-link-panel button").disabled');
+ assert.equal(await evaluate('document.querySelector(".oauth-link-value").value === window.__compat.url'), true);
+ }
+ await call('Runtime.enable'); await call('Page.enable'); await call('Network.enable');
+ await call('Network.setBlockedURLs', { urls: ['https://*', 'http://*.invalid/*', 'http://*.com/*', 'http://*.cn/*'] });
+ await call('Emulation.setUserAgentOverride', { userAgent: 'Mozilla/5.0 Windows compatibility fixture', platform: 'Win32' });
+ await metrics(1200, 760);
+ await navigate();
+ await waitFor('!!document.querySelector(".oauth-content > button[title]")');
+ await prepareCopy(true);
+ await capture('oauth-copy-failure');
+ await evaluate('window.__compat.copyFailure=false');
+ await click('.oauth-link-panel button');
+ await waitFor('window.__compat.clipboard === window.__compat.url');
+ assert.equal(await evaluate('window.__compat.calls.filter(c=>c.command==="start_oauth_login").length'), 1, 'Copy retry must not rotate PKCE/state');
+ await capture('oauth-copy-retry');
+ await click('.oauth-content > button:last-child');
+ await waitFor('!!document.querySelector(".oauth-content textarea.text-input")');
+ assert.equal(await evaluate('document.querySelector(".oauth-content textarea.text-input").value'), '', 'Authorization URL must not become callback input');
+ for (const [width, height, scale] of [[960, 640, 1.25], [800, 600, 1.5], [600, 500, 2]]) {
+ await metrics(width, height, scale);
+ await capture(`oauth-layout-${scale}`);
+ }
+ await metrics(1200, 760);
+ await click('.close-btn'); await waitFor('!document.querySelector(".modal-overlay")');
+ await click('#fixture-open'); await waitFor('!!document.querySelector(".modal-overlay")');
+ await evaluate('window.__compat.loginDelay=600');
+ await click('.oauth-content > button[title]');
+ await click('.close-btn'); await click('#fixture-open'); await delay(900);
+ assert.equal(await evaluate('!!document.querySelector(".oauth-link-value")'), false, 'Closed login attempt updated reopened modal');
+ await capture('oauth-close-reopen');
+ await evaluate('window.__compat.loginDelay=0; document.querySelectorAll(".tab-item")[2].click()');
+ await prepareCopy(true);
+ await capture('google-copy-failure');
+ await evaluate('window.__compat.copyFailure=false; window.__compat.clipboard=null');
+ await click('.oauth-link-panel button');
+ await waitFor('window.__compat.clipboard===window.__compat.url');
+ assert.equal(await evaluate('window.__compat.calls.filter(c=>c.command==="start_antigravity_oauth_login").length'), 1);
+ await capture('google-copy-retry');
+ assert.deepEqual(errors, [], 'Unhandled browser exception');
+ console.log(`OAuth link regression: ${scenes.length} scenes passed; native IPC and account data mocked.`);
+} finally {
+ fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify({ scenes, errors }, null, 2));
+ if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ id: ++sequence, method: 'Browser.close' })); await delay(500); }
+ socket?.close();
+ if (child && child.exitCode === null) { child.kill(); await Promise.race([once(child, 'exit'), delay(5000)]); }
+ await server.close();
+ try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* Browser shutdown can briefly hold the isolated profile. */ }
+}
diff --git a/src/components/AddAccountModal.css b/src/components/AddAccountModal.css
index 1741e6a..3cfbb49 100644
--- a/src/components/AddAccountModal.css
+++ b/src/components/AddAccountModal.css
@@ -159,6 +159,10 @@
}
.oauth-status {
+ max-width: 100%;
+ box-sizing: border-box;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
margin-top: 16px;
font-size: 13px;
font-weight: 500;
@@ -168,6 +172,29 @@
border-radius: 20px;
}
+.oauth-link-panel {
+ width: 100%;
+ min-width: 0;
+ margin-top: 12px;
+ text-align: left;
+ overflow-wrap: anywhere;
+}
+
+.oauth-link-value {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ margin: 6px 0;
+ padding: 10px;
+ font-family: monospace;
+ font-size: 12px;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ resize: vertical;
+ user-select: text;
+}
+
.form-group {
margin-bottom: 20px;
}
diff --git a/src/components/AddAccountModal.tsx b/src/components/AddAccountModal.tsx
index 65214fc..145b1d2 100644
--- a/src/components/AddAccountModal.tsx
+++ b/src/components/AddAccountModal.tsx
@@ -1,4 +1,5 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
+import { OAuthLink } from './OAuthLink';
import { listen, emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
@@ -81,6 +82,21 @@ export function AddAccountModal({ isOpen, onClose, onAdd, onSuccess }: AddAccoun
const [showPasteInput, setShowPasteInput] = useState(false);
const [callbackInput, setCallbackInput] = useState('');
const [submittingCallback, setSubmittingCallback] = useState(false);
+ const [authLink, setAuthLink] = useState<{ url: string; provider: 'openai' | 'google' } | null>(null);
+ const [copyingLink, setCopyingLink] = useState(false);
+ const [linkCopyError, setLinkCopyError] = useState(null);
+ const linkGeneration = useRef(0);
+
+ useEffect(() => {
+ if (!isOpen) {
+ linkGeneration.current++;
+ setAuthLink(null);
+ setCopyingLink(false);
+ setLinkCopyError(null);
+ setLoading(false);
+ }
+ return () => { linkGeneration.current++; };
+ }, [isOpen]);
// 批量导入
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkResult, setBulkResult] = useState(null);
@@ -280,57 +296,55 @@ export function AddAccountModal({ isOpen, onClose, onAdd, onSuccess }: AddAccoun
}
};
- // 复制授权链接(不开默认浏览器,让用户自己选要用哪个浏览器粘贴)
- const handleCopyOAuthLink = async () => {
- setLoading(true);
- setError(null);
- setOauthStatus('正在生成授权链接...');
-
+ const copyPreparedLink = async (url: string, generation: number) => {
+ setCopyingLink(true);
+ setLinkCopyError(null);
try {
- const url = await startOAuthLogin(false);
- // 走后端 pbcopy 而不是 navigator.clipboard:webview 跨 await 后 user-gesture 失效会触发 NotAllowedError
- try {
- await invoke('copy_to_clipboard', { text: url });
+ await invoke('copy_to_clipboard', { text: url });
+ if (generation === linkGeneration.current) {
setOauthStatus('授权链接已复制,请粘贴到目标浏览器完成授权,回调会自动回到本应用...');
- } catch (copyErr) {
- // 复制失败也别卡住流程:把 URL 显示出来让用户手动复制
- setCallbackInput(url);
- setShowPasteInput(false);
- setOauthStatus(`复制到剪贴板失败(${String(copyErr)}),请手动复制:\n${url}`);
}
} catch (err) {
- setError(String(err));
- setOauthStatus('');
- setLoading(false);
+ if (generation === linkGeneration.current) {
+ setLinkCopyError(String(err));
+ setOauthStatus('自动复制失败。请在下方选择完整链接手动复制,或重试复制。');
+ }
+ } finally {
+ if (generation === linkGeneration.current) setCopyingLink(false);
}
};
- const handleAntigravityLogin = async () => {
+ const prepareOAuthLink = async (provider: 'openai' | 'google') => {
+ const generation = ++linkGeneration.current;
setLoading(true);
setError(null);
- setOauthStatus('正在启动 Google Antigravity 授权...');
+ setAuthLink(null);
+ setLinkCopyError(null);
+ setOauthStatus('正在生成授权链接...');
try {
- await invoke('start_antigravity_oauth_login', { openBrowser: true });
- setOauthStatus('请在浏览器中完成 Google 授权...');
+ const url = provider === 'openai'
+ ? await startOAuthLogin(false)
+ : await invoke('start_antigravity_oauth_login', { openBrowser: false });
+ if (generation !== linkGeneration.current) return;
+ setAuthLink({ url, provider });
+ await copyPreparedLink(url, generation);
} catch (err) {
- setError(String(err));
- setOauthStatus('');
- setLoading(false);
+ if (generation === linkGeneration.current) {
+ setError(String(err));
+ setOauthStatus('');
+ setLoading(false);
+ }
}
};
+ const handleCopyOAuthLink = () => prepareOAuthLink('openai');
- const handleCopyAntigravityOAuthLink = async () => {
+ const handleAntigravityLogin = async () => {
setLoading(true);
setError(null);
- setOauthStatus('正在生成 Google 授权链接...');
+ setOauthStatus('正在启动 Google Antigravity 授权...');
try {
- const url = await invoke('start_antigravity_oauth_login', { openBrowser: false });
- try {
- await invoke('copy_to_clipboard', { text: url });
- setOauthStatus('Google 授权链接已复制,请粘贴到目标浏览器完成授权...');
- } catch (copyError) {
- setOauthStatus(`复制到剪贴板失败(${String(copyError)}),请手动复制:\n${url}`);
- }
+ await invoke('start_antigravity_oauth_login', { openBrowser: true });
+ setOauthStatus('请在浏览器中完成 Google 授权...');
} catch (err) {
setError(String(err));
setOauthStatus('');
@@ -338,7 +352,13 @@ export function AddAccountModal({ isOpen, onClose, onAdd, onSuccess }: AddAccoun
}
};
+ const handleCopyAntigravityOAuthLink = () => prepareOAuthLink('google');
+
const handleClose = () => {
+ linkGeneration.current++;
+ setAuthLink(null);
+ setLinkCopyError(null);
+ setCopyingLink(false);
// OAuth 进行中也允许关闭:后端 oauth_server 下次 start 时会 abort 旧任务,无需显式取消
setName('');
setNotes('');
@@ -802,7 +822,7 @@ export function AddAccountModal({ isOpen, onClose, onAdd, onSuccess }: AddAccoun
onClick={handleAntigravityLogin}
disabled={loading}
>
- {loading ? '处理中...' : '连接 Google 账号'}
+ {authLink ? '等待浏览器授权…' : loading ? '处理中...' : '连接 Google 账号'}
)}
{oauthStatus && {oauthStatus}
}
+ {authLink?.provider === 'google' && void copyPreparedLink(authLink.url, linkGeneration.current)} />}
{error && {error}
}
) : activeTab === 'official' ? (
@@ -876,7 +898,7 @@ export function AddAccountModal({ isOpen, onClose, onAdd, onSuccess }: AddAccoun
onClick={handleOpenAILogin}
disabled={loading}
>
- {loading && oauthStatus ? '处理中...' : '立即登录 OpenAI'}
+ {authLink ? '等待浏览器授权…' : loading && oauthStatus ? '处理中...' : '立即登录 OpenAI'}