Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"core:window:allow-theme",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color",
"dialog:allow-open"
"dialog:allow-open",
"dialog:allow-save"
]
}
36 changes: 35 additions & 1 deletion src-tauri/src/management_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::{
collections::HashMap,
error::Error,
fs,
path::Path,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::LazyLock,
time::Duration,
Expand Down Expand Up @@ -127,6 +127,36 @@ pub(crate) async fn upload_auth_file(
read_management_value(response).await
}

#[tauri::command]
pub(crate) fn export_auth_file(source: String, target: String) -> Result<(), String> {
let install_dir = core_install_dir()?;
let candidate = PathBuf::from(source.trim());
// The credentials list reports paths relative to the core install directory.
let source_path = if candidate.is_absolute() {
candidate
} else {
install_dir.join(candidate)
};
if !source_path.is_file() {
return Err(format!(
"Credential file not found: {}",
path_to_string(&source_path)
));
}
let target_path = PathBuf::from(target.trim());
if target_path.as_os_str().is_empty() {
return Err("Credential export target is empty".to_string());
}
fs::copy(&source_path, &target_path).map_err(|error| {
format!(
"Failed to copy {} to {}: {error}",
path_to_string(&source_path),
path_to_string(&target_path)
)
})?;
Ok(())
}

#[tauri::command]
pub(crate) fn open_auth_files_directory(
gui_config_state: tauri::State<'_, GuiConfigState>,
Expand Down Expand Up @@ -352,6 +382,10 @@ pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result<
config.port,
current_core_tls_settings()?.enabled,
);
// Plugin panel routes and the plugin quota API are only served on the v0 mux.
if let Some(rest) = path.strip_prefix("v0/") {
return Ok(format!("{origin}/v0/{rest}"));
}
Ok(format!("{origin}/v8/management/{path}"))
}

Expand Down
1 change: 1 addition & 0 deletions src/assets/icons/mimo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/icons/plugin.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/icons/qoder.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/icons/trae.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,4 +1699,15 @@ export const jaOverrides = {
'quota.service.error.unrecognized': '上流からクォータデータが返されましたが、形式を認識できません',
'quota.service.error.codexResetOnly': 'リセットクォータを使用できるのは Codex 認証情報のみです',
'quota.service.error.missingConsumeAuthIndex': 'auth-index がないためリセットクォータを使用できません',
'authFiles.export': 'エクスポート',
'authFiles.exported': '{name} をエクスポートしました',
'authFiles.exportFailed': 'エクスポートに失敗: {error}',
'quota.plugin.pool': '全アカウント合計',
'quota.plugin.poolDetail': '{count} アカウント · 残り {remain} / {size}',
'quota.plugin.packages': 'パッケージ枠',
'quota.plugin.packagesDetail': '残り {remain} / {size} · {count} パッケージ',
'quota.plugin.regionDetail': '残り {remain} / {size}',
'quota.plugin.packCount': '{count} パッケージ',
'quota.plugin.checkin': 'チェックイン積分',
'quota.plugin.checkinDetail': '本日 +{today} · 累計 {total} · {streak} 日連続',
} satisfies Record<MessageKey, string>;
11 changes: 11 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,4 +1699,15 @@ export const en: Record<MessageKey, string> = {
'quota.service.error.unrecognized': 'Upstream returned quota data in an unrecognized format',
'quota.service.error.codexResetOnly': 'Only Codex credentials support reset credits',
'quota.service.error.missingConsumeAuthIndex': 'Missing auth-index; the reset credit cannot be used',
'authFiles.export': 'Export',
'authFiles.exported': 'Exported {name}',
'authFiles.exportFailed': 'Export failed: {error}',
'quota.plugin.pool': 'All accounts',
'quota.plugin.poolDetail': '{count} accounts · {remain} of {size} left',
'quota.plugin.packages': 'Package quota',
'quota.plugin.packagesDetail': '{remain} of {size} left · {count} packages',
'quota.plugin.regionDetail': '{remain} of {size} left',
'quota.plugin.packCount': '{count} packages',
'quota.plugin.checkin': 'Check-in credits',
'quota.plugin.checkinDetail': '+{today} today · {total} total · {streak} day streak',
};
11 changes: 11 additions & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1698,6 +1698,17 @@ export const zhCN = {
'quota.service.error.unrecognized': '上游返回了配额数据,但无法识别',
'quota.service.error.codexResetOnly': '只有 Codex 凭据支持使用重置额度',
'quota.service.error.missingConsumeAuthIndex': '缺少 auth-index,无法使用重置额度',
'authFiles.export': '导出',
'authFiles.exported': '已导出 {name}',
'authFiles.exportFailed': '导出失败:{error}',
'quota.plugin.pool': '账号池合计',
'quota.plugin.poolDetail': '{count} 个账号 · 剩余 {remain} / 共 {size}',
'quota.plugin.packages': '套餐额度',
'quota.plugin.packagesDetail': '剩余 {remain} / 共 {size} · {count} 个套餐包',
'quota.plugin.regionDetail': '剩余 {remain} / 共 {size}',
'quota.plugin.packCount': '{count} 个套餐包',
'quota.plugin.checkin': '签到积分',
'quota.plugin.checkinDetail': '今日 +{today} · 累计 {total} · 连续 {streak} 天',
} as const;

export type MessageKey = keyof typeof zhCN;
116 changes: 114 additions & 2 deletions src/pages/AuthFileManagementPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { save } from '@tauri-apps/plugin-dialog';
import { useConfirmation } from '../components/ConfirmationDialog';
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel';
Expand Down Expand Up @@ -30,9 +31,15 @@ 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 workbuddyIcon from '../assets/icons/workbuddy.png';
import traeIcon from '../assets/icons/trae.svg';
import qoderIcon from '../assets/icons/qoder.svg';
import zcodeIcon from '../assets/icons/zcode.png';
import mimoIcon from '../assets/icons/mimo.svg';
import vertexIcon from '../assets/icons/vertex.svg';
import {
formatDate,
isRecord,
managementApi,
readBoolean,
readNumber,
Expand Down Expand Up @@ -88,6 +95,86 @@ const providerIcons: Record<string, string> = {
vertex: vertexIcon,
xai: grokIcon,
devin: devinIcon,
workbuddy: workbuddyIcon,
trae: traeIcon,
qoder: qoderIcon,
zcode: zcodeIcon,
mimo: mimoIcon,
};

// Third-party exports (for example cockpit-style `workbuddy_accounts_*.json`) ship a
// JSON array of flat snake_case accounts, while CPA stores one nested camelCase file
// per credential. Split the array and reshape every entry before uploading it.
const REGION_BY_DOMAIN: Record<string, string> = {
'copilot.tencent.com': 'cn',
'codebuddy.cn': 'cn',
'www.codebuddy.cn': 'cn',
'workbuddy.ai': 'global',
'www.workbuddy.ai': 'global',
'codebuddy.ai': 'intl',
'www.codebuddy.ai': 'intl',
};

const normalizeImportedCredential = (
value: Record<string, unknown>,
fallbackProvider: string,
): Record<string, unknown> => {
if (isRecord(value.auth) || isRecord(value.account)) return value;
const accessToken = readString(value, 'accessToken', 'access_token');
if (!accessToken) return value;
const domain = readString(value, 'domain') || 'www.codebuddy.cn';
const provider = readString(value, 'provider', 'type', 'account_type') || fallbackProvider;
const rawExpiry = value.expiresAt ?? value.expires_at;
let expiresAt = 0;
if (typeof rawExpiry === 'number' && Number.isFinite(rawExpiry)) {
expiresAt = rawExpiry > 10 ** 11 ? Math.floor(rawExpiry / 1000) : Math.floor(rawExpiry);
}
return {
account: {
enterpriseId: '',
nickname: readString(value, 'nickname', 'name'),
uid: readString(value, 'uid', 'user_id'),
},
auth: {
accessToken,
refreshToken: readString(value, 'refreshToken', 'refresh_token'),
expiresAt,
domain,
region: readString(value, 'region') || REGION_BY_DOMAIN[domain] || 'cn',
},
auth_kind: 'oauth',
disabled: false,
provider,
type: provider,
};
};

const importCredentialFile = async (file: File): Promise<number> => {
let parsed: unknown = null;
try {
parsed = JSON.parse(await file.text());
} catch {
parsed = null;
}
const entries = (Array.isArray(parsed) ? parsed : [parsed]).filter(isRecord);
const alreadyCredential =
entries.length === 1 && (isRecord(entries[0].auth) || isRecord(entries[0].account));
if (parsed === null || entries.length === 0 || alreadyCredential) {
await managementApi.uploadAuthFile(file);
return 1;
}
const fallbackProvider = file.name.split(/[_.-]/)[0].toLowerCase();
let uploaded = 0;
for (const entry of entries) {
const normalized = normalizeImportedCredential(entry, fallbackProvider);
const provider =
readString(normalized, 'provider', 'type', 'account_type') || fallbackProvider || 'credential';
const uid = isRecord(normalized.account) ? readString(normalized.account, 'uid') : '';
const name = uid ? `${provider}-${uid}.json` : file.name;
await managementApi.uploadAuthFileContent(name, JSON.stringify(normalized));
uploaded += 1;
}
return uploaded;
};

const providerName = (file: AuthFile) => {
Expand Down Expand Up @@ -294,8 +381,7 @@ export function AuthFileManagementPage() {
const failures: string[] = [];
for (const file of selected) {
try {
await managementApi.uploadAuthFile(file);
uploaded += 1;
uploaded += await importCredentialFile(file);
} catch (requestError) {
failures.push(`${file.name}:${String(requestError)}`);
}
Expand All @@ -311,6 +397,31 @@ export function AuthFileManagementPage() {
}
};

const exportFile = async (file: AuthFile) => {
feedback.clearNotice();
setError('');
const name = fileName(file);
const source = readString(file, 'path');
if (!source) {
setError(t('authFiles.exportFailed', { error: t('authFiles.fileOnly') }));
return;
}
setBusy(true);
try {
const target = await save({
defaultPath: name,
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (!target) return;
await managementApi.exportAuthFile(source, target);
showNotice({ key: 'authFiles.exported', variables: { name } });
} catch (requestError) {
setError(t('authFiles.exportFailed', { error: String(requestError) }));
} finally {
setBusy(false);
}
};

const toggleStatus = async (file: AuthFile) => {
feedback.clearNotice();
setBusy(true);
Expand Down Expand Up @@ -453,6 +564,7 @@ export function AuthFileManagementPage() {
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')} aria-label={t('authFiles.copyName')}>{copied === name ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy || !isOAuthCredentialFile(file)} title={isOAuthCredentialFile(file) ? undefined : t('authFiles.fileOnly')}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button quiet" onClick={() => void exportFile(file)} disabled={busy || !readString(file, 'path')} title={t('authFiles.export')} aria-label={t('authFiles.export')}><FileDown size={15} aria-hidden="true" /></button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')} aria-label={t('common.delete')}><Trash2 size={15} aria-hidden="true" /></button>
</footer>
</article>
Expand Down
23 changes: 22 additions & 1 deletion src/pages/ManagementPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ 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 workbuddyIcon from '../assets/icons/workbuddy.png';
import traeIcon from '../assets/icons/trae.svg';
import qoderIcon from '../assets/icons/qoder.svg';
import zcodeIcon from '../assets/icons/zcode.png';
import mimoIcon from '../assets/icons/mimo.svg';
import { useI18n } from '../i18n';
import { FloatingNotice, useAppNotice } from '../appNotice';
import { oauthSubpages, type OAuthSubpage } from '../oauthNavigation';
Expand All @@ -32,7 +37,18 @@ import { QuotaPage } from './QuotaPage';
import { validateDevinCallback } from '../services/devinOAuth';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';

type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin';
type OAuthProviderId =
| 'codex'
| 'claude'
| 'antigravity'
| 'kimi'
| 'xai'
| 'devin'
| 'workbuddy'
| 'trae'
| 'qoder'
| 'zcode'
| 'mimo';
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';

type OAuthProviderState = {
Expand Down Expand Up @@ -72,6 +88,11 @@ const oauthProviders = [
{ 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 },
{ id: 'workbuddy' as const, name: 'WorkBuddy OAuth', icon: workbuddyIcon },
{ id: 'trae' as const, name: 'Trae OAuth', icon: traeIcon },
{ id: 'qoder' as const, name: 'Qoder OAuth', icon: qoderIcon },
{ id: 'zcode' as const, name: 'ZCode OAuth', icon: zcodeIcon },
{ id: 'mimo' as const, name: 'MiMo OAuth', icon: mimoIcon },
];

const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
Expand Down
4 changes: 3 additions & 1 deletion src/pages/QuotaPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 pluginIcon from '../assets/icons/plugin.svg';
import { managementApi, readBoolean, responseList } from '../services/managementApi';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
import {
Expand Down Expand Up @@ -42,9 +43,10 @@ const providerMeta: Record<QuotaProvider, { label: string; icon: string }> = {
xai: { label: 'xAI', icon: grokIcon },
devin: { label: 'Devin', icon: devinIcon },
antigravity: { label: 'Antigravity', icon: antigravityIcon },
plugin: { label: 'Plugins', icon: pluginIcon },
};

const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin'];
const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin', 'plugin'];
const REFRESH_CONCURRENCY = 4;

export function QuotaPage() {
Expand Down
6 changes: 6 additions & 0 deletions src/services/managementApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export const managementApi = {
data,
});
},
uploadAuthFileContent: async (name: string, content: string) => {
const data = Array.from(new TextEncoder().encode(content));
return invoke<ManagementJson>('upload_auth_file', { name, data });
},
exportAuthFile: (source: string, target: string) =>
invoke<void>('export_auth_file', { source, target }),
openAuthFilesDirectory: () => invoke<void>('open_auth_files_directory'),
};

Expand Down
Loading