10 | {/* 全局窗口拖拽区域 - 使用 JS 手动触发拖拽,解决 HTML 属性失效问题 */}
11 |
{
22 | getCurrentWindow().startDragging();
23 | }}
24 | />
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | export default Layout;
36 |
--------------------------------------------------------------------------------
/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "antigravity_tools"
3 | version = "2.0.2"
4 | description = "A Tauri App"
5 | authors = ["you"]
6 | license = "CC-BY-NC-SA-4.0"
7 | edition = "2021"
8 |
9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
10 |
11 | [lib]
12 | # The `_lib` suffix may seem redundant but it is necessary
13 | # to make the lib name unique and wouldn't conflict with the bin name.
14 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
15 | name = "antigravity_tools_lib"
16 | crate-type = ["staticlib", "cdylib", "rlib"]
17 |
18 | [build-dependencies]
19 | tauri-build = { version = "2", features = [] }
20 |
21 | [dependencies]
22 | tauri = { version = "2", features = ["tray-icon", "image-png"] }
23 | tauri-plugin-opener = "2"
24 | serde = { version = "1", features = ["derive"] }
25 | serde_json = "1"
26 | uuid = { version = "1.10", features = ["v4", "serde"] }
27 | chrono = "0.4"
28 | dirs = "5.0"
29 | reqwest = { version = "0.12", features = ["json"] }
30 | tracing = "0.1"
31 | tracing-subscriber = "0.3"
32 | rusqlite = { version = "0.32", features = ["bundled"] }
33 | base64 = "0.22"
34 | sysinfo = "0.31"
35 | tokio = { version = "1", features = ["full"] }
36 | url = "2.5.7"
37 | tauri-plugin-dialog = "2.4.2"
38 | tauri-plugin-fs = "2.4.4"
39 | image = "0.25.9"
40 | thiserror = "2.0.17"
41 |
42 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | darkMode: 'class',
8 | theme: {
9 | extend: {},
10 | },
11 | plugins: [require("daisyui")],
12 | daisyui: {
13 | themes: [
14 | {
15 | light: {
16 | "primary": "#3b82f6",
17 | "secondary": "#64748b",
18 | "accent": "#10b981",
19 | "neutral": "#1f2937",
20 | "base-100": "#ffffff",
21 | "info": "#0ea5e9",
22 | "success": "#10b981",
23 | "warning": "#f59e0b",
24 | "error": "#ef4444",
25 | },
26 | },
27 | {
28 | dark: {
29 | "primary": "#3b82f6",
30 | "secondary": "#94a3b8",
31 | "accent": "#10b981",
32 | "neutral": "#1f2937",
33 | "base-100": "#0f172a", // Slate-900
34 | "base-200": "#1e293b", // Slate-800
35 | "base-300": "#334155", // Slate-700
36 | "info": "#0ea5e9",
37 | "success": "#10b981",
38 | "warning": "#f59e0b",
39 | "error": "#ef4444",
40 | },
41 | },
42 | ],
43 | darkTheme: "dark",
44 | },
45 | }
46 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | tags:
5 | - 'v*'
6 | workflow_dispatch:
7 |
8 | jobs:
9 | release:
10 | permissions:
11 | contents: write
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | platform: [macos-latest, ubuntu-22.04, windows-latest]
16 |
17 | runs-on: ${{ matrix.platform }}
18 |
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v4
22 |
23 | - name: Install dependencies (Ubuntu only)
24 | if: matrix.platform == 'ubuntu-22.04'
25 | run: |
26 | sudo apt-get update
27 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
28 |
29 | - name: Rust setup
30 | uses: dtolnay/rust-toolchain@stable
31 | with:
32 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
33 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
34 |
35 | - name: Node.js setup
36 | uses: actions/setup-node@v4
37 | with:
38 | node-version: 20
39 | cache: 'npm'
40 |
41 | - name: Install frontend dependencies
42 | run: npm install
43 |
44 | - name: Build the app
45 | uses: tauri-apps/tauri-action@v0
46 | env:
47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48 | with:
49 | tagName: ${{ github.ref_name }}
50 | releaseName: 'Antigravity Tools ${{ github.ref_name }}'
51 | releaseBody: 'See the assets to download this version and install.'
52 | releaseDraft: true
53 | prerelease: false
54 |
--------------------------------------------------------------------------------
/src/services/accountService.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from '@tauri-apps/api/core';
2 | import { Account, QuotaData } from '../types/account';
3 |
4 | export async function listAccounts(): Promise
{
5 | return await invoke('list_accounts');
6 | }
7 |
8 | export async function getCurrentAccount(): Promise {
9 | return await invoke('get_current_account');
10 | }
11 |
12 | export async function addAccount(email: string, refreshToken: string): Promise {
13 | return await invoke('add_account', { email, refreshToken });
14 | }
15 |
16 | export async function deleteAccount(accountId: string): Promise {
17 | return await invoke('delete_account', { accountId });
18 | }
19 |
20 | export async function switchAccount(accountId: string): Promise {
21 | return await invoke('switch_account', { accountId });
22 | }
23 |
24 | export async function fetchAccountQuota(accountId: string): Promise {
25 | return await invoke('fetch_account_quota', { accountId });
26 | }
27 |
28 | export interface RefreshStats {
29 | total: number;
30 | success: number;
31 | failed: number;
32 | details: string[];
33 | }
34 |
35 | export async function refreshAllQuotas(): Promise {
36 | return await invoke('refresh_all_quotas');
37 | }
38 |
39 | // OAuth
40 | export async function startOAuthLogin(): Promise {
41 | return await invoke('start_oauth_login');
42 | }
43 |
44 | export async function cancelOAuthLogin(): Promise {
45 | return await invoke('cancel_oauth_login');
46 | }
47 |
48 | // 导入
49 | export async function importV1Accounts(): Promise {
50 | return await invoke('import_v1_accounts');
51 | }
52 |
53 | export async function importFromDb(): Promise {
54 | return await invoke('import_from_db');
55 | }
56 |
--------------------------------------------------------------------------------
/src/stores/useConfigStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { AppConfig } from '../types/config';
3 | import * as configService from '../services/configService';
4 |
5 | interface ConfigState {
6 | config: AppConfig | null;
7 | loading: boolean;
8 | error: string | null;
9 |
10 | // Actions
11 | loadConfig: () => Promise;
12 | saveConfig: (config: AppConfig) => Promise;
13 | updateTheme: (theme: string) => Promise;
14 | updateLanguage: (language: string) => Promise;
15 | }
16 |
17 | export const useConfigStore = create((set, get) => ({
18 | config: null,
19 | loading: false,
20 | error: null,
21 |
22 | loadConfig: async () => {
23 | set({ loading: true, error: null });
24 | try {
25 | const config = await configService.loadConfig();
26 | set({ config, loading: false });
27 | } catch (error) {
28 | set({ error: String(error), loading: false });
29 | }
30 | },
31 |
32 | saveConfig: async (config: AppConfig) => {
33 | set({ loading: true, error: null });
34 | try {
35 | await configService.saveConfig(config);
36 | set({ config, loading: false });
37 | } catch (error) {
38 | set({ error: String(error), loading: false });
39 | throw error;
40 | }
41 | },
42 |
43 | updateTheme: async (theme: string) => {
44 | const { config } = get();
45 | if (!config) return;
46 |
47 | const newConfig = { ...config, theme };
48 | await get().saveConfig(newConfig);
49 | },
50 |
51 | updateLanguage: async (language: string) => {
52 | const { config } = get();
53 | if (!config) return;
54 |
55 | const newConfig = { ...config, language };
56 | await get().saveConfig(newConfig);
57 | },
58 | }));
59 |
--------------------------------------------------------------------------------
/src-tauri/src/models/account.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use super::{token::TokenData, quota::QuotaData};
3 |
4 | /// 账号数据结构
5 | #[derive(Debug, Clone, Serialize, Deserialize)]
6 | pub struct Account {
7 | pub id: String,
8 | pub email: String,
9 | pub name: Option,
10 | pub token: TokenData,
11 | pub quota: Option,
12 | pub created_at: i64,
13 | pub last_used: i64,
14 | }
15 |
16 | impl Account {
17 | pub fn new(id: String, email: String, token: TokenData) -> Self {
18 | let now = chrono::Utc::now().timestamp();
19 | Self {
20 | id,
21 | email,
22 | name: None,
23 | token,
24 | quota: None,
25 | created_at: now,
26 | last_used: now,
27 | }
28 | }
29 |
30 | pub fn update_last_used(&mut self) {
31 | self.last_used = chrono::Utc::now().timestamp();
32 | }
33 |
34 | pub fn update_quota(&mut self, quota: QuotaData) {
35 | self.quota = Some(quota);
36 | }
37 | }
38 |
39 | /// 账号索引数据(accounts.json)
40 | #[derive(Debug, Clone, Serialize, Deserialize)]
41 | pub struct AccountIndex {
42 | pub version: String,
43 | pub accounts: Vec,
44 | pub current_account_id: Option,
45 | }
46 |
47 | /// 账号摘要信息
48 | #[derive(Debug, Clone, Serialize, Deserialize)]
49 | pub struct AccountSummary {
50 | pub id: String,
51 | pub email: String,
52 | pub name: Option,
53 | pub created_at: i64,
54 | pub last_used: i64,
55 | }
56 |
57 | impl AccountIndex {
58 | pub fn new() -> Self {
59 | Self {
60 | version: "2.0".to_string(),
61 | accounts: Vec::new(),
62 | current_account_id: None,
63 | }
64 | }
65 | }
66 |
67 | impl Default for AccountIndex {
68 | fn default() -> Self {
69 | Self::new()
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src-tauri/output_check_final.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
2 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
3 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
4 | [0m [0m[0m[1m[38;5;12m|[0m
5 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
6 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
9 |
10 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
11 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
12 | [0m [0m[0m[1m[38;5;12m|[0m
13 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
14 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
15 |
16 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 2 warnings
17 | [1m[96m Building[0m [=======================> ] 616/617: antigravity_tools(bin)
[K[1m[92m Finished[0m ]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\`dev` profile [unoptimized + debuginfo]]8;;\ target(s) in 1.49s
18 |
--------------------------------------------------------------------------------
/src-tauri/output_check_owned.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
2 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
3 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
4 | [0m [0m[0m[1m[38;5;12m|[0m
5 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
6 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
9 |
10 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
11 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
12 | [0m [0m[0m[1m[38;5;12m|[0m
13 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
14 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
15 |
16 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 2 warnings
17 | [1m[96m Building[0m [=======================> ] 616/617: antigravity_tools(bin)
[K[1m[92m Finished[0m ]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\`dev` profile [unoptimized + debuginfo]]8;;\ target(s) in 1.52s
18 |
--------------------------------------------------------------------------------
/src/components/common/ToastContainer.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect } from 'react';
2 | import { createPortal } from 'react-dom';
3 | import Toast, { ToastType } from './Toast';
4 |
5 | export interface ToastItem {
6 | id: string;
7 | message: string;
8 | type: ToastType;
9 | duration?: number;
10 | }
11 |
12 | let toastCounter = 0;
13 | let addToastExternal: ((message: string, type: ToastType, duration?: number) => void) | null = null;
14 |
15 | export const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
16 | if (addToastExternal) {
17 | addToastExternal(message, type, duration);
18 | } else {
19 | console.warn('ToastContainer not mounted');
20 | }
21 | };
22 |
23 | const ToastContainer = () => {
24 | const [toasts, setToasts] = useState([]);
25 |
26 | const addToast = useCallback((message: string, type: ToastType, duration?: number) => {
27 | const id = `toast-${Date.now()}-${toastCounter++}`;
28 | setToasts(prev => [...prev, { id, message, type, duration }]);
29 | }, []);
30 |
31 | const removeToast = useCallback((id: string) => {
32 | setToasts(prev => prev.filter(t => t.id !== id));
33 | }, []);
34 |
35 | useEffect(() => {
36 | addToastExternal = addToast;
37 | return () => {
38 | addToastExternal = null;
39 | };
40 | }, [addToast]);
41 |
42 | return createPortal(
43 |
44 |
45 | {toasts.map(toast => (
46 |
51 | ))}
52 |
53 |
,
54 | document.body
55 | );
56 | };
57 |
58 | export default ToastContainer;
59 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Antigravity Tools
9 |
34 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/utils/format.ts:
--------------------------------------------------------------------------------
1 | import { formatDistanceToNow } from 'date-fns';
2 | import { zhCN, enUS } from 'date-fns/locale';
3 |
4 | export function formatRelativeTime(timestamp: number, language: string = 'zh-CN'): string {
5 | const locale = language === 'zh-CN' ? zhCN : enUS;
6 | return formatDistanceToNow(new Date(timestamp * 1000), {
7 | addSuffix: true,
8 | locale,
9 | });
10 | }
11 |
12 | export function formatBytes(bytes: number): string {
13 | if (bytes === 0) return '0 Bytes';
14 |
15 | const k = 1024;
16 | const sizes = ['Bytes', 'KB', 'MB', 'GB'];
17 | const i = Math.floor(Math.log(bytes) / Math.log(k));
18 |
19 | return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
20 | }
21 |
22 | export function getQuotaColor(percentage: number): string {
23 | if (percentage >= 50) return 'success';
24 | if (percentage >= 20) return 'warning';
25 | return 'error';
26 | }
27 |
28 | export function formatTimeRemaining(dateStr: string): string {
29 | const targetDate = new Date(dateStr);
30 | const now = new Date();
31 | const diffMs = targetDate.getTime() - now.getTime();
32 |
33 | if (diffMs <= 0) return '0h 0m';
34 |
35 | const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
36 | const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
37 |
38 | if (diffHrs >= 24) {
39 | const diffDays = Math.floor(diffHrs / 24);
40 | const remainingHrs = diffHrs % 24;
41 | return `${diffDays}d ${remainingHrs}h`;
42 | }
43 |
44 | return `${diffHrs}h ${diffMins}m`;
45 | }
46 |
47 | export function formatDate(timestamp: string | number | undefined | null): string | null {
48 | if (!timestamp) return null;
49 | const date = typeof timestamp === 'number'
50 | ? new Date(timestamp * 1000)
51 | : new Date(timestamp);
52 |
53 | if (isNaN(date.getTime())) return null;
54 |
55 | return date.toLocaleString(undefined, {
56 | year: 'numeric',
57 | month: '2-digit',
58 | day: '2-digit',
59 | hour: '2-digit',
60 | minute: '2-digit',
61 | second: '2-digit',
62 | hour12: false
63 | });
64 | }
65 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* 禁止过度滚动和橡皮筋效果 */
6 | html,
7 | body {
8 | overscroll-behavior: none;
9 | height: 100%;
10 | overflow: hidden;
11 | margin: 0;
12 | padding: 0;
13 | border: none;
14 | }
15 |
16 | html {
17 | background-color: #FAFBFC;
18 | }
19 |
20 | html.dark {
21 | background-color: #1d232a;
22 | }
23 |
24 | /* 全局样式 */
25 | body {
26 | margin: 0;
27 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
28 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
29 | sans-serif;
30 | -webkit-font-smoothing: antialiased;
31 | -moz-osx-font-smoothing: grayscale;
32 | background-color: #FAFBFC;
33 | }
34 |
35 | /* Dark mode override for body strictly */
36 | .dark body {
37 | background-color: #1d232a;
38 | /* matches base-300 commonly used */
39 | }
40 |
41 | #root {
42 | width: 100%;
43 | height: 100%;
44 | overflow-y: auto;
45 | overscroll-behavior: none;
46 | }
47 |
48 | /* 移除默认的 tap 高亮 */
49 | * {
50 | -webkit-tap-highlight-color: transparent;
51 | }
52 |
53 | /* 只移除链接的默认下划线,不强制颜色 */
54 | a {
55 | text-decoration: none;
56 | }
57 |
58 | /* 滚动条优化 - 彻底隐藏但保留功能 */
59 | ::-webkit-scrollbar {
60 | width: 0px;
61 | background: transparent;
62 | }
63 |
64 | ::-webkit-scrollbar-track {
65 | background-color: transparent;
66 | }
67 |
68 | ::-webkit-scrollbar-thumb {
69 | background-color: rgba(0, 0, 0, 0.1);
70 | border-radius: 99px;
71 | border: 3px solid transparent;
72 | background-clip: content-box;
73 | transition: background-color 0.2s;
74 | }
75 |
76 | ::-webkit-scrollbar-thumb:hover {
77 | background-color: rgba(0, 0, 0, 0.3);
78 | }
79 |
80 | /* View Transitions API 主题切换动画 */
81 | ::view-transition-old(root),
82 | ::view-transition-new(root) {
83 | animation: none;
84 | mix-blend-mode: normal;
85 | }
86 |
87 | ::view-transition-old(root) {
88 | z-index: 1;
89 | }
90 |
91 | ::view-transition-new(root) {
92 | z-index: 9999;
93 | }
94 |
95 | .dark::view-transition-old(root) {
96 | z-index: 9999;
97 | }
98 |
99 | .dark::view-transition-new(root) {
100 | z-index: 1;
101 | }
--------------------------------------------------------------------------------
/src-tauri/src/modules/i18n.rs:
--------------------------------------------------------------------------------
1 | use serde_json::Value;
2 | use std::collections::HashMap;
3 |
4 | /// 托盘文本结构
5 | #[derive(Debug, Clone)]
6 | pub struct TrayTexts {
7 | pub current: String,
8 | pub quota: String,
9 | pub switch_next: String,
10 | pub refresh_current: String,
11 | pub show_window: String,
12 | pub quit: String,
13 | pub no_account: String,
14 | pub unknown_quota: String,
15 | pub forbidden: String,
16 | }
17 |
18 | /// 从 JSON 加载翻译
19 | fn load_translations(lang: &str) -> HashMap {
20 | let json_content = match lang {
21 | "en" | "en-US" => include_str!("../../../src/locales/en.json"),
22 | _ => include_str!("../../../src/locales/zh.json"),
23 | };
24 |
25 | let v: Value = serde_json::from_str(json_content)
26 | .unwrap_or_else(|_| serde_json::json!({}));
27 |
28 | let mut map = HashMap::new();
29 |
30 | if let Some(tray) = v.get("tray").and_then(|t| t.as_object()) {
31 | for (key, value) in tray {
32 | if let Some(s) = value.as_str() {
33 | map.insert(key.clone(), s.to_string());
34 | }
35 | }
36 | }
37 |
38 | map
39 | }
40 |
41 | /// 获取托盘文本(根据语言)
42 | pub fn get_tray_texts(lang: &str) -> TrayTexts {
43 | let t = load_translations(lang);
44 |
45 | TrayTexts {
46 | current: t.get("current").cloned().unwrap_or_else(|| "Current".to_string()),
47 | quota: t.get("quota").cloned().unwrap_or_else(|| "Quota".to_string()),
48 | switch_next: t.get("switch_next").cloned().unwrap_or_else(|| "Switch to Next Account".to_string()),
49 | refresh_current: t.get("refresh_current").cloned().unwrap_or_else(|| "Refresh Current Quota".to_string()),
50 | show_window: t.get("show_window").cloned().unwrap_or_else(|| "Show Main Window".to_string()),
51 | quit: t.get("quit").cloned().unwrap_or_else(|| "Quit Application".to_string()),
52 | no_account: t.get("no_account").cloned().unwrap_or_else(|| "No Account".to_string()),
53 | unknown_quota: t.get("unknown_quota").cloned().unwrap_or_else(|| "Unknown".to_string()),
54 | forbidden: t.get("forbidden").cloned().unwrap_or_else(|| "Account Forbidden".to_string()),
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/accounts/AccountGrid.tsx:
--------------------------------------------------------------------------------
1 | import { Account } from '../../types/account';
2 | import AccountCard from './AccountCard';
3 |
4 | interface AccountGridProps {
5 | accounts: Account[];
6 | selectedIds: Set;
7 | refreshingIds: Set;
8 | onToggleSelect: (id: string) => void;
9 | currentAccountId: string | null;
10 | switchingAccountId: string | null;
11 | onSwitch: (accountId: string) => void;
12 | onRefresh: (accountId: string) => void;
13 | onViewDetails: (accountId: string) => void;
14 | onExport: (accountId: string) => void;
15 | onDelete: (accountId: string) => void;
16 | }
17 |
18 | function AccountGrid({ accounts, selectedIds, refreshingIds, onToggleSelect, currentAccountId, switchingAccountId, onSwitch, onRefresh, onViewDetails, onExport, onDelete }: AccountGridProps) {
19 | if (accounts.length === 0) {
20 | return (
21 |
22 |
暂无账号
23 |
点击上方"添加账号"按钮添加第一个账号
24 |
25 | );
26 | }
27 |
28 | return (
29 |
30 | {accounts.map((account) => (
31 |
onToggleSelect(account.id)}
37 | isCurrent={account.id === currentAccountId}
38 | isSwitching={account.id === switchingAccountId}
39 | onSwitch={() => onSwitch(account.id)}
40 | onRefresh={() => onRefresh(account.id)}
41 | onViewDetails={() => onViewDetails(account.id)}
42 | onExport={() => onExport(account.id)}
43 | onDelete={() => onDelete(account.id)}
44 | />
45 | ))}
46 |
47 | );
48 | }
49 |
50 | export default AccountGrid;
51 |
--------------------------------------------------------------------------------
/src-tauri/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod models;
2 | mod modules;
3 | mod commands;
4 | mod utils;
5 | pub mod error;
6 |
7 | use modules::logger;
8 |
9 | // 测试命令
10 | #[tauri::command]
11 | fn greet(name: &str) -> String {
12 | format!("Hello, {}! You've been greeted from Rust!", name)
13 | }
14 |
15 | #[cfg_attr(mobile, tauri::mobile_entry_point)]
16 | pub fn run() {
17 | // 初始化日志
18 | logger::init_logger();
19 |
20 | tauri::Builder::default()
21 | .plugin(tauri_plugin_dialog::init())
22 | .plugin(tauri_plugin_fs::init())
23 | .plugin(tauri_plugin_opener::init())
24 | .setup(|app| {
25 | modules::tray::create_tray(app.handle())?;
26 | Ok(())
27 | })
28 | .on_window_event(|window, event| {
29 | if let tauri::WindowEvent::CloseRequested { api, .. } = event {
30 | let _ = window.hide();
31 | #[cfg(target_os = "macos")]
32 | {
33 | use tauri::Manager;
34 | window.app_handle().set_activation_policy(tauri::ActivationPolicy::Accessory).unwrap_or(());
35 | }
36 | api.prevent_close();
37 | }
38 | })
39 | .invoke_handler(tauri::generate_handler![
40 | greet,
41 | // 账号管理命令
42 | commands::list_accounts,
43 | commands::add_account,
44 | commands::delete_account,
45 | commands::switch_account,
46 | commands::get_current_account,
47 | // 配额命令
48 | commands::fetch_account_quota,
49 | commands::refresh_all_quotas,
50 | // 配置命令
51 | commands::load_config,
52 | commands::save_config,
53 | // 新增命令
54 | commands::start_oauth_login,
55 | commands::cancel_oauth_login,
56 | commands::import_v1_accounts,
57 | commands::import_from_db,
58 | commands::save_text_file,
59 | commands::clear_log_cache,
60 | commands::open_data_folder,
61 | commands::get_data_dir_path,
62 | commands::show_main_window,
63 | commands::get_antigravity_path,
64 | ])
65 | .run(tauri::generate_context!())
66 | .expect("error while running tauri application");
67 | }
68 |
--------------------------------------------------------------------------------
/src-tauri/src/modules/db.rs:
--------------------------------------------------------------------------------
1 | use rusqlite::Connection;
2 | use base64::{Engine as _, engine::general_purpose};
3 | use std::path::PathBuf;
4 | use crate::utils::protobuf;
5 |
6 | /// 获取 Antigravity 数据库路径(跨平台)
7 | pub fn get_db_path() -> Result {
8 | #[cfg(target_os = "macos")]
9 | {
10 | let home = dirs::home_dir().ok_or("无法获取 Home 目录")?;
11 | Ok(home.join("Library/Application Support/Antigravity/User/globalStorage/state.vscdb"))
12 | }
13 |
14 | #[cfg(target_os = "windows")]
15 | {
16 | let appdata = std::env::var("APPDATA")
17 | .map_err(|_| "无法获取 APPDATA 环境变量".to_string())?;
18 | Ok(PathBuf::from(appdata).join("Antigravity\\User\\globalStorage\\state.vscdb"))
19 | }
20 |
21 | #[cfg(target_os = "linux")]
22 | {
23 | let home = dirs::home_dir().ok_or("无法获取 Home 目录")?;
24 | Ok(home.join(".config/Antigravity/User/globalStorage/state.vscdb"))
25 | }
26 | }
27 |
28 | /// 注入 Token 到数据库
29 | pub fn inject_token(
30 | db_path: &PathBuf,
31 | access_token: &str,
32 | refresh_token: &str,
33 | expiry: i64,
34 | ) -> Result {
35 | // 1. 打开数据库
36 | let conn = Connection::open(db_path)
37 | .map_err(|e| format!("打开数据库失败: {}", e))?;
38 |
39 | // 2. 读取当前数据
40 | let current_data: String = conn
41 | .query_row(
42 | "SELECT value FROM ItemTable WHERE key = ?",
43 | ["jetskiStateSync.agentManagerInitState"],
44 | |row| row.get(0),
45 | )
46 | .map_err(|e| format!("读取数据失败: {}", e))?;
47 |
48 | // 3. Base64 解码
49 | let blob = general_purpose::STANDARD
50 | .decode(¤t_data)
51 | .map_err(|e| format!("Base64 解码失败: {}", e))?;
52 |
53 | // 4. 移除旧 Field 6
54 | let clean_data = protobuf::remove_field(&blob, 6)?;
55 |
56 | // 5. 创建新 Field 6
57 | let new_field = protobuf::create_oauth_field(access_token, refresh_token, expiry);
58 |
59 | // 6. 合并数据
60 | let final_data = [clean_data, new_field].concat();
61 | let final_b64 = general_purpose::STANDARD.encode(&final_data);
62 |
63 | // 7. 写入数据库
64 | conn.execute(
65 | "UPDATE ItemTable SET value = ? WHERE key = ?",
66 | [&final_b64, "jetskiStateSync.agentManagerInitState"],
67 | )
68 | .map_err(|e| format!("写入数据失败: {}", e))?;
69 |
70 | Ok(format!("Token 注入成功!\n数据库: {:?}", db_path))
71 | }
72 |
--------------------------------------------------------------------------------
/src-tauri/src/modules/logger.rs:
--------------------------------------------------------------------------------
1 | use tracing::{info, warn, error};
2 | use tracing_subscriber;
3 | use std::fs;
4 | use std::path::PathBuf;
5 | use crate::modules::account::get_data_dir;
6 |
7 | pub fn get_log_dir() -> Result {
8 | let data_dir = get_data_dir()?;
9 | let log_dir = data_dir.join("logs");
10 |
11 | if !log_dir.exists() {
12 | fs::create_dir_all(&log_dir).map_err(|e| format!("创建日志目录失败: {}", e))?;
13 | }
14 |
15 | Ok(log_dir)
16 | }
17 |
18 | /// 初始化日志系统
19 | pub fn init_logger() {
20 | tracing_subscriber::fmt()
21 | .with_target(false)
22 | .with_thread_ids(false)
23 | .with_level(true)
24 | .init();
25 |
26 | // 简单的文件日志模拟 (因缺少 tracing-appender)
27 | if let Ok(log_dir) = get_log_dir() {
28 | let log_file = log_dir.join("app.log");
29 | let _ = fs::write(log_file, format!("Log init at {}\n", chrono::Local::now()));
30 | }
31 |
32 | info!("日志系统已初始化");
33 | }
34 |
35 | /// 清理日志缓存
36 | pub fn clear_logs() -> Result<(), String> {
37 | let log_dir = get_log_dir()?;
38 | if log_dir.exists() {
39 | fs::remove_dir_all(&log_dir).map_err(|e| format!("清理日志目录失败: {}", e))?;
40 | fs::create_dir_all(&log_dir).map_err(|e| format!("重建日志目录失败: {}", e))?;
41 |
42 | // 重建后立即写入一条初始日志,确保文件存在
43 | let log_file = log_dir.join("app.log");
44 | let _ = fs::write(log_file, format!("Log cleared at {}\n", chrono::Local::now()));
45 | }
46 | Ok(())
47 | }
48 |
49 | fn append_log(level: &str, message: &str) {
50 | if let Ok(log_dir) = get_log_dir() {
51 | let log_file = log_dir.join("app.log");
52 | // 使用 append 模式打开文件,如果文件不存在则创建
53 | if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(log_file) {
54 | use std::io::Write;
55 | let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
56 | let _ = writeln!(file, "[{}] [{}] {}", time, level, message);
57 | }
58 | }
59 | }
60 |
61 | /// 记录信息日志
62 | pub fn log_info(message: &str) {
63 | info!("{}", message);
64 | append_log("INFO", message);
65 | }
66 |
67 | /// 记录警告日志
68 | pub fn log_warn(message: &str) {
69 | warn!("{}", message);
70 | append_log("WARN", message);
71 | }
72 |
73 | /// 记录错误日志
74 | pub fn log_error(message: &str) {
75 | error!("{}", message);
76 | append_log("ERROR", message);
77 | }
78 |
--------------------------------------------------------------------------------
/src-tauri/output_check.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m tauri-plugin-fs v2.4.4
2 | [1m[92m Compiling[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
3 | [1m[92m Checking[0m tauri-plugin-opener v2.5.2
4 | [1m[96m Building[0m [=======================> ] 544/550: tauri-plugin-opener, taur...
[1m[96m Building[0m [=======================> ] 545/550: tauri-plugin-fs, antigrav...
[K[1m[92m Checking[0m tauri-plugin-dialog v2.4.2
5 | [1m[96m Building[0m [=======================> ] 546/550: tauri-plugin-dialog, anti...
[1m[96m Building[0m [=======================> ] 547/550: tauri-plugin-dialog
[1m[96m Building[0m [=======================> ] 548/550: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
6 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
9 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
10 | [0m [0m[0m[1m[38;5;12m|[0m
11 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
12 |
13 | [1m[96m Building[0m [=======================> ] 548/550: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
14 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
15 | [0m [0m[0m[1m[38;5;12m|[0m
16 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
17 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
18 |
19 | [1m[96m Building[0m [=======================> ] 548/550: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 2 warnings
20 | [1m[96m Building[0m [=======================> ] 549/550: antigravity_tools(bin)
[K[1m[92m Finished[0m ]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\`dev` profile [unoptimized + debuginfo]]8;;\ target(s) in 1.68s
21 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2 |
3 | import Layout from './components/layout/Layout';
4 | import Dashboard from './pages/Dashboard';
5 | import Accounts from './pages/Accounts';
6 | import Settings from './pages/Settings';
7 | import ThemeManager from './components/common/ThemeManager';
8 | import { useEffect } from 'react';
9 | import { useConfigStore } from './stores/useConfigStore';
10 | import { useAccountStore } from './stores/useAccountStore';
11 | import { useTranslation } from 'react-i18next';
12 | import { listen } from '@tauri-apps/api/event';
13 |
14 | const router = createBrowserRouter([
15 | {
16 | path: '/',
17 | element: ,
18 | children: [
19 | {
20 | index: true,
21 | element: ,
22 | },
23 | {
24 | path: 'accounts',
25 | element: ,
26 | },
27 | {
28 | path: 'settings',
29 | element: ,
30 | },
31 | ],
32 | },
33 | ]);
34 |
35 | function App() {
36 | const { config, loadConfig } = useConfigStore();
37 | const { fetchCurrentAccount, fetchAccounts } = useAccountStore();
38 | const { i18n } = useTranslation();
39 |
40 | useEffect(() => {
41 | loadConfig();
42 | }, [loadConfig]);
43 |
44 | // Sync language from config
45 | useEffect(() => {
46 | if (config?.language) {
47 | i18n.changeLanguage(config.language);
48 | }
49 | }, [config?.language, i18n]);
50 |
51 | // Listen for tray events
52 | useEffect(() => {
53 | const unlistenPromises: Promise<() => void>[] = [];
54 |
55 | // 监听托盘切换账号事件
56 | unlistenPromises.push(
57 | listen('tray://account-switched', () => {
58 | console.log('[App] Tray account switched, refreshing...');
59 | fetchCurrentAccount();
60 | fetchAccounts();
61 | })
62 | );
63 |
64 | // 监听托盘刷新事件
65 | unlistenPromises.push(
66 | listen('tray://refresh-current', () => {
67 | console.log('[App] Tray refresh triggered, refreshing...');
68 | fetchCurrentAccount();
69 | fetchAccounts();
70 | })
71 | );
72 |
73 | // Cleanup
74 | return () => {
75 | Promise.all(unlistenPromises).then(unlisteners => {
76 | unlisteners.forEach(unlisten => unlisten());
77 | });
78 | };
79 | }, [fetchCurrentAccount, fetchAccounts]);
80 |
81 | return (
82 | <>
83 |
84 |
85 | >
86 | );
87 | }
88 |
89 | export default App;
90 |
--------------------------------------------------------------------------------
/public/tauri.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/components/common/ThemeManager.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { useEffect } from 'react';
3 | import { useConfigStore } from '../../stores/useConfigStore';
4 | import { getCurrentWindow } from '@tauri-apps/api/window';
5 |
6 | export default function ThemeManager() {
7 | const { config, loadConfig } = useConfigStore();
8 |
9 | // Load config on mount
10 | useEffect(() => {
11 | const init = async () => {
12 | await loadConfig();
13 | // Show window after a short delay to ensure React has painted
14 | setTimeout(async () => {
15 | await getCurrentWindow().show();
16 | }, 100);
17 | };
18 | init();
19 | }, [loadConfig]);
20 |
21 | // Apply theme when config changes
22 | useEffect(() => {
23 | if (!config) return;
24 |
25 | const applyTheme = async (theme: string) => {
26 | const root = document.documentElement;
27 | const isDark = theme === 'dark';
28 |
29 | // Set Tauri window background color
30 | try {
31 | const bgColor = isDark ? '#1d232a' : '#FAFBFC';
32 | await getCurrentWindow().setBackgroundColor(bgColor);
33 | } catch (e) {
34 | console.error('Failed to set window background color:', e);
35 | }
36 |
37 | // Set DaisyUI theme
38 | root.setAttribute('data-theme', theme);
39 |
40 | // Set inline style for immediate visual feedback
41 | root.style.backgroundColor = isDark ? '#1d232a' : '#FAFBFC';
42 |
43 | // Set Tailwind dark mode class
44 | if (isDark) {
45 | root.classList.add('dark');
46 | } else {
47 | root.classList.remove('dark');
48 | }
49 | };
50 |
51 | const theme = config.theme || 'system';
52 |
53 | // Sync to localStorage for early boot check
54 | localStorage.setItem('app-theme-preference', theme);
55 |
56 | if (theme === 'system') {
57 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
58 |
59 | const handleSystemChange = (e: MediaQueryListEvent | MediaQueryList) => {
60 | const systemTheme = e.matches ? 'dark' : 'light';
61 | applyTheme(systemTheme);
62 | };
63 |
64 | // Initial alignment
65 | handleSystemChange(mediaQuery);
66 |
67 | // Listen for changes
68 | mediaQuery.addEventListener('change', handleSystemChange);
69 | return () => mediaQuery.removeEventListener('change', handleSystemChange);
70 | } else {
71 | applyTheme(theme);
72 | }
73 | }, [config?.theme]);
74 |
75 | return null; // This component handles side effects only
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/common/Toast.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { CheckCircle, XCircle, Info, AlertTriangle, X } from 'lucide-react';
3 |
4 | export type ToastType = 'success' | 'error' | 'info' | 'warning';
5 |
6 | export interface ToastProps {
7 | id: string;
8 | message: string;
9 | type: ToastType;
10 | duration?: number;
11 | onClose: (id: string) => void;
12 | }
13 |
14 | const Toast = ({ id, message, type, duration = 3000, onClose }: ToastProps) => {
15 | const [isVisible, setIsVisible] = useState(false);
16 |
17 | useEffect(() => {
18 | // Exciting entrance
19 | requestAnimationFrame(() => setIsVisible(true));
20 |
21 | if (duration > 0) {
22 | const timer = setTimeout(() => {
23 | setIsVisible(false);
24 | setTimeout(() => onClose(id), 300); // Wait for transition
25 | }, duration);
26 | return () => clearTimeout(timer);
27 | }
28 | }, [duration, id, onClose]);
29 |
30 | const getIcon = () => {
31 | switch (type) {
32 | case 'success': return ;
33 | case 'error': return ;
34 | case 'warning': return ;
35 | case 'info': default: return ;
36 | }
37 | };
38 |
39 | const getStyles = () => {
40 | switch (type) {
41 | case 'success': return 'border-green-100 dark:border-green-900/30 bg-white dark:bg-base-100';
42 | case 'error': return 'border-red-100 dark:border-red-900/30 bg-white dark:bg-base-100';
43 | case 'warning': return 'border-yellow-100 dark:border-yellow-900/30 bg-white dark:bg-base-100';
44 | case 'info': default: return 'border-blue-100 dark:border-blue-900/30 bg-white dark:bg-base-100';
45 | }
46 | };
47 |
48 | return (
49 |
53 | {getIcon()}
54 |
{message}
55 |
61 |
62 | );
63 | };
64 |
65 | export default Toast;
66 |
--------------------------------------------------------------------------------
/src-tauri/output_check_final_4.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
2 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
3 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
4 | [0m [0m[0m[1m[38;5;12m|[0m
5 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
6 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
9 |
10 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
11 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
12 | [0m [0m[0m[1m[38;5;12m|[0m
13 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
14 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
15 |
16 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: variable does not need to be mutable[0m
17 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:155:24[0m
18 | [0m [0m[0m[1m[38;5;12m|[0m
19 | [0m[1m[38;5;12m155[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m...[0m[0mlet Ok(mut account) = modules::load_account[0m[0m[1m[38;5;12m...[0m
20 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m----[0m[0m[1m[33m^^^^^^^[0m
21 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m|[0m
22 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12mhelp: remove this `mut`[0m
23 | [0m [0m[0m[1m[38;5;12m|[0m
24 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default[0m
25 |
26 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 3 warnings (run `cargo fix --lib -p antigravity_tools` to apply 1 suggestion)
27 | [1m[96m Building[0m [=======================> ] 616/617: antigravity_tools(bin)
[K[1m[92m Finished[0m ]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\`dev` profile [unoptimized + debuginfo]]8;;\ target(s) in 1.44s
28 |
--------------------------------------------------------------------------------
/src/components/common/BackgroundTaskRunner.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { useConfigStore } from '../../stores/useConfigStore';
3 | import { useAccountStore } from '../../stores/useAccountStore';
4 |
5 | function BackgroundTaskRunner() {
6 | const { config } = useConfigStore();
7 | const { refreshAllQuotas, fetchCurrentAccount } = useAccountStore();
8 |
9 | // Use refs to track previous state to detect "off -> on" transitions
10 | const prevAutoRefreshRef = useRef(false);
11 | const prevAutoSyncRef = useRef(false);
12 |
13 | // Auto Refresh Quota Effect
14 | useEffect(() => {
15 | if (!config) return;
16 |
17 | let intervalId: ReturnType | null = null;
18 | const { auto_refresh, refresh_interval } = config;
19 |
20 | // Check if we just turned it on
21 | if (auto_refresh && !prevAutoRefreshRef.current) {
22 | console.log('[BackgroundTask] Auto-refresh enabled, executing immediately...');
23 | refreshAllQuotas();
24 | }
25 | prevAutoRefreshRef.current = auto_refresh;
26 |
27 | if (auto_refresh && refresh_interval > 0) {
28 | console.log(`[BackgroundTask] Starting auto-refresh quota timer: ${refresh_interval} mins`);
29 | intervalId = setInterval(() => {
30 | console.log('[BackgroundTask] Auto-refreshing all quotas...');
31 | refreshAllQuotas();
32 | }, refresh_interval * 60 * 1000);
33 | }
34 |
35 | return () => {
36 | if (intervalId) {
37 | console.log('[BackgroundTask] Clearing auto-refresh timer');
38 | clearInterval(intervalId);
39 | }
40 | };
41 | }, [config?.auto_refresh, config?.refresh_interval]);
42 |
43 | // Auto Sync Current Account Effect
44 | useEffect(() => {
45 | if (!config) return;
46 |
47 | let intervalId: ReturnType | null = null;
48 | const { auto_sync, sync_interval } = config;
49 |
50 | // Check if we just turned it on
51 | if (auto_sync && !prevAutoSyncRef.current) {
52 | console.log('[BackgroundTask] Auto-sync enabled, executing immediately...');
53 | fetchCurrentAccount();
54 | }
55 | prevAutoSyncRef.current = auto_sync;
56 |
57 | if (auto_sync && sync_interval > 0) {
58 | console.log(`[BackgroundTask] Starting auto-sync account timer: ${sync_interval} seconds`);
59 | intervalId = setInterval(() => {
60 | console.log('[BackgroundTask] Auto-syncing current account...');
61 | fetchCurrentAccount();
62 | }, sync_interval * 1000);
63 | }
64 |
65 | return () => {
66 | if (intervalId) {
67 | console.log('[BackgroundTask] Clearing auto-sync timer');
68 | clearInterval(intervalId);
69 | }
70 | };
71 | }, [config?.auto_sync, config?.sync_interval]);
72 |
73 | // Render nothing
74 | return null;
75 | }
76 |
77 | export default BackgroundTaskRunner;
78 |
--------------------------------------------------------------------------------
/src-tauri/output_check_final_3.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
2 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
3 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
4 | [0m [0m[0m[1m[38;5;12m|[0m
5 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
6 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
9 |
10 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
11 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
12 | [0m [0m[0m[1m[38;5;12m|[0m
13 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
14 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
15 |
16 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `account_id`[0m
17 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:70:40[0m
18 | [0m [0m[0m[1m[38;5;12m|[0m
19 | [0m[1m[38;5;12m70[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m...[0m[0m let Ok(Some(account_id)) = modules::get_cur[0m[0m[1m[38;5;12m...[0m
20 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^^^^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_account_id`[0m
21 |
22 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: variable does not need to be mutable[0m
23 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:153:24[0m
24 | [0m [0m[0m[1m[38;5;12m|[0m
25 | [0m[1m[38;5;12m153[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m...[0m[0mlet Ok(mut account) = modules::load_account[0m[0m[1m[38;5;12m...[0m
26 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m----[0m[0m[1m[33m^^^^^^^[0m
27 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m|[0m
28 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12mhelp: remove this `mut`[0m
29 | [0m [0m[0m[1m[38;5;12m|[0m
30 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default[0m
31 |
32 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 4 warnings (run `cargo fix --lib -p antigravity_tools` to apply 1 suggestion)
33 | [1m[96m Building[0m [=======================> ] 616/617: antigravity_tools(bin)
[K[1m[92m Finished[0m ]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\`dev` profile [unoptimized + debuginfo]]8;;\ target(s) in 0.74s
34 |
--------------------------------------------------------------------------------
/src-tauri/output_check_tray.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
2 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[38;5;9merror[E0599][0m[0m[1m: no method named `menu` found for struct `TrayIcon` in the current scope[0m
3 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:203:39[0m
4 | [0m [0m[0m[1m[38;5;12m|[0m
5 | [0m[1m[38;5;12m203[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m if let Some(menu) = tray.menu() {[0m
6 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;9m^^^^[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m[1m[38;5;14mhelp[0m[0m: there is a method `set_menu` with a similar name, but with different arguments[0m
9 | [0m [0m[0m[1m[38;5;12m--> [0m[0m/Users/lbjlaq/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.9.5/src/tray/mod.rs:512:3[0m
10 | [0m [0m[0m[1m[38;5;12m|[0m
11 | [0m[1m[38;5;12m512[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m pub fn set_menu(&self, menu: Option) -> crate::Result<()> {[0m
12 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;14m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^[0m
13 |
14 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
15 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
16 | [0m [0m[0m[1m[38;5;12m|[0m
17 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
18 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
19 | [0m [0m[0m[1m[38;5;12m|[0m
20 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
21 |
22 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
23 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
24 | [0m [0m[0m[1m[38;5;12m|[0m
25 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
26 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
27 |
28 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `account_id`[0m
29 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:70:40[0m
30 | [0m [0m[0m[1m[38;5;12m|[0m
31 | [0m[1m[38;5;12m70[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m...[0m[0m let Ok(Some(account_id)) = modules::get_cur[0m[0m[1m[38;5;12m...[0m
32 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^^^^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_account_id`[0m
33 |
34 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1mFor more information about this error, try `rustc --explain E0599`.[0m
35 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 3 warnings
36 | [1m[91merror[0m: could not compile `antigravity_tools` (lib) due to 1 previous error; 3 warnings emitted
37 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src-tauri/output_check_final_2.txt:
--------------------------------------------------------------------------------
1 | [1m[92m Checking[0m antigravity_tools v0.1.0 (/Users/lbjlaq/Desktop/antigravity_tauri/src-tauri)
2 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[38;5;9merror[E0107][0m[0m[1m: missing generics for trait `IsMenuItem`[0m
3 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:220:51[0m
4 | [0m [0m[0m[1m[38;5;12m|[0m
5 | [0m[1m[38;5;12m220[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m...[0m[0mtauri::menu::IsMenuItem> = vec![&i_u, &i_q];[0m
6 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;9m^^^^^^^^^^[0m[0m [0m[0m[1m[38;5;9mexpected 1 generic argument[0m
7 | [0m [0m[0m[1m[38;5;12m|[0m
8 | [0m[1m[38;5;10mnote[0m[0m: trait defined here, with 1 generic parameter: `R`[0m
9 | [0m [0m[0m[1m[38;5;12m--> [0m[0m/Users/lbjlaq/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.9.5/src/menu/mod.rs:722:11[0m
10 | [0m [0m[0m[1m[38;5;12m|[0m
11 | [0m[1m[38;5;12m722[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0mpub trait IsMenuItem: sealed::IsMe[0m[0m[1m[38;5;12m...[0m
12 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;10m^^^^^^^^^^[0m[0m [0m[0m[1m[38;5;12m-[0m
13 | [0m[1m[38;5;14mhelp[0m[0m: add missing generic argument[0m
14 | [0m [0m[0m[1m[38;5;12m|[0m
15 | [0m[1m[38;5;12m220[0m[0m [0m[0m[1m[38;5;12m| [0m[0m let mut items: Vec<&dyn tauri::menu::IsMenuItem[0m[0m[38;5;10m[0m[0m> = vec![&i_u, &i_q];[0m
16 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[38;5;10m+++[0m
17 |
18 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
19 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:18:13[0m
20 | [0m [0m[0m[1m[38;5;12m|[0m
21 | [0m[1m[38;5;12m18[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_los[0m[0m[1m[38;5;12m...[0m
22 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
23 | [0m [0m[0m[1m[38;5;12m|[0m
24 | [0m [0m[0m[1m[38;5;12m= [0m[0m[1mnote[0m[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default[0m
25 |
26 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `name`[0m
27 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/process.rs:188:13[0m
28 | [0m [0m[0m[1m[38;5;12m|[0m
29 | [0m[1m[38;5;12m188[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m let name = process.name().to_string_lo[0m[0m[1m[38;5;12m...[0m
30 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_name`[0m
31 |
32 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1m[33mwarning[0m[0m[1m: unused variable: `account_id`[0m
33 | [0m [0m[0m[1m[38;5;12m--> [0m[0msrc/modules/tray.rs:70:40[0m
34 | [0m [0m[0m[1m[38;5;12m|[0m
35 | [0m[1m[38;5;12m70[0m[0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[38;5;12m...[0m[0m let Ok(Some(account_id)) = modules::get_cur[0m[0m[1m[38;5;12m...[0m
36 | [0m [0m[0m[1m[38;5;12m|[0m[0m [0m[0m[1m[33m^^^^^^^^^^[0m[0m [0m[0m[1m[33mhelp: if this is intentional, prefix it with an underscore: `_account_id`[0m
37 |
38 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[0m[1mFor more information about this error, try `rustc --explain E0107`.[0m
39 | [1m[96m Building[0m [=======================> ] 615/617: antigravity_tools
[K[1m[33mwarning[0m: `antigravity_tools` (lib) generated 3 warnings
40 | [1m[91merror[0m: could not compile `antigravity_tools` (lib) due to 1 previous error; 3 warnings emitted
41 |
--------------------------------------------------------------------------------
/src/components/accounts/AccountTable.tsx:
--------------------------------------------------------------------------------
1 | import { Account } from '../../types/account';
2 | import AccountRow from './AccountRow';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | interface AccountTableProps {
6 | accounts: Account[];
7 | selectedIds: Set;
8 | refreshingIds: Set;
9 | onToggleSelect: (id: string) => void;
10 | onToggleAll: () => void;
11 | currentAccountId: string | null;
12 | switchingAccountId: string | null;
13 | onSwitch: (accountId: string) => void;
14 | onRefresh: (accountId: string) => void;
15 | onViewDetails: (accountId: string) => void;
16 | onExport: (accountId: string) => void;
17 | onDelete: (accountId: string) => void;
18 | }
19 |
20 | function AccountTable({ accounts, selectedIds, refreshingIds, onToggleSelect, onToggleAll, currentAccountId, switchingAccountId, onSwitch, onRefresh, onViewDetails, onExport, onDelete }: AccountTableProps) {
21 | const { t } = useTranslation();
22 |
23 | if (accounts.length === 0) {
24 | return (
25 |
26 |
{t('accounts.empty.title')}
27 |
{t('accounts.empty.desc')}
28 |
29 | );
30 | }
31 |
32 | return (
33 |
71 | );
72 | }
73 |
74 | export default AccountTable;
75 |
--------------------------------------------------------------------------------
/src/components/common/ModalDialog.tsx:
--------------------------------------------------------------------------------
1 | import { AlertTriangle, CheckCircle, XCircle, Info } from 'lucide-react';
2 | import { createPortal } from 'react-dom';
3 |
4 | export type ModalType = 'confirm' | 'success' | 'error' | 'info';
5 |
6 | interface ModalDialogProps {
7 | isOpen: boolean;
8 | title: string;
9 | message: string;
10 | type?: ModalType;
11 | onConfirm: () => void;
12 | onCancel?: () => void;
13 | confirmText?: string;
14 | cancelText?: string;
15 | isDestructive?: boolean;
16 | }
17 |
18 | export default function ModalDialog({
19 | isOpen,
20 | title,
21 | message,
22 | type = 'confirm',
23 | onConfirm,
24 | onCancel,
25 | confirmText = '确定',
26 | cancelText = '取消',
27 | isDestructive = false
28 | }: ModalDialogProps) {
29 | if (!isOpen) return null;
30 |
31 | const getIcon = () => {
32 | switch (type) {
33 | case 'success':
34 | return ;
35 | case 'error':
36 | return ;
37 | case 'info':
38 | return ;
39 | case 'confirm':
40 | default:
41 | return isDestructive ? : ;
42 | }
43 | };
44 |
45 | const getIconBg = () => {
46 | switch (type) {
47 | case 'success': return 'bg-green-50 dark:bg-green-900/20';
48 | case 'error': return 'bg-red-50 dark:bg-red-900/20';
49 | case 'info': return 'bg-blue-50 dark:bg-blue-900/20';
50 | case 'confirm': default: return isDestructive ? 'bg-red-50 dark:bg-red-900/20' : 'bg-blue-50 dark:bg-blue-900/20';
51 | }
52 | };
53 |
54 | const showCancel = type === 'confirm' && onCancel;
55 |
56 | return createPortal(
57 |
58 | {/* Draggable Top Region */}
59 |
60 |
61 |
62 |
63 |
64 | {getIcon()}
65 |
66 |
67 |
{title}
68 |
{message}
69 |
70 |
71 | {showCancel && (
72 |
78 | )}
79 |
88 |
89 |
90 |
91 |
92 |
,
93 | document.body
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src-tauri/src/modules/oauth_server.rs:
--------------------------------------------------------------------------------
1 | use std::io::{Read, Write};
2 | use tokio::net::TcpListener;
3 | use tokio::io::{AsyncReadExt, AsyncWriteExt};
4 | use tokio::sync::oneshot;
5 | use std::sync::{Mutex, OnceLock};
6 | use tauri::Url;
7 | use crate::modules::oauth;
8 |
9 | // 全局取消 Token 存储
10 | static CANCELLATION_TOKEN: OnceLock>>> = OnceLock::new();
11 |
12 | /// 获取取消 Token 的 Mutex
13 | fn get_cancellation_token() -> &'static Mutex