diff --git a/src-tauri/src/core_config/alias_edit.rs b/src-tauri/src/core_config/alias_edit.rs index 4bfe66b..4236598 100644 --- a/src-tauri/src/core_config/alias_edit.rs +++ b/src-tauri/src/core_config/alias_edit.rs @@ -205,7 +205,7 @@ pub(crate) fn resolve_model_alias_edit_source( if let ThinkingAliasSourceLocation::Oauth { channel, .. } = source.location { if let Some(model) = definitions .iter() - .filter(|set| set.channel.key == channel) + .filter(|set| set.channel.key == channel && set.channel.supports_reasoning) .flat_map(|set| &set.models) .find(|model| model.id.eq_ignore_ascii_case(&source.source.model)) { diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 15bf06d..5b35608 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -12,7 +12,7 @@ pub(crate) struct OAuthAliasChannel { pub(crate) force_mapping: bool, } -pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [ +pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 8] = [ OAuthAliasChannel { key: "vertex", provider: "Vertex OAuth", @@ -67,6 +67,15 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [ supports_fast: false, force_mapping: false, }, + OAuthAliasChannel { + key: "devin", + provider: "Devin OAuth", + kind: "devin-oauth", + protocol: "interactions", + supports_reasoning: false, + supports_fast: false, + force_mapping: false, + }, OAuthAliasChannel { key: "xai", provider: "xAI OAuth", @@ -126,6 +135,7 @@ pub(crate) fn normalize_oauth_alias_channel(value: &str) -> Option<&'static str> "claude" | "anthropic" => Some("claude"), "codex" => Some("codex"), "kimi" | "moonshot" => Some("kimi"), + "devin" | "cognition" => Some("devin"), "xai" | "x-ai" | "grok" => Some("xai"), _ => None, } diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 181d6c2..7b24573 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -361,6 +361,7 @@ fn normalize_management_oauth_provider(provider: &str) -> Result let key = match key.as_str() { "claude" | "anthropic" => "anthropic".to_string(), "anti-gravity" => "antigravity".to_string(), + "cognition" => "devin".to_string(), "grok" | "x-ai" | "x.ai" => "xai".to_string(), other => other.to_string(), }; @@ -375,7 +376,7 @@ fn normalize_management_oauth_provider(provider: &str) -> Result } fn management_oauth_uses_webui_callback(provider_key: &str) -> bool { - matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai") + matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai" | "devin") } async fn read_management_json(response: reqwest::Response) -> Result @@ -459,6 +460,15 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn devin_oauth_uses_the_management_callback_flow() { + for provider in ["Devin", " cognition "] { + let key = normalize_management_oauth_provider(provider).unwrap(); + assert_eq!(key, "devin"); + assert!(management_oauth_uses_webui_callback(&key)); + } + } + #[test] fn core_logs_follow_the_default_auth_directory() { let base_dir = PathBuf::from("test-base"); diff --git a/src-tauri/src/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index 2379268..988c8d2 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -699,3 +699,25 @@ fn edit_model_alias_rejects_missing_and_ambiguous_aliases() { let content = "oauth-model-alias:\n codex:\n - name: model-a\n alias: shared\n claude:\n - name: model-b\n alias: shared\n"; assert!(resolve_model_alias_edit_source(content, "shared", &[]).is_err()); } + +#[test] +fn devin_aliases_use_the_devin_channel_without_unsupported_overrides() { + assert_eq!(normalize_oauth_alias_channel("cognition"), Some("devin")); + let available_models = test_agent_models(&["devin/swe-2"]); + let mut definitions = test_oauth_definition_set("devin", &["devin/swe-2"]); + definitions.models[0].reasoning_levels = vec!["medium".to_string(), "high".to_string()]; + let definitions = vec![definitions]; + let sources = resolved_oauth_alias_sources("{}\n", &definitions, &available_models, AliasSourceCapability::Base).unwrap(); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].source.kind, "devin-oauth"); + let rendered = add_model_alias_to_yaml("{}\n", &sources[0], "swe-2", "", false).unwrap(); + assert!(rendered.contains("devin:"), "{rendered}"); + assert!(rendered.contains("name: devin/swe-2"), "{rendered}"); + assert!(rendered.contains("alias: swe-2"), "{rendered}"); + assert!(!rendered.contains("payload:"), "{rendered}"); + let edited = model_alias_edit_context(&rendered, "swe-2", &definitions).unwrap(); + assert!(edited.source.reasoning_levels.is_empty()); + for capability in [AliasSourceCapability::Reasoning, AliasSourceCapability::Fast] { + assert!(resolved_oauth_alias_sources("{}\n", &definitions, &available_models, capability).unwrap().is_empty()); + } +} diff --git a/src/assets/icons/devin.svg b/src/assets/icons/devin.svg new file mode 100644 index 0000000..c5f9a18 --- /dev/null +++ b/src/assets/icons/devin.svg @@ -0,0 +1 @@ +Devin \ No newline at end of file diff --git a/src/i18n/easyMode.ts b/src/i18n/easyMode.ts index 9cd9ec3..b573fc5 100644 --- a/src/i18n/easyMode.ts +++ b/src/i18n/easyMode.ts @@ -27,6 +27,7 @@ export const easyModeZhCN = { "easyMode.oauth.providerDesc.antigravity": "Antigravity 账号授权登录", "easyMode.oauth.providerDesc.kimi": "Moonshot / Kimi 账号授权登录", "easyMode.oauth.providerDesc.xai": "xAI / Grok 账号授权登录", + "easyMode.oauth.providerDesc.devin": "Devin / Cognition 账号授权登录", "easyMode.api.platformsConnected": "已接入 {count} 个平台", "easyMode.api.supportedPlatforms": "支持 OpenAI、Anthropic、Codex、Gemini、DeepSeek", "easyMode.api.platformName.openai": "OpenAI 格式", @@ -108,6 +109,7 @@ export const easyModeEn: Record = { "easyMode.oauth.providerDesc.antigravity": "Antigravity authorization sign-in", "easyMode.oauth.providerDesc.kimi": "Moonshot / Kimi authorization sign-in", "easyMode.oauth.providerDesc.xai": "xAI / Grok authorization sign-in", + "easyMode.oauth.providerDesc.devin": "Devin / Cognition authorization sign-in", "easyMode.api.platformsConnected": "{count} platform(s) connected", "easyMode.api.supportedPlatforms": "Supports OpenAI, Anthropic, Codex, Gemini, DeepSeek", "easyMode.api.platformName.openai": "OpenAI Format", @@ -189,6 +191,7 @@ export const easyModeJa: Record = { "easyMode.oauth.providerDesc.antigravity": "Antigravity アカウント認証ログイン", "easyMode.oauth.providerDesc.kimi": "Moonshot / Kimi アカウント認証ログイン", "easyMode.oauth.providerDesc.xai": "xAI / Grok アカウント認証ログイン", + "easyMode.oauth.providerDesc.devin": "Devin / Cognition アカウント認証ログイン", "easyMode.api.platformsConnected": "{count} 件のプラットフォームを接続済み", "easyMode.api.supportedPlatforms": "OpenAI、Anthropic、Codex、Gemini、DeepSeek に対応", "easyMode.api.platformName.openai": "OpenAI 形式", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 1aad1fe..25b5321 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -326,6 +326,9 @@ export const jaOverrides = { 'quota.confirm.available': '現在利用可能:{count} 回', 'quota.confirm.expiry': '最も早い有効期限:{time}', 'quota.confirm.warning': '手動リセットを 1 回消費します。「リセットを実行」をクリックした場合のみ実行します。キャンセルや閉じる操作では消費しません。', + 'oauth.devinHint': 'ブラウザーで Devin を認証してください。リダイレクトに失敗した場合は、今回のログインの完全なコールバック URL を貼り付けてください。', + 'oauth.devinStateMismatch': 'コールバックの state が今回の Devin ログインと一致しません。最新の認証リンクでログインしてください。', + 'quota.service.daily': '日次クォータ', 'oauth.title': 'OAuth ログイン', 'oauth.tabs.label': 'OAuth 管理ページ', 'oauth.loginSuccess': '{provider} へのログインに成功しました', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1cfcfa4..9459c9b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -313,6 +313,9 @@ export const en: Record = { 'quota.confirm.available': 'Currently available: {count}', 'quota.confirm.expiry': 'Earliest expiry: {time}', 'quota.confirm.warning': 'This consumes one manual reset credit. It runs only after Confirm Reset; canceling or closing does not consume a credit.', + 'oauth.devinHint': 'Authorize Devin in your browser. If the redirect fails, paste the full callback URL from this login attempt.', + 'oauth.devinStateMismatch': 'The callback state does not match this Devin login. Complete sign-in using the latest authorization link.', + 'quota.service.daily': 'Daily quota', 'oauth.title': 'OAuth Sign-In', 'oauth.tabs.label': 'OAuth management sections', 'oauth.loginSuccess': '{provider} sign-in succeeded', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e34d03c..dddda91 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -312,6 +312,9 @@ export const zhCN = { 'quota.confirm.available': '当前可用:{count} 次', 'quota.confirm.expiry': '最早过期:{time}', 'quota.confirm.warning': '将消耗 1 次主动重置机会。只有点击“确认重置”才会执行,取消或关闭不会消耗次数。', + 'oauth.devinHint': '在浏览器中完成 Devin 授权;若跳转失败,请粘贴本次登录的完整回调 URL。', + 'oauth.devinStateMismatch': '回调 state 与本次 Devin 登录不匹配,请使用最新授权链接完成登录。', + 'quota.service.daily': '每日额度', 'oauth.title': 'OAuth 登录', 'oauth.tabs.label': 'OAuth 管理子页面', 'oauth.loginSuccess': '{provider} 登录成功', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index 14e4013..052f761 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -22,6 +22,7 @@ import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; import geminiIcon from '../assets/icons/gemini.svg'; import grokIcon from '../assets/icons/grok.svg'; +import devinIcon from '../assets/icons/devin.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; import vertexIcon from '../assets/icons/vertex.svg'; import { @@ -88,6 +89,7 @@ const providerIcons: Record = { kimi: kimiIcon, vertex: vertexIcon, xai: grokIcon, + devin: devinIcon, }; const providerName = (file: AuthFile) => { @@ -95,11 +97,13 @@ const providerName = (file: AuthFile) => { if (value === 'anthropic') return 'Claude'; if (value === 'anti-gravity') return 'Antigravity'; if (value === 'xai') return 'xAI'; + if (value === 'cognition') return 'Devin'; return value ? value.charAt(0).toUpperCase() + value.slice(1) : translate(getCurrentLocale(), 'authFiles.unknownProvider'); }; const providerKey = (file: AuthFile) => { const value = readString(file, 'provider', 'type', 'account_type').toLowerCase(); + if (value === 'cognition') return 'devin'; return value === 'anthropic' ? 'claude' : value === 'anti-gravity' ? 'antigravity' : value; }; @@ -531,7 +535,7 @@ export function AuthFileManagementPage() { const priority = parseAuthFilePriority(file.priority) ?? 0; return (
- +
{name}{statusText(file)}
{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''} diff --git a/src/pages/EasyModePage.tsx b/src/pages/EasyModePage.tsx index a53c34e..a8684b0 100644 --- a/src/pages/EasyModePage.tsx +++ b/src/pages/EasyModePage.tsx @@ -45,6 +45,7 @@ import claudeIcon from "../assets/icons/claude.svg"; import antigravityIcon from "../assets/icons/antigravity.svg"; import kimiIcon from "../assets/icons/kimi-light.svg"; import grokIcon from "../assets/icons/grok.svg"; +import devinIcon from "../assets/icons/devin.svg"; import openaiIcon from "../assets/icons/openai-light.svg"; import deepseekIcon from "../assets/icons/deepseek.svg"; import geminiIcon from "../assets/icons/gemini.svg"; @@ -52,7 +53,7 @@ import geminiIcon from "../assets/icons/gemini.svg"; type AuthMethod = "oauth" | "api"; type SetupStep = 1 | 2; -type OAuthProviderId = "codex" | "claude" | "antigravity" | "kimi" | "xai"; +type OAuthProviderId = "codex" | "claude" | "antigravity" | "kimi" | "xai" | "devin"; type OAuthProviderInfo = { id: OAuthProviderId; @@ -67,6 +68,7 @@ const oauthProviders: OAuthProviderInfo[] = [ { id: "antigravity", name: "Antigravity OAuth", icon: antigravityIcon, descriptionKey: "easyMode.oauth.providerDesc.antigravity" }, { id: "kimi", name: "Kimi OAuth", icon: kimiIcon, descriptionKey: "easyMode.oauth.providerDesc.kimi" }, { id: "xai", name: "xAI OAuth", icon: grokIcon, descriptionKey: "easyMode.oauth.providerDesc.xai" }, + { id: "devin", name: "Devin OAuth", icon: devinIcon, descriptionKey: "easyMode.oauth.providerDesc.devin" }, ]; type ApiSection = "openai-compatibility" | "deepseek" | "claude" | "gemini" | "codex"; @@ -212,7 +214,7 @@ export function EasyModePage({ const norm = providerId === "claude" ? "claude" : providerId === "codex" ? "codex" : providerId; return authFiles.some((f) => { const p = readString(f, "provider", "type").toLowerCase(); - return p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic")); + return (norm === "devin" && p === "cognition") || p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic")); }); }; @@ -833,7 +835,7 @@ export function EasyModePage({ >
- +
{provider.name} diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index db9d381..98f5aea 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -12,6 +12,7 @@ import antigravityIcon from '../assets/icons/antigravity.svg'; import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; +import devinIcon from '../assets/icons/devin.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; import { useI18n } from '../i18n'; import { FloatingNotice, useAppNotice } from '../appNotice'; @@ -28,8 +29,9 @@ import { } from '../services/oauthLoginState'; import { AuthFileManagementPage } from './AuthFileManagementPage'; import { QuotaPage } from './QuotaPage'; +import { validateDevinCallback } from '../services/devinOAuth'; -type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai'; +type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin'; type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error'; type OAuthProviderState = { @@ -68,6 +70,7 @@ const oauthProviders = [ { id: 'antigravity' as const, name: 'Antigravity OAuth', icon: antigravityIcon }, { id: 'kimi' as const, name: 'Kimi OAuth', icon: kimiIcon }, { id: 'xai' as const, name: 'xAI OAuth', icon: grokIcon }, + { id: 'devin' as const, name: 'Devin OAuth', icon: devinIcon }, ]; const OAUTH_CALLBACK_SUPPORTED = new Set([ @@ -75,6 +78,7 @@ const OAUTH_CALLBACK_SUPPORTED = new Set([ 'claude', 'antigravity', 'xai', + 'devin', ]); const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback'; const OAUTH_POLL_INTERVAL_MS = 3000; @@ -165,6 +169,7 @@ export function OAuthLoginPage() { const [selectedBrowser, setSelectedBrowser] = useState(loadOAuthBrowserPreference); const pollingTimers = useRef>>({}); const pollingRequests = useRef>>({}); + const pollingSessions = useRef>>({}); const credentialSnapshots = useRef>>({}); useEffect(() => { @@ -212,6 +217,7 @@ export function OAuthLoginPage() { const clearPollingTimer = useCallback((provider: OAuthProviderId) => { const timer = pollingTimers.current[provider]; if (timer !== undefined) window.clearInterval(timer); + delete pollingSessions.current[provider]; delete pollingTimers.current[provider]; delete pollingRequests.current[provider]; }, []); @@ -256,11 +262,14 @@ export function OAuthLoginPage() { const startPolling = useCallback( (provider: OAuthProviderId, state: string) => { clearPollingTimer(provider); + pollingSessions.current[provider] = state; + const isCurrent = () => pollingSessions.current[provider] === state; const checkStatus = async () => { if (pollingRequests.current[provider]) return; pollingRequests.current[provider] = true; try { const result = await invoke('get_oauth_status', { state }); + if (!isCurrent()) return; const status = (result.status || '').toLowerCase(); if (status === 'ok') { let priorityError = ''; @@ -269,6 +278,7 @@ export function OAuthLoginPage() { } catch (error) { priorityError = String(error); } + if (!isCurrent()) return; completeProviderAuth(provider); showNotice( priorityError @@ -292,6 +302,7 @@ export function OAuthLoginPage() { ); } } catch (error) { + if (!isCurrent()) return; updateProviderState(provider, { status: 'error', error: String(error), @@ -300,7 +311,7 @@ export function OAuthLoginPage() { clearPollingTimer(provider); showNotice(String(error), 'error'); } finally { - delete pollingRequests.current[provider]; + if (isCurrent()) delete pollingRequests.current[provider]; } }; pollingTimers.current[provider] = window.setInterval( @@ -313,6 +324,7 @@ export function OAuthLoginPage() { useEffect(() => { return () => { + pollingSessions.current = {}; Object.values(pollingTimers.current).forEach((timer) => { if (timer !== undefined) window.clearInterval(timer); }); @@ -386,6 +398,12 @@ export function OAuthLoginPage() { await managementApi.delete('/oauth-session', { query: { state: currentState } }); } } catch (error) { + if (provider === 'devin') { + updateProviderState(provider, { refreshing: false }); + if (currentState) startPolling(provider, currentState); + showNotice(String(error), 'error'); + return; + } console.warn('Failed to cancel the previous OAuth session before refreshing', error); } try { @@ -428,6 +446,13 @@ export function OAuthLoginPage() { return; } + if (provider === 'devin') { + const error = validateDevinCallback(callbackInput, current?.state); + if (error) { + showNotice(t(error === 'state_mismatch' ? 'oauth.devinStateMismatch' : 'oauth.invalidCallback'), 'error'); + return; + } + } const redirectUrl = resolveCallbackUrl(provider, callbackInput, current?.state); if (!redirectUrl) { showNotice( @@ -499,7 +524,7 @@ export function OAuthLoginPage() { return (
- +

{provider.name}

{shouldShowOAuthLoginStatus(state.status) ? ( @@ -509,7 +534,7 @@ export function OAuthLoginPage() {
-

{t('oauth.hint')}

+

{t(provider.id === 'devin' ? 'oauth.devinHint' : 'oauth.hint')}

{state.url ? (
{t('oauth.authorizationLink')}
diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 84c4ace..3a51a58 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -8,6 +8,7 @@ import antigravityIcon from '../assets/icons/antigravity.svg'; import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; +import devinIcon from '../assets/icons/devin.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; import { managementApi, readBoolean, responseList } from '../services/managementApi'; import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; @@ -38,10 +39,11 @@ const providerMeta: Record = { codex: { label: 'Codex', icon: codexIcon }, kimi: { label: 'Kimi', icon: kimiIcon }, xai: { label: 'xAI', icon: grokIcon }, + devin: { label: 'Devin', icon: devinIcon }, antigravity: { label: 'Antigravity', icon: antigravityIcon }, }; -const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi']; +const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin']; const REFRESH_CONCURRENCY = 4; export function QuotaPage() { @@ -177,7 +179,7 @@ export function QuotaPage() {
{grouped.map(([provider, items]) => (
-

{providerMeta[provider].label}

{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}
+

{providerMeta[provider].label}

{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}
{items.map(({ file, quota }) => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}
))} @@ -216,6 +218,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; {quota.subscriptionActiveUntil ? {t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })} : null}
: null} + {quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ?
{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}
: null} {quota.status === 'success' ?
{quota.rows.map((row, index) => { const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now); return
diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index bc7ba89..ec10b64 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -168,6 +168,7 @@ export const thinkingAliasSourceKindLabel = (kind: string) => { if (kind === 'vertex-oauth') return 'Vertex OAuth'; if (kind === 'kimi-oauth') return 'Kimi OAuth'; if (kind === 'xai-oauth') return 'xAI OAuth'; + if (kind === 'devin-oauth') return 'Devin OAuth'; if (kind === 'codex-api') return 'Codex API'; if (kind === 'claude-api') return 'Claude API'; if (kind === 'gemini-api') return 'Gemini API'; diff --git a/src/services/authFiles.ts b/src/services/authFiles.ts index ac147ef..55a792b 100644 --- a/src/services/authFiles.ts +++ b/src/services/authFiles.ts @@ -29,6 +29,7 @@ export const normalizeAuthFilePriorityInput = (value: string): number | null => const normalizeOAuthProvider = (value: string) => { const provider = value.trim().toLowerCase(); + if (provider === 'cognition') return 'devin'; if (provider === 'anthropic') return 'claude'; if (provider === 'openai') return 'codex'; return provider; diff --git a/src/services/devinOAuth.ts b/src/services/devinOAuth.ts new file mode 100644 index 0000000..cf97b56 --- /dev/null +++ b/src/services/devinOAuth.ts @@ -0,0 +1,20 @@ +export const validateDevinCallback = ( + input: string, + expectedState?: string, +): 'invalid' | 'state_mismatch' | undefined => { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return 'invalid'; + } + const params = url.searchParams; + if (!['http:', 'https:'].includes(url.protocol) + || params.getAll('state').length !== 1 + || !params.get('state')?.trim() + || !['code', 'error', 'error_description'].some((key) => params.get(key)?.trim())) { + return 'invalid'; + } + if (!expectedState || params.get('state') !== expectedState) return 'state_mismatch'; + return undefined; +}; diff --git a/src/services/devinQuota.ts b/src/services/devinQuota.ts new file mode 100644 index 0000000..d44d395 --- /dev/null +++ b/src/services/devinQuota.ts @@ -0,0 +1,60 @@ +import { isRecord } from './managementApi'; + +export const DEVIN_QUOTA_URL = 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus'; +export const DEVIN_QUOTA_HEADERS = { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', +}; +export const DEVIN_QUOTA_DATA = JSON.stringify({ + metadata: { + ideName: 'chisel', + ideVersion: '3000.10.21', + apiKey: '$TOKEN$', + locale: 'en', + os: 'darwin', + extensionVersion: '3000.10.21', + clientName: 'chisel', + }, +}); + +const percentValue = (value: unknown): number | null => { + if (typeof value !== 'number' && typeof value !== 'string') return null; + if (typeof value === 'string' && !/^\d+(?:\.\d+)?$/.test(value.trim())) return null; + const percent = Number(value); + return Number.isFinite(percent) && percent >= 0 && percent <= 100 ? percent : null; +}; + +const unixInstant = (value: unknown): number | undefined => { + if (typeof value !== 'number' && typeof value !== 'string') return undefined; + if (typeof value === 'string' && !/^\d+$/.test(value.trim())) return undefined; + const seconds = Number(value); + const ms = seconds * 1000; + return Number.isSafeInteger(seconds) && ms > 0 && Number.isFinite(new Date(ms).getTime()) + ? ms : undefined; +}; + +export const readDevinQuota = (payload: unknown) => { + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + payload = null; + } + } + const userStatus = isRecord(payload) && isRecord(payload.userStatus) ? payload.userStatus : {}; + const planStatus = isRecord(userStatus.planStatus) ? userStatus.planStatus : {}; + const planInfo = isRecord(planStatus.planInfo) ? planStatus.planInfo : {}; + const windows = (['daily', 'weekly'] as const).map((id) => ({ + id, + remainingPercent: percentValue(planStatus[id + 'QuotaRemainingPercent']), + resetAtMs: unixInstant(planStatus[id + 'QuotaResetAtUnix']), + })); + const observed = windows.some((window) => window.remainingPercent !== null || window.resetAtMs !== undefined); + const planEnd = planStatus.planEnd; + return { + windows: observed ? windows : [], + plan: typeof planInfo.planName === 'string' ? planInfo.planName.trim() || undefined : undefined, + subscriptionActiveUntil: typeof planEnd === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(planEnd) + && Number.isFinite(Date.parse(planEnd)) ? planEnd : undefined, + }; +}; diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index e87c017..5036d87 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -7,6 +7,7 @@ import { readString, } from './managementApi'; import { authFileName } from './authFiles'; +import { DEVIN_QUOTA_DATA, DEVIN_QUOTA_HEADERS, DEVIN_QUOTA_URL, readDevinQuota } from './devinQuota'; import { antigravityProjectFor, codexMetadataFor, isPaidXaiFile } from './quotaMetadata'; import { quotaResetFor, quotaResetInstant } from './quotaTime'; import { getCurrentLocale, translate, type AppLocale } from '../i18n'; @@ -17,7 +18,7 @@ const quotaText = ( ) => translate(getCurrentLocale(), key, variables); export type AuthFile = Record; -export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity'; +export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity' | 'devin'; export type QuotaStatus = 'idle' | 'loading' | 'success' | 'error'; export type QuotaRow = { label: string; @@ -45,6 +46,7 @@ export type QuotaState = { export const idleQuota = (): QuotaState => ({ status: 'idle', rows: [] }); const endpointByProvider: Record = { + devin: DEVIN_QUOTA_URL, claude: 'https://api.anthropic.com/api/oauth/usage', codex: 'https://chatgpt.com/backend-api/wham/usage', kimi: 'https://api.kimi.com/coding/v1/usages', @@ -62,6 +64,7 @@ const ANTIGRAVITY_CODE_ASSIST_URL = 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; const headersByProvider: Record> = { + devin: DEVIN_QUOTA_HEADERS, claude: { Authorization: 'Bearer $TOKEN$', 'Content-Type': 'application/json', @@ -90,9 +93,10 @@ const headersByProvider: Record> = { export const providerForFile = (file: AuthFile): QuotaProvider | null => { const value = readString(file, 'provider', 'type', 'account_type').toLowerCase().replace(/_/g, '-'); if (value === 'x-ai' || value === 'grok') return 'xai'; + if (value === 'cognition') return 'devin'; if (value === 'anthropic') return 'claude'; if (value === 'anti-gravity') return 'antigravity'; - return ['claude', 'codex', 'kimi', 'xai', 'antigravity'].includes(value) + return ['claude', 'codex', 'kimi', 'xai', 'antigravity', 'devin'].includes(value) ? (value as QuotaProvider) : null; }; @@ -337,6 +341,11 @@ export const codexResetCreditDetailsFor = ( }; export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRow[] => { + if (provider === 'devin') return readDevinQuota(payload).windows.map((window) => ({ + label: quotaText(window.id === 'daily' ? 'quota.service.daily' : 'quota.service.weekly'), + remainingPercent: window.remainingPercent, + resetAtMs: window.resetAtMs, + })); const value = parseBody(payload); if (!isRecord(value)) return []; @@ -754,6 +763,10 @@ async function callUpstreamQuota( ): Promise { const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex')); + if (provider === 'devin') { + if (!readString(file, 'name')) throw new Error(quotaText('quota.service.error.missingAuthIndex')); + return requestQuotaPayload(authIndex, DEVIN_QUOTA_URL, DEVIN_QUOTA_HEADERS, 'POST', DEVIN_QUOTA_DATA, undefined, responseClock); + } const header = { ...headersByProvider[provider] }; if (provider === 'codex') { const accountId = resolvedCodexAccountId ?? await resolveCodexAccountId(file); @@ -871,9 +884,10 @@ async function loadQuotaSnapshot(file: AuthFile): Promise { return { status: 'success', rows, - plan: detectedPlan + plan: (provider === 'devin' ? readDevinQuota(payload).plan : detectedPlan) ?? (readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType') || codexMetadata?.plan), - subscriptionActiveUntil: codexMetadata?.subscriptionActiveUntil, + subscriptionActiveUntil: provider === 'devin' + ? readDevinQuota(payload).subscriptionActiveUntil : codexMetadata?.subscriptionActiveUntil, resetCreditsError, resetCreditsApplicable: usageCreditDetails.applicableAvailableCount ?? resetCreditDetails?.applicableAvailableCount ?? resetCredits, diff --git a/src/styles.css b/src/styles.css index b2821b6..dc4f42a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3310,6 +3310,10 @@ textarea:focus-visible { white-space: nowrap; } +:root[data-theme='dark'] .devin-logo { + filter: invert(1); +} + .provider-logo { width: 38px; height: 38px; diff --git a/tests/devin-ui.cjs b/tests/devin-ui.cjs new file mode 100644 index 0000000..5cd0af4 --- /dev/null +++ b/tests/devin-ui.cjs @@ -0,0 +1,86 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const base = process.env.DEVIN_TEST_BASE_URL || 'http://127.0.0.1:1421'; +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + page.setDefaultTimeout(10000); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort()); + const open = query => page.goto(base + '/tests/fixtures/devin.html?' + (query || ''), { waitUntil: 'domcontentloaded' }); + const card = () => page.locator('.oauth-card').filter({ hasText: 'Devin OAuth' }); + const click = label => card().getByRole('button', { name: label, exact: true }).click(); + const calls = () => page.evaluate(() => window.devinFixture.calls); + await open(); + await card().waitFor(); + assert.equal(await page.locator('.oauth-card').count(), 6); + await click('Start Sign-In'); + await card().getByRole('textbox').waitFor(); + assert.ok((await calls()).some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'devin' && call.args.browser === 'none')); + await card().getByRole('textbox').fill('http://127.0.0.1:8317/callback?state=old&code=code'); + await click('Submit Callback'); + await page.getByText('The callback state does not match', { exact: false }).waitFor(); + assert.equal((await calls()).filter(call => call.cmd === 'submit_oauth_callback').length, 0); + await card().getByRole('textbox').fill('http://127.0.0.1:8317/callback?state=attempt-1&code=code'); + await click('Submit Callback'); + await card().getByText('Completed', { exact: true }).waitFor(); + assert.equal(await card().getByRole('textbox').count(), 0); + + await open(); + await click('Start Sign-In'); + await card().getByRole('textbox').waitFor(); + await page.evaluate(() => { window.devinFixture.holdStatus = true; }); + await page.waitForFunction(() => window.devinFixture.releaseStatus !== null); + await click('Refresh Link'); + await page.waitForFunction(() => window.devinFixture.attempts === 2); + await page.evaluate(() => window.devinFixture.releaseStatus()); + await page.waitForTimeout(150); + assert.equal(await card().getByText('Completed', { exact: true }).count(), 0); + assert.ok(await card().getByRole('textbox').isVisible()); + assert.ok((await calls()).some(call => call.cmd === 'management_request' && call.args.request.path === '/oauth-session' && call.args.request.query.state === 'attempt-1')); + await page.evaluate(() => { window.devinFixture.cancelError = true; }); + await click('Refresh Link'); + await page.getByText('Cancellation failed', { exact: false }).waitFor(); + assert.equal(await page.evaluate(() => window.devinFixture.attempts), 2); + + await open('view=easy'); + const easy = page.locator('.simple-mode-provider-card').filter({ hasText: 'Devin OAuth' }); + await easy.getByRole('button').click(); + await page.waitForFunction(() => window.devinFixture.attempts === 1); + await page.evaluate(() => { window.devinFixture.status = 'ok'; }); + await page.waitForFunction(() => document.querySelectorAll('.simple-mode-provider-card.connected').length === 1); + assert.ok((await calls()).some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'devin')); + + await open('view=files'); + await page.getByText('devin-test.json', { exact: true }).waitFor(); + await page.getByRole('button', { name: 'Fetch Quota', exact: true }).click(); + await page.getByText('75%', { exact: false }).first().waitFor(); + assert.equal(await page.locator('.devin-logo').count(), 1); + + await open('view=quota'); + await page.getByRole('button', { name: 'Fetch Quota', exact: true }).click(); + await page.getByText('Daily quota', { exact: true }).waitFor(); + assert.ok((await page.locator('.real-quota-card').innerText()).includes('Pro')); + assert.equal(await page.locator('.real-quota-track').count(), 2); + assert.equal(await page.locator('.real-quota-track span').last().evaluate(node => node.style.width), '0%'); + await page.evaluate(() => { window.devinFixture.quotaError = true; }); + await page.getByRole('button', { name: 'Refresh All', exact: true }).click(); + await page.getByText('Session expired', { exact: false }).waitFor(); + assert.equal(await page.locator('.real-quota-track').count(), 0); + + fs.mkdirSync('misc', { recursive: true }); + for (const theme of ['light', 'dark']) { + await open('theme=' + theme + '&locale=zh-CN'); + await card().waitFor(); + await page.screenshot({ path: 'misc/devin-oauth-' + theme + '.png', fullPage: true }); + const filter = await page.locator('.devin-logo').evaluate(node => getComputedStyle(node).filter); + assert.equal(filter, theme === 'dark' ? 'invert(1)' : 'none'); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + } + assert.deepEqual(errors, []); + console.log('Devin browser checks passed: OAuth, callback validation, session refresh, simplified login, auth files, quota and both themes.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/devin.test.ts b/tests/devin.test.ts new file mode 100644 index 0000000..e7b7471 --- /dev/null +++ b/tests/devin.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test'; +import { validateDevinCallback } from '../src/services/devinOAuth'; +import { readDevinQuota } from '../src/services/devinQuota'; +import { loadQuota, providerForFile, quotaRowsFor } from '../src/services/quotaService'; +import { changedOAuthAuthFileNames, snapshotAuthFiles } from '../src/services/authFiles'; +import { managementApi } from '../src/services/managementApi'; +import { loadOAuthModelSettings, saveOAuthModelSettings } from '../src/services/oauthModelSettings'; + +const liveStatus = (fields: Record) => ({ userStatus: { planStatus: fields } }); +const file = { name: 'devin-test.json', provider: 'devin', auth_index: 'devin-index' }; +const mocks: Array<{ mockRestore(): void }> = []; +afterEach(() => { mocks.splice(0).forEach((mock) => mock.mockRestore()); }); + +describe('Devin OAuth', () => { + it('accepts the complete redirect and denial callback from the current attempt', () => { + expect(validateDevinCallback(' http://127.0.0.1:8317/callback?state=current&code=abc ', 'current')).toBeUndefined(); + expect(validateDevinCallback('https://remote.example/callback?state=current&error=access_denied', 'current')).toBeUndefined(); + }); + it('rejects missing, duplicated or stale state and never manufactures a callback', () => { + for (const input of ['abc', 'code=abc', 'file:///callback?state=current&code=a', 'http://localhost/callback?code=abc', 'http://localhost/callback?state=current', 'http://localhost/callback?state=current&state=current&code=a']) { + expect(validateDevinCallback(input, 'current')).toBe('invalid'); + } + expect(validateDevinCallback('http://localhost/callback?state=old&code=a', 'current')).toBe('state_mismatch'); + expect(validateDevinCallback('http://localhost/callback?state=current&code=a')).toBe('state_mismatch'); + }); + it('recognizes Devin and Cognition files when applying defaults to newly created credentials', () => { + expect(providerForFile(file)).toBe('devin'); + expect(providerForFile({ type: 'cognition' })).toBe('devin'); + const old = { ...file, priority: 5 }; + expect(changedOAuthAuthFileNames(snapshotAuthFiles([old]), [old, { name: 'new.json', type: 'cognition' }], 'devin')).toEqual(['new.json']); + }); +}); + +describe('Devin live quota', () => { + it('preserves zero and fractional remaining percentages and Unix reset times', () => { + const result = readDevinQuota(JSON.stringify(liveStatus({ + dailyQuotaRemainingPercent: 0, weeklyQuotaRemainingPercent: '81.25', + dailyQuotaResetAtUnix: '1893456000', weeklyQuotaResetAtUnix: 1893542400, + planInfo: { planName: ' Pro ' }, planEnd: '2030-02-01T00:00:00Z', + }))); + expect(result).toMatchObject({ plan: 'Pro', subscriptionActiveUntil: '2030-02-01T00:00:00Z', windows: [ + { id: 'daily', remainingPercent: 0, resetAtMs: 1893456000000 }, + { id: 'weekly', remainingPercent: 81.25, resetAtMs: 1893542400000 }, + ] }); + expect(quotaRowsFor('devin', liveStatus({ dailyQuotaRemainingPercent: 0 }))).toEqual([ + { label: '每日额度', remainingPercent: 0, resetAtMs: undefined }, + { label: '每周额度', remainingPercent: null, resetAtMs: undefined }, + ]); + }); + it('does not turn malformed, missing or cached metadata into live quota', () => { + for (const value of [-1, 101, Infinity, true, '', ' ', '0x10', {}, null]) { + expect(readDevinQuota(liveStatus({ dailyQuotaRemainingPercent: value })).windows).toEqual([]); + } + expect(readDevinQuota({ quota: { signals: { daily_quota_remaining_percent: 100 } } }).windows).toEqual([]); + expect(readDevinQuota('invalid JSON').windows).toEqual([]); + expect(readDevinQuota(liveStatus({ dailyQuotaResetAtUnix: '1.5', weeklyQuotaResetAtUnix: '9999999999999999999' })).windows).toEqual([]); + expect(readDevinQuota(liveStatus({ weeklyQuotaResetAtUnix: 1893456000 })).windows[0].remainingPercent).toBeNull(); + }); + it('uses the selected auth index with a body placeholder and shares concurrent refreshes', async () => { + const post = spyOn(managementApi, 'post').mockResolvedValue({ status_code: 200, body: liveStatus({ dailyQuotaRemainingPercent: 75, weeklyQuotaRemainingPercent: 50, planInfo: { planName: 'Pro' } }) } as never); + const get = spyOn(managementApi, 'get').mockRejectedValue(new Error('Must not download credentials')); + mocks.push(post, get); + const first = loadQuota(file); + const second = loadQuota(file); + expect(first).toBe(second); + expect(await first).toMatchObject({ status: 'success', plan: 'Pro', rows: [{ remainingPercent: 75 }, { remainingPercent: 50 }] }); + expect(get).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + const [path, body] = post.mock.calls[0]; + expect(path).toBe('/api-call'); + expect(body).toMatchObject({ authIndex: 'devin-index', method: 'POST', url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', header: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' } }); + expect(JSON.parse((body as { data: string }).data)).toEqual({ metadata: { ideName: 'chisel', ideVersion: '3000.10.21', apiKey: '$TOKEN$', locale: 'en', os: 'darwin', extensionVersion: '3000.10.21', clientName: 'chisel' } }); + await loadQuota(file); + expect(post).toHaveBeenCalledTimes(2); + }); + it('refuses disabled credentials and incomplete identities without upstream requests', async () => { + const post = spyOn(managementApi, 'post'); + mocks.push(post); + for (const entry of [{ ...file, disabled: true }, { ...file, auth_index: undefined }, { ...file, name: '' }]) { + expect(await loadQuota(entry)).toMatchObject({ status: 'error', rows: [] }); + } + expect(post).not.toHaveBeenCalled(); + }); + it('reports upstream errors and empty successful responses', async () => { + const post = spyOn(managementApi, 'post').mockResolvedValue({ status_code: 401, body: { error: 'expired' } } as never); + mocks.push(post); + expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [], error: 'expired' }); + post.mockResolvedValue({ status_code: 200, body: liveStatus({ planInfo: { planName: 'Pro' } }) } as never); + expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [] }); + }); +}); + +it('loads and saves Devin model exclusions under its own provider channel', async () => { + const calls: unknown[] = []; + const api = { + get: async (path: string) => { + calls.push(path); + return path === '/model-definitions/devin' + ? { models: [{ id: 'devin/swe-2', display_name: 'SWE-2' }] } + : { 'oauth-excluded-models': { devin: ['devin/old'] } }; + }, + patch: async (path: string, body: Record) => { calls.push([path, body]); }, + delete: async () => {}, + }; + const settings = await loadOAuthModelSettings({ scope: 'provider', provider: 'devin', label: 'Devin' }, api); + expect(settings.models.map((model) => model.id)).toEqual(['devin/old', 'devin/swe-2']); + await saveOAuthModelSettings(settings, ['devin/swe-2'], api); + expect(calls).toContainEqual(['/oauth-excluded-models', { provider: 'devin', models: ['devin/swe-2'] }]); +}); diff --git a/tests/fixtures/devin.html b/tests/fixtures/devin.html new file mode 100644 index 0000000..3eb3489 --- /dev/null +++ b/tests/fixtures/devin.html @@ -0,0 +1,2 @@ + +Devin integration
diff --git a/tests/fixtures/devin.tsx b/tests/fixtures/devin.tsx new file mode 100644 index 0000000..07624b2 --- /dev/null +++ b/tests/fixtures/devin.tsx @@ -0,0 +1,67 @@ +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../src/i18n'; +import { OAuthLoginPage } from '../../src/pages/ManagementPages'; +import { QuotaPage } from '../../src/pages/QuotaPage'; +import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage'; +import { EasyModePage } from '../../src/pages/EasyModePage'; +import '../../src/styles.css'; + +const params = new URLSearchParams(location.search); +const view = params.get('view'); +localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en'); +localStorage.setItem('easy-cli-proxy-api.oauth-browser.v3', 'none'); +document.documentElement.dataset.theme = params.get('theme') ?? 'light'; +const file = { name: 'devin-test.json', provider: 'devin', auth_index: 'devin-index', status: 'active' }; +const fixture = { + calls: [] as Array<{ cmd: string; args: any }>, + attempts: 0, + status: 'wait', + cancelError: false, + quotaError: false, + holdStatus: false, + releaseStatus: null as null | (() => void), + files: view === 'quota' || view === 'files' ? [file] : [] as typeof file[], +}; +Object.assign(window, { devinFixture: fixture }); +mockIPC(async (cmd, args: any) => { + fixture.calls.push({ cmd, args }); + if (cmd === 'set_app_locale') return null; + if (cmd === 'list_oauth_browsers') return [{ id: 'default', label: 'System Default' }]; + if (cmd === 'start_oauth_login') { + fixture.attempts++; + fixture.status = 'wait'; + return { url: 'https://auth.devin.ai/authorize?state=attempt-' + fixture.attempts, state: 'attempt-' + fixture.attempts, opened: true }; + } + if (cmd === 'get_oauth_status') { + if (fixture.holdStatus) { + fixture.holdStatus = false; + return new Promise((resolve) => { fixture.releaseStatus = () => resolve({ status: 'ok' }); }); + } + if (fixture.status === 'ok') fixture.files = [file]; + return { status: fixture.status }; + } + if (cmd === 'submit_oauth_callback') { fixture.status = 'ok'; return null; } + if (cmd === 'open_oauth_url') return null; + if (cmd === 'management_request') { + const request = args.request; + if (request.path === '/auth-files') return { files: fixture.files }; + if (request.path === '/config') return {}; + if (request.path === '/auth-files/fields') return {}; + if (request.path === '/oauth-session') { + if (fixture.cancelError) throw new Error('Cancellation failed'); + return { status: 'ok', cancelled: true }; + } + if (request.path === '/api-call') return fixture.quotaError + ? { status_code: 401, body: { error: 'Session expired' } } + : { status_code: 200, body: { userStatus: { planStatus: { + dailyQuotaRemainingPercent: 75, weeklyQuotaRemainingPercent: 0, + dailyQuotaResetAtUnix: '1893456000', weeklyQuotaResetAtUnix: '1893542400', + planInfo: { planName: 'Pro' }, planEnd: '2030-02-01T00:00:00Z', + } } } }; + } + throw new Error('Unexpected command: ' + cmd + ' ' + JSON.stringify(args)); +}, { shouldMockEvents: true }); +createRoot(document.getElementById('root')!).render(
+ {view === 'quota' ? : view === 'files' ? : view === 'easy' ? : } +
);