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'}