From 969e8527b380a9912cac080d265aeb2a7e773815 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 6 Sep 2026 16:53:20 +0800 Subject: [PATCH] fix(quota): use native Tauri dialogs for reset confirmation - Replace browser confirmations and grant dialog permissions - Await explicit consent before consuming reset credits - Prevent duplicate resets and restore state on cancellation - Add native confirmation regression tests --- src-tauri/capabilities/default.json | 3 +- src/pages/AuthFileManagementPage.tsx | 5 +- src/pages/QuotaPage.tsx | 50 ++++----- src/services/quotaActions.ts | 44 ++++++++ tests/quotaConfirmation.test.ts | 146 +++++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 src/services/quotaActions.ts create mode 100644 tests/quotaConfirmation.test.ts diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e06f87d..8104afe 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "core:window:allow-set-theme", "core:window:allow-set-background-color", "core:webview:allow-set-webview-background-color", - "dialog:allow-open" + "dialog:allow-open", + "dialog:allow-message" ] } diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index f6cee10..56b524a 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -1,4 +1,5 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { confirm } from '@tauri-apps/plugin-dialog'; import { Check, Copy, @@ -216,7 +217,9 @@ export function AuthFileManagementPage() { if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file, { confirmXaiPaidProbe: () => window.confirm(t('quota.xaiProbeConfirm')) }); + const result = await loadQuota(file, { + confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }), + }); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [key]: result })); }); diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 14f89c8..d547b2f 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react'; +import { confirm } from '@tauri-apps/plugin-dialog'; +import { resetCodexQuotaWithConfirmation } from '../services/quotaActions'; import antigravityIcon from '../assets/icons/antigravity.svg'; import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; @@ -8,7 +10,6 @@ import kimiIcon from '../assets/icons/kimi-light.svg'; import { managementApi, readBoolean, responseList } from '../services/managementApi'; import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; import { - consumeCodexResetCredit, fileName, formatQuotaTimestamp, idleQuota, @@ -84,43 +85,28 @@ export function QuotaPage() { if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file, { confirmXaiPaidProbe: () => window.confirm(t('quota.xaiProbeConfirm')) }); + const result = await loadQuota(file, { + confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }), + }); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [key]: result })); }); }, [t]); const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => { - if (getQuotaCacheSnapshot()[quotaKey(file)]?.status === 'loading') return; - const confirmed = window.confirm([ - t('quota.confirm.title', { name: fileName(file) }), - '', - t('quota.confirm.cost'), - t('quota.confirm.available', { count: quota.resetCredits ?? '—' }), - t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }), - '', - t('quota.confirm.warning'), - ].join('\n')); - if (!confirmed) return; - const key = quotaKey(file); - const cacheGeneration = captureQuotaCacheGeneration(); - updateQuotaCache((current) => ({ ...current, [key]: { ...current[key], status: 'loading', rows: [] } })); + setError(''); try { - const result = await consumeCodexResetCredit(file); - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ ...current, [key]: result })); - }); + await resetCodexQuotaWithConfirmation(file, () => confirm([ + t('quota.confirm.title', { name: fileName(file) }), + '', + t('quota.confirm.cost'), + t('quota.confirm.available', { count: quota.resetCredits ?? '—' }), + t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }), + '', + t('quota.confirm.warning'), + ].join('\n'), { title: t('quota.reset'), kind: 'warning' })); } catch (requestError) { - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ - ...current, - [key]: { - status: 'error', - rows: [], - error: requestError instanceof Error ? requestError.message : String(requestError), - }, - })); - }); + setError(requestError instanceof Error ? requestError.message : String(requestError)); } }, [locale, t]); @@ -140,7 +126,9 @@ export function QuotaPage() { const batch = files.slice(index, index + REFRESH_CONCURRENCY); await Promise.all(batch.map(async (file) => { const result = await loadQuota(file, { - confirmXaiPaidProbe: () => window.confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`), + confirmXaiPaidProbe: () => confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`, { + title: t('quota.title'), kind: 'warning', + }), }); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result })); diff --git a/src/services/quotaActions.ts b/src/services/quotaActions.ts new file mode 100644 index 0000000..28c0e86 --- /dev/null +++ b/src/services/quotaActions.ts @@ -0,0 +1,44 @@ +import { + captureQuotaCacheGeneration, + commitQuotaCacheIfCurrent, + getQuotaCacheSnapshot, + updateQuotaCache, +} from './quotaCache'; +import { consumeCodexResetCredit, idleQuota, quotaKey, type AuthFile, type QuotaState } from './quotaService'; + +/** Reserve the credential before opening the asynchronous native confirmation dialog. */ +export async function resetCodexQuotaWithConfirmation( + file: AuthFile, + confirmReset: () => Promise, +): Promise { + const key = quotaKey(file); + const previous = getQuotaCacheSnapshot()[key] ?? idleQuota(); + if (previous.status === 'loading') return; + const generation = captureQuotaCacheGeneration(); + const pending: QuotaState = { ...previous, status: 'loading' }; + updateQuotaCache((current) => ({ ...current, [key]: pending })); + + const isCurrent = () => captureQuotaCacheGeneration() === generation + && getQuotaCacheSnapshot()[key] === pending; + const commit = (quota: QuotaState) => { + commitQuotaCacheIfCurrent(generation, () => { + updateQuotaCache((current) => current[key] === pending ? { ...current, [key]: quota } : current); + }); + }; + + let started = false; + try { + // Never treat a pending promise, dismissal, or unexpected result as consent. + if (await confirmReset() !== true || !isCurrent()) return; + started = true; + commit(await consumeCodexResetCredit(file)); + } catch (error) { + if (started) commit({ + status: 'error', rows: [], error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + // Cancellation and dialog failures preserve the displayed quota and release the lock. + if (!started) commit(previous); + } +} diff --git a/tests/quotaConfirmation.test.ts b/tests/quotaConfirmation.test.ts new file mode 100644 index 0000000..d5e264c --- /dev/null +++ b/tests/quotaConfirmation.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { confirm } from '@tauri-apps/plugin-dialog'; +import { clearMocks, mockIPC } from '@tauri-apps/api/mocks'; +import { resetCodexQuotaWithConfirmation } from '../src/services/quotaActions'; +import { getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache } from '../src/services/quotaCache'; +import { quotaKey, type QuotaState } from '../src/services/quotaService'; + +const file = { name: 'confirm-test.json', provider: 'codex', auth_index: 'confirm-test' }; +const key = quotaKey(file); +const previous: QuotaState = { + status: 'success', rows: [{ label: '5h', remainingPercent: 0 }], resetCredits: 2, +}; +const ask = () => confirm('Consume one Codex reset credit?', { title: 'Reset quota', kind: 'warning' }); +let originalWindow: PropertyDescriptor | undefined; +let originalCache: ReturnType; +let answer: () => Promise; +let dialogCalls: unknown[]; +let upstreamCalls: { url: string; method: string }[]; +let consumeError: boolean; + +beforeEach(() => { + originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true }); + originalCache = getQuotaCacheSnapshot(); + updateQuotaCache({ [key]: previous }); + answer = async () => 'Cancel'; + dialogCalls = []; + upstreamCalls = []; + consumeError = false; + mockIPC((command, payload) => { + if (command === 'plugin:dialog|message') { + dialogCalls.push(payload); + return answer(); + } + if (command === 'management_request') { + const request = (payload as { request: { path: string; body: { url: string; method: string } } }).request; + expect(request.path).toBe('/api-call'); + upstreamCalls.push(request.body); + if (request.body.url.endsWith('/consume') && consumeError) return { status_code: 409, body: 'reset denied' }; + return { + status_code: 200, + body: request.body.url.endsWith('/usage') + ? { rate_limit: { primary_window: { used_percent: 0 } } } + : { available_count: 1 }, + }; + } + throw new Error(`Unexpected command: ${command}`); + }); +}); + +afterEach(() => { + clearMocks(); + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + updateQuotaCache(originalCache); +}); + +describe('native quota reset confirmation', () => { + it('uses the native dialog IPC and has its required Tauri capability', async () => { + const capabilities = await Bun.file(new URL('../src-tauri/capabilities/default.json', import.meta.url)).json(); + expect(capabilities.permissions).toContain('dialog:allow-message'); + await resetCodexQuotaWithConfirmation(file, ask); + expect(dialogCalls).toEqual([{ + message: 'Consume one Codex reset credit?', title: 'Reset quota', kind: 'warning', buttons: 'OkCancel', + }]); + expect(upstreamCalls).toHaveLength(0); + }); + + it('waits for explicit confirmation before sending any request', async () => { + let resolve!: (value: string) => void; + answer = () => new Promise((done) => { resolve = done; }); + const resetting = resetCodexQuotaWithConfirmation(file, ask); + expect(getQuotaCacheSnapshot()[key].status).toBe('loading'); + expect(upstreamCalls).toHaveLength(0); + resolve('Ok'); + await resetting; + expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1); + expect(upstreamCalls[0].method).toBe('POST'); + expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'success', resetCredits: 1 }); + }); + + it.each(['Cancel', null, undefined, 'unexpected'])('does not consume a credit when dismissed with %s', async (result) => { + answer = async () => result; + await resetCodexQuotaWithConfirmation(file, ask); + expect(upstreamCalls).toHaveLength(0); + expect(getQuotaCacheSnapshot()[key]).toBe(previous); + }); + + it('restores the previous quota when opening the dialog fails, and allows retry', async () => { + answer = async () => { throw new Error('dialog permission denied'); }; + await expect(resetCodexQuotaWithConfirmation(file, ask)).rejects.toThrow('dialog permission denied'); + expect(getQuotaCacheSnapshot()[key]).toBe(previous); + expect(upstreamCalls).toHaveLength(0); + answer = async () => 'Ok'; + await resetCodexQuotaWithConfirmation(file, ask); + expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1); + }); + + it('does not open another dialog or consume twice on repeated clicks', async () => { + let resolve!: (value: string) => void; + answer = () => new Promise((done) => { resolve = done; }); + const first = resetCodexQuotaWithConfirmation(file, ask); + await resetCodexQuotaWithConfirmation(file, ask); + expect(dialogCalls).toHaveLength(1); + expect(upstreamCalls).toHaveLength(0); + resolve('Ok'); + await first; + expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1); + }); + + it('ignores confirmation if the credential was removed while the dialog was open', async () => { + let resolve!: (value: string) => void; + answer = () => new Promise((done) => { resolve = done; }); + const resetting = resetCodexQuotaWithConfirmation(file, ask); + pruneQuotaCache(new Set()); + resolve('Ok'); + await resetting; + expect(upstreamCalls).toHaveLength(0); + expect(getQuotaCacheSnapshot()[key]).toBeUndefined(); + }); + + it('does not overwrite newer quota state after a pending dialog is cancelled', async () => { + let resolve!: (value: string) => void; + answer = () => new Promise((done) => { resolve = done; }); + const resetting = resetCodexQuotaWithConfirmation(file, ask); + const newer: QuotaState = { status: 'success', rows: [], resetCredits: 3 }; + updateQuotaCache({ [key]: newer }); + resolve('Cancel'); + await resetting; + expect(getQuotaCacheSnapshot()[key]).toBe(newer); + expect(upstreamCalls).toHaveLength(0); + }); + + it('reports reset failures and releases the pending state', async () => { + answer = async () => 'Ok'; + consumeError = true; + await expect(resetCodexQuotaWithConfirmation(file, ask)).rejects.toThrow('reset denied'); + expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'error', error: 'reset denied' }); + }); + + it('does not reintroduce browser confirmation in quota actions', async () => { + const page = await Bun.file(new URL('../src/pages/QuotaPage.tsx', import.meta.url)).text(); + expect(page).not.toContain('window.confirm'); + expect(page).toContain('await resetCodexQuotaWithConfirmation(file, () => confirm('); + }); +});