diff --git a/README.en.md b/README.en.md index 74164d7..bc3fa7f 100644 --- a/README.en.md +++ b/README.en.md @@ -43,7 +43,7 @@ TokenMeter is a lightweight Windows desktop monitor for token usage, API costs, | Light theme | Dark theme | | --- | --- | -| ![TokenMeter light theme](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter dark theme](docs/images/token-spider-ui-v3-dark.png) | +| ![TokenMeter light theme](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter dark theme](docs/images/tokenmeter-v1.11.2-dark.png) | ## Requirements @@ -137,7 +137,7 @@ TokenMeter/ ## Version and releases -Current version: `1.11.1`. See [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) for change notes and checksums. +Current version: `1.11.2`. See [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) for change notes and checksums. ## License diff --git a/README.ja.md b/README.ja.md index 332370a..e404b51 100644 --- a/README.ja.md +++ b/README.ja.md @@ -43,7 +43,7 @@ TokenMeter は、DeepSeek と Xiaomi MiMo の Token 消費量、API コスト、 | ライトテーマ | ダークテーマ | | --- | --- | -| ![TokenMeter ライトテーマ](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter ダークテーマ](docs/images/token-spider-ui-v3-dark.png) | +| ![TokenMeter ライトテーマ](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter ダークテーマ](docs/images/tokenmeter-v1.11.2-dark.png) | ## 動作要件 @@ -129,7 +129,7 @@ TokenMeter/ ## バージョンと Release -現在のバージョン:`1.11.1`。変更履歴とチェックサムは [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) を参照してください。 +現在のバージョン:`1.11.2`。変更履歴とチェックサムは [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) を参照してください。 ## License diff --git a/README.ko.md b/README.ko.md index f251a34..2818dae 100644 --- a/README.ko.md +++ b/README.ko.md @@ -43,7 +43,7 @@ TokenMeter는 DeepSeek와 Xiaomi MiMo의 Token 소비량, API 비용, 계정 잔 | 라이트 테마 | 다크 테마 | | --- | --- | -| ![TokenMeter 라이트 테마](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 다크 테마](docs/images/token-spider-ui-v3-dark.png) | +| ![TokenMeter 라이트 테마](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 다크 테마](docs/images/tokenmeter-v1.11.2-dark.png) | ## 시스템 요구 사항 @@ -129,7 +129,7 @@ TokenMeter/ ## 버전 및 Release -현재 버전: `1.11.1`. 변경 사항과 체크섬은 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)에서 확인하세요. +현재 버전: `1.11.2`. 변경 사항과 체크섬은 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)에서 확인하세요. ## License diff --git a/README.md b/README.md index 1033663..13c2786 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ TokenMeter 是一款轻量级 Windows 桌面 AI 用量监控工具。它以类 - Codex 按接口返回的窗口时长展示当前周额度与重置时间,右侧显示近 7 天 Token 使用量;同时保留年度活动、累计/峰值 Token、按单个任务计算的最长聊天和连续使用天数。 - Codex 默认读取本机 CLI 目录;非默认位置通过只读目录选择器设置,并兼容旧版已保存的 `auth.json` 文件路径。 - DeepSeek 支持峰谷计价提示;MiMo Cookie 可通过专用 Chrome 会话获取和续期。 -- 网络异常时保留最近成功数据;历史数据缓存在本地 SQLite。 +- 网络异常时保留最近一次成功额度,并继续更新本地 Token 活动;本地统计不会覆盖远程额度更新时间。 +- 历史数据缓存在本地 SQLite;自动更新前备份 `usage.db`,分时数据按设置天数的 2 倍保护期清理并记录清理明细。 - API Key、Bearer Token 和 Cookie 保存到 Windows 凭据管理器。 - 支持迁移应用数据目录、自动更新及单实例运行。 @@ -45,7 +46,7 @@ TokenMeter 是一款轻量级 Windows 桌面 AI 用量监控工具。它以类 | 浅色主题 | 深色主题 | | --- | --- | -| ![TokenMeter 浅色主题](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主题](docs/images/token-spider-ui-v3-dark.png) | +| ![TokenMeter 浅色主题](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主题](docs/images/tokenmeter-v1.11.2-dark.png) | ## 系统要求 @@ -151,7 +152,7 @@ TokenMeter/ ## 版本与 Release -当前版本:`1.11.1`。更新记录及校验文件见 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。 +当前版本:`1.11.2`。更新记录及校验文件见 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。 2.0 的 Provider 能力边界、分批计划和已执行基础见 [TokenMeter 2.0 路线图](docs/V2_ROADMAP.md)。 diff --git a/README.zh-TW.md b/README.zh-TW.md index b8bfcf8..030547c 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -43,7 +43,7 @@ TokenMeter 是輕量級 Windows 桌面 AI Token 用量監控工具,用於查 | 淺色主題 | 深色主題 | | --- | --- | -| ![TokenMeter 淺色主題](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主題](docs/images/token-spider-ui-v3-dark.png) | +| ![TokenMeter 淺色主題](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主題](docs/images/tokenmeter-v1.11.2-dark.png) | ## 系統需求 @@ -129,7 +129,7 @@ TokenMeter/ ## 版本與 Release -目前版本:`1.11.1`。更新說明與校驗檔請見 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。 +目前版本:`1.11.2`。更新說明與校驗檔請見 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。 ## License diff --git a/app_identity.py b/app_identity.py index 32a38bd..6f48c48 100644 --- a/app_identity.py +++ b/app_identity.py @@ -4,7 +4,7 @@ APP_DISPLAY_NAME = "TokenMeter" APP_STORAGE_NAME = "TokenSpider" -APP_VERSION = "1.11.1" +APP_VERSION = "1.11.2" # Keep the legacy storage and mutex identities so upgrades retain user data, # credentials, and single-instance coordination across every public rename. diff --git a/app_update.py b/app_update.py index ca5e189..233ae80 100644 --- a/app_update.py +++ b/app_update.py @@ -564,6 +564,15 @@ def launch_installer(bundle: DownloadBundle) -> None: cleanup_paths = [str(cache_dir.relative_to(updates_root))] except ValueError as exc: raise UpdateError("更新缓存目录不在允许的清理范围内") from exc + try: + from data import history + + backup_path = history.backup_usage_database(bundle.release.version) + except Exception as exc: + config_manager.logger().exception("Pre-update usage database backup failed") + raise UpdateError("更新前备份本地 usage.db 失败,已取消更新") from exc + if backup_path is not None: + config_manager.logger().info("Pre-update usage database backup: %s", backup_path) config_manager.save_pending_update_cleanup( { "version": MANIFEST_VERSION, diff --git a/data/history.py b/data/history.py index 2fba2a7..d58bb5c 100644 --- a/data/history.py +++ b/data/history.py @@ -3,14 +3,16 @@ from __future__ import annotations import sqlite3 -from contextlib import contextmanager +from contextlib import closing, contextmanager from datetime import date, datetime, timedelta from decimal import Decimal, InvalidOperation +from pathlib import Path from typing import Any, Iterator import config_manager DB_PATH = config_manager.CONFIG_DIR / "usage.db" +MINUTE_USAGE_CLEANUP_GRACE_MULTIPLIER = 2 MINUTE_TOKEN_TYPES = ( "PROMPT_CACHE_HIT_TOKEN", @@ -129,6 +131,40 @@ def _connect() -> Iterator[sqlite3.Connection]: connection.close() +def backup_usage_database( + release_version: str, observed_at: datetime | None = None +) -> Path | None: + """Create a consistent pre-update SQLite snapshot outside the update cache.""" + + source_path = DB_PATH.resolve(strict=False) + if not source_path.is_file(): + return None + safe_version = "".join( + character if character.isalnum() or character in {".", "-", "_"} else "_" + for character in str(release_version).strip() + ) or "unknown" + timestamp = (observed_at or datetime.now()).strftime("%Y%m%d%H%M%S%f") + backup_dir = source_path.parent / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / f"usage-before-update-v{safe_version}-{timestamp}.db" + temporary_path = backup_path.with_suffix(backup_path.suffix + ".tmp") + source_uri = source_path.as_uri() + "?mode=ro" + try: + with ( + closing(sqlite3.connect(source_uri, uri=True, timeout=5)) as source, + closing(sqlite3.connect(temporary_path, timeout=5)) as target, + ): + source.backup(target) + result = target.execute("PRAGMA quick_check").fetchone() + if not result or result[0] != "ok": + raise sqlite3.DatabaseError("usage.db backup integrity check failed") + temporary_path.replace(backup_path) + except Exception: + temporary_path.unlink(missing_ok=True) + raise + return backup_path + + def needs_initial_sync(provider: str = "deepseek") -> bool: with _connect() as connection: row = connection.execute( @@ -422,28 +458,63 @@ def _minute_usage_retention_threshold(current_day: date, retention_days: int) -> return (current_day - timedelta(days=retention_days - 1)).isoformat() +def _delete_expired_minute_usage( + connection: sqlite3.Connection, + provider: str, + current_day: date, + retention_days: int, +) -> tuple[str, dict[str, int]]: + effective_days = retention_days * MINUTE_USAGE_CLEANUP_GRACE_MULTIPLIER + threshold = _minute_usage_retention_threshold(current_day, effective_days) + deleted_rows: dict[str, int] = {} + for table in ( + "minute_usage", + "minute_usage_snapshot", + "minute_cost_usage", + "minute_cost_snapshot", + ): + cursor = connection.execute( + f"DELETE FROM {table} WHERE provider = ? AND usage_date < ?", + (provider, threshold), + ) + deleted_rows[table] = max(0, int(cursor.rowcount)) + return threshold, deleted_rows + + +def _log_minute_usage_cleanup( + provider: str, + retention_days: int, + threshold: str, + deleted_rows: dict[str, int], +) -> None: + total_deleted = sum(deleted_rows.values()) + if not total_deleted: + return + details = ", ".join( + f"{table}={count}" for table, count in deleted_rows.items() + ) + config_manager.logger().info( + "Minute usage cleanup: provider=%s cutoff=%s configured_days=%s " + "effective_days=%s deleted_rows=%s (%s)", + provider, + threshold, + retention_days, + retention_days * MINUTE_USAGE_CLEANUP_GRACE_MULTIPLIER, + total_deleted, + details, + ) + + def clear_expired_minute_usage( provider: str, current_day: date, retention_days: int = 3 -) -> None: - """删除指定提供商超过保留天数的临时分时缓存。""" - threshold = _minute_usage_retention_threshold(current_day, retention_days) +) -> int: + """超过配置天数的双倍宽限期后,删除指定提供商的分时缓存。""" with _connect() as connection: - connection.execute( - "DELETE FROM minute_usage WHERE provider = ? AND usage_date < ?", - (provider, threshold), - ) - connection.execute( - "DELETE FROM minute_usage_snapshot WHERE provider = ? AND usage_date < ?", - (provider, threshold), - ) - connection.execute( - "DELETE FROM minute_cost_usage WHERE provider = ? AND usage_date < ?", - (provider, threshold), - ) - connection.execute( - "DELETE FROM minute_cost_snapshot WHERE provider = ? AND usage_date < ?", - (provider, threshold), + threshold, deleted_rows = _delete_expired_minute_usage( + connection, provider, current_day, retention_days ) + _log_minute_usage_cleanup(provider, retention_days, threshold, deleted_rows) + return sum(deleted_rows.values()) def save_estimated_minute_usage( @@ -474,22 +545,8 @@ def save_estimated_minute_usage( if not cost_cny.is_finite(): cost_cny = None with _connect() as connection: - threshold = _minute_usage_retention_threshold(usage_day, retention_days) - connection.execute( - "DELETE FROM minute_usage WHERE provider = ? AND usage_date < ?", - (provider, threshold), - ) - connection.execute( - "DELETE FROM minute_usage_snapshot WHERE provider = ? AND usage_date < ?", - (provider, threshold), - ) - connection.execute( - "DELETE FROM minute_cost_usage WHERE provider = ? AND usage_date < ?", - (provider, threshold), - ) - connection.execute( - "DELETE FROM minute_cost_snapshot WHERE provider = ? AND usage_date < ?", - (provider, threshold), + cleanup_threshold, deleted_rows = _delete_expired_minute_usage( + connection, provider, usage_day, retention_days ) previous, previous_at = _snapshot_rows(connection, provider, usage_date) if previous_at is None: @@ -535,6 +592,9 @@ def save_estimated_minute_usage( _save_estimated_minute_cost_usage( connection, provider, usage_date, cost_cny, observed_at ) + _log_minute_usage_cleanup( + provider, retention_days, cleanup_threshold, deleted_rows + ) return status diff --git a/data/store.py b/data/store.py index 78dea94..d10de05 100644 --- a/data/store.py +++ b/data/store.py @@ -418,8 +418,27 @@ def _base_snapshot(cls, provider_id: str = "") -> "TokenData": return copy.deepcopy(snapshot) if snapshot else cls() @classmethod - def fetch(cls, today: date | None = None, lightweight: bool = False) -> "TokenData": - providers = list(active_providers()) + def cached_snapshot(cls, provider_id: str) -> "TokenData | None": + """Return an isolated successful snapshot for one provider, if available.""" + with cls._cache_lock: + snapshot = cls._provider_snapshots.get(provider_id) + return copy.deepcopy(snapshot) if snapshot else None + + @classmethod + def fetch( + cls, + today: date | None = None, + lightweight: bool = False, + config: Mapping[str, Any] | None = None, + ) -> "TokenData": + # Background workers must use the configuration captured when they were + # created. Otherwise a queued task can silently switch providers before + # it starts running. + providers = ( + list(active_providers(config)) + if config is not None + else list(active_providers()) + ) if not providers: return cls( status="error", @@ -466,6 +485,8 @@ def _fetch_with_provider( per.status = "loading" per.is_stale = False successes = 0 + kept_cached_quota = False + quota_refresh_failed = False minute_rows: list[dict[str, Any]] = [] minute_cost_rows: list[dict[str, Any]] = [] minute_status = "unavailable" @@ -520,19 +541,53 @@ def _fetch_with_provider( quota, quota_error = None, FetchError( "UNKNOWN_ERROR", "订阅额度", str(exc) ) + quota_refresh_failed = bool( + quota_error + and getattr(provider, "supports_subscription_quota", False) + ) + kept_cached_quota = bool( + quota_error + and quota_error.code in {"NETWORK_ERROR", "NETWORK_TIMEOUT"} + and previous_per + and ( + previous_per.quota_windows + or previous_per.quota_metrics + or previous_per.account_plan + ) + ) + if kept_cached_quota and previous_per is not None: + # Codex 的远程额度失败时仍会返回最新本地活动。保留上次成功 + # 的远程额度,避免瞬时网络波动把卡片清空。 + per.quota_windows = copy.deepcopy(previous_per.quota_windows) + per.quota_metrics = copy.deepcopy(previous_per.quota_metrics) + per.account_label = previous_per.account_label + per.account_plan = previous_per.account_plan if quota is not None: - per.quota_windows = list(quota.windows) - per.quota_metrics = list(quota.metrics) + if not kept_cached_quota: + per.quota_windows = list(quota.windows) + per.quota_metrics = list(quota.metrics) + per.account_label = quota.account_label + per.account_plan = quota.plan per.quota_statistics = list(quota.statistics) - per.account_label = quota.account_label - per.account_plan = quota.plan data.daily_usage = [ {"date": usage_day, "tokens": tokens, "cost_cny": 0} for usage_day, tokens in quota.activity ] successes += 1 + elif kept_cached_quota: + # 没有本地活动时也继续展示上一份完整额度。 + successes += 1 if quota_error: - per.errors.append(quota_error) + if kept_cached_quota: + # 静默降级只影响界面;日志仍保留失败证据用于诊断。 + config_manager.logger().warning( + "Fetch failed: provider=%s source=%s code=%s; retained cached quota", + provider.id, + quota_error.source, + quota_error.code, + ) + else: + per.errors.append(quota_error) try: balance, balance_error = provider.fetch_balance() @@ -741,8 +796,9 @@ def _fetch_with_provider( data.minute_cost_usage_history = minute_cost_history if successes: - data.last_success_at = datetime.now() - data.last_updated = data.last_success_at.strftime("%H:%M:%S") + if not quota_refresh_failed: + data.last_success_at = datetime.now() + data.last_updated = data.last_success_at.strftime("%H:%M:%S") data.status = "partial" if per.errors else "ok" data.is_stale = bool(per.errors) with cls._cache_lock: diff --git a/docs/images/tokenmeter-v1.11.2-dark.png b/docs/images/tokenmeter-v1.11.2-dark.png new file mode 100644 index 0000000..f8d1954 Binary files /dev/null and b/docs/images/tokenmeter-v1.11.2-dark.png differ diff --git a/installer/TokenMeter.iss b/installer/TokenMeter.iss index a30d07a..ba81670 100644 --- a/installer/TokenMeter.iss +++ b/installer/TokenMeter.iss @@ -1,5 +1,5 @@ #ifndef MyAppVersion - #define MyAppVersion "1.11.1" + #define MyAppVersion "1.11.2" #endif #define MyAppName "TokenMeter" #define MyAppExeName "TokenMeter.exe" diff --git a/release-notes/v1.11.2.md b/release-notes/v1.11.2.md new file mode 100644 index 0000000..b808190 --- /dev/null +++ b/release-notes/v1.11.2.md @@ -0,0 +1,19 @@ +更新类型:Bug 修复与数据安全优化 + +### 修复 + +- 修复切换数据来源时旧请求阻塞新 Provider 展示的问题,切换后优先刷新当前 Provider,避免面板长时间停留在旧数据或空状态。 +- 修复 Codex 额度接口失败时空 `quota_windows` 覆盖最近成功额度的问题;网络失败期间保留上一份成功额度,仅继续更新本地 Token 活动。 +- 修复本地 Token 统计成功时间被误显示为远程额度更新时间的问题,远程额度时间仅在额度请求成功后更新。 + +### 优化 + +- 分时数据实际保留期限调整为设置天数的 2 倍:设置 30 天时满 60 天后清理,设置 15 天时满 30 天后清理,避免修改设置后立即丢失历史分时数据。 +- 新增分时清理审计日志,记录 Provider、清理截止日期、配置/实际保留天数及各表删除行数。 +- 自动更新启动前使用 SQLite 在线备份 `usage.db` 并执行完整性检查;备份失败时取消更新,避免升级过程中损坏或丢失本地历史数据。 + +### 验证 + +- 完整自动化测试:`278 passed, 9 subtests passed`。 +- Python 编译检查通过。 +- `git diff --check` 通过。 diff --git a/tests/test_history.py b/tests/test_history.py index cb7feac..2afa030 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -2,10 +2,11 @@ import sqlite3 import tempfile import unittest +from contextlib import closing from datetime import date, datetime, timedelta from decimal import Decimal from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch os.environ["APPDATA"] = str(Path.cwd() / ".test-appdata") @@ -112,6 +113,45 @@ def test_legacy_database_is_migrated_without_deleting_history(self): self.assertIn("minute_usage", tables) self.assertIn("minute_usage_snapshot", tables) + def test_backup_usage_database_creates_verified_pre_update_snapshot(self): + with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: + db_path = Path(directory) / "usage.db" + with patch.object(history, "DB_PATH", db_path): + history.save_usage( + [payload("2026-08-10", 12)], + [payload("2026-08-10", ".125")], + provider="deepseek", + ) + backup_path = history.backup_usage_database( + "1.12.0", datetime(2026, 8, 10, 18, 30) + ) + + self.assertIsNotNone(backup_path) + assert backup_path is not None + self.assertEqual(backup_path.parent, db_path.parent / "backups") + self.assertEqual( + backup_path.name, + "usage-before-update-v1.12.0-20260810183000000000.db", + ) + with closing(sqlite3.connect(backup_path)) as connection: + self.assertEqual(connection.execute("PRAGMA quick_check").fetchone()[0], "ok") + self.assertEqual( + connection.execute( + "SELECT SUM(token_amount) FROM daily_usage WHERE provider = 'deepseek'" + ).fetchone()[0], + 12, + ) + self.assertFalse( + backup_path.with_suffix(backup_path.suffix + ".tmp").exists() + ) + + def test_backup_usage_database_skips_missing_database(self): + with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: + db_path = Path(directory) / "usage.db" + with patch.object(history, "DB_PATH", db_path): + self.assertIsNone(history.backup_usage_database("1.12.0")) + self.assertFalse((db_path.parent / "backups").exists()) + def test_estimated_minute_usage_distributes_delta_and_is_idempotent(self): with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: with patch.object(history, "DB_PATH", Path(directory) / "usage.db"): @@ -149,7 +189,7 @@ def test_estimated_minute_usage_distributes_delta_and_is_idempotent(self): def test_minute_cleanup_keeps_daily_history_and_rolls_back_on_failure(self): with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: with patch.object(history, "DB_PATH", Path(directory) / "usage.db"): - old_day = date(2026, 7, 12) + old_day = date(2026, 7, 11) current_day = date(2026, 7, 13) totals = {token_type: 0 for token_type in history.MINUTE_TOKEN_TYPES} history.save_estimated_minute_usage( @@ -196,34 +236,58 @@ def test_minute_cleanup_keeps_daily_history_and_rolls_back_on_failure(self): history.clear_expired_minute_usage("deepseek", current_day, 1) self.assertTrue(history.minute_usage_for_day("deepseek", old_day)) - def test_minute_cleanup_keeps_configured_retention_days(self): - with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: - with patch.object(history, "DB_PATH", Path(directory) / "usage.db"): - current_day = date(2026, 7, 13) - expired_day = current_day - timedelta(days=3) - retained_day = current_day - timedelta(days=2) - totals = {token_type: 0 for token_type in history.MINUTE_TOKEN_TYPES} - for usage_day in (expired_day, retained_day): - history.save_estimated_minute_usage( - "deepseek", - usage_day, - totals, - datetime.combine(usage_day, datetime.min.time()), - retention_days=365, - ) - totals["RESPONSE_TOKEN"] += 1 - history.save_estimated_minute_usage( - "deepseek", - usage_day, - totals, - datetime.combine(usage_day, datetime.min.time()) + timedelta(minutes=1), - retention_days=365, - ) + def test_minute_cleanup_uses_double_retention_grace_and_logs_deleted_rows(self): + for retention_days in (15, 30): + with self.subTest(retention_days=retention_days): + with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: + with patch.object(history, "DB_PATH", Path(directory) / "usage.db"): + current_day = date(2026, 8, 10) + effective_days = retention_days * 2 + expired_day = current_day - timedelta(days=effective_days) + retained_day = current_day - timedelta(days=effective_days - 1) + totals = { + token_type: 0 for token_type in history.MINUTE_TOKEN_TYPES + } + for usage_day in (expired_day, retained_day): + history.save_estimated_minute_usage( + "deepseek", + usage_day, + totals, + datetime.combine(usage_day, datetime.min.time()), + retention_days=365, + ) + totals["RESPONSE_TOKEN"] += 1 + history.save_estimated_minute_usage( + "deepseek", + usage_day, + totals, + datetime.combine(usage_day, datetime.min.time()) + + timedelta(minutes=1), + retention_days=365, + ) - history.clear_expired_minute_usage("deepseek", current_day, 3) + logger = Mock() + with patch.object( + history.config_manager, "logger", return_value=logger + ): + deleted_rows = history.clear_expired_minute_usage( + "deepseek", current_day, retention_days + ) - self.assertEqual(history.minute_usage_for_day("deepseek", expired_day), []) - self.assertTrue(history.minute_usage_for_day("deepseek", retained_day)) + self.assertGreater(deleted_rows, 0) + self.assertEqual( + history.minute_usage_for_day("deepseek", expired_day), [] + ) + self.assertTrue( + history.minute_usage_for_day("deepseek", retained_day) + ) + logger.info.assert_called_once() + log_args = logger.info.call_args.args + self.assertEqual(log_args[1], "deepseek") + self.assertEqual(log_args[2], retained_day.isoformat()) + self.assertEqual(log_args[3], retention_days) + self.assertEqual(log_args[4], effective_days) + self.assertEqual(log_args[5], deleted_rows) def test_minute_usage_dates_include_snapshot_only_days_and_are_provider_scoped(self): with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: @@ -325,7 +389,7 @@ def test_estimated_minute_cost_usage_tracks_baselines_and_exact_distribution(sel def test_minute_cost_cleanup_and_transaction_are_provider_scoped(self): with tempfile.TemporaryDirectory(dir=self.temp_root()) as directory: with patch.object(history, "DB_PATH", Path(directory) / "usage.db"): - old_day = date(2026, 7, 12) + old_day = date(2026, 7, 11) current_day = date(2026, 7, 13) totals = {token_type: 0 for token_type in history.MINUTE_TOKEN_TYPES} for provider, usage_day in (("mimo", old_day), ("deepseek", old_day)): diff --git a/tests/test_qt_ui.py b/tests/test_qt_ui.py index 7ae1478..4d2d0fb 100644 --- a/tests/test_qt_ui.py +++ b/tests/test_qt_ui.py @@ -124,6 +124,18 @@ def test_panel_token_values_use_readable_units(): panel.close() +def test_panel_keeps_cached_values_visible_while_refreshing(): + data = sample_data() + data.today_cost_cny = 1.25 + panel = MainPanel() + + panel.update_data(data, loading=False, refreshing=True) + + assert panel.today_card.value.text() == "¥1.25" + assert panel.status_text.text() == "正在更新" + panel.close() + + def test_trend_uses_exactly_seven_cost_bars_with_hover_tooltip(): trend = TrendCard() trend.set_rows(sample_data().daily_usage, date.today()) @@ -1772,6 +1784,7 @@ def test_settings_exposes_minute_usage_retention_days(): ) assert window._values()["MINUTE_USAGE_CHART_TYPE"] == "bar" assert window.minute_usage_retention_days.value() == 7 + assert "2N 天" in window.minute_usage_retention_days.toolTip() window.minute_usage_retention_days.setValue(14) assert window._values()["MINUTE_USAGE_RETENTION_DAYS"] == 14 window.close() diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 2af6f3a..cb75f12 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -29,8 +29,10 @@ def widget_stub(): widget._apply_update = Mock() widget._thread_pool = Mock() widget._refresh_timer = Mock() + widget.panel = Mock() widget.tray = Mock() widget.open_settings = Mock() + widget._sync_pricing_state = Mock() widget._auth_expired_notified = False widget._auth_expired_provider_id = None widget._mimo_renewal_task = None @@ -128,14 +130,15 @@ def test_panel_and_ball_use_configured_refresh_interval(self): def test_compact_mimo_uses_lightweight_refresh(self): widget = widget_stub() - with patch("ui.qt_widget.config_manager.get") as get_config: - get_config.side_effect = lambda key, default=None: { - "ACTIVE_PROVIDER": "mimo", - }.get(key, default) + with patch( + "ui.qt_widget.config_manager.all_config", + return_value={"ACTIVE_PROVIDER": "mimo"}, + ): widget.refresh() task = widget._thread_pool.start.call_args.args[0] self.assertTrue(task._lightweight) + self.assertEqual(task._config["ACTIVE_PROVIDER"], "mimo") def test_repeated_refresh_runs_once_then_one_pending(self): widget = widget_stub() @@ -145,6 +148,62 @@ def test_repeated_refresh_runs_once_then_one_pending(self): self.assertEqual(widget._thread_pool.start.call_count, 1) self.assertTrue(widget._pending_refresh) + def test_provider_switch_starts_without_waiting_for_previous_refresh(self): + widget = widget_stub() + widget._refreshing = True + widget._pending_refresh = True + widget._request_id = 4 + loading = TokenData( + per_provider=[PerProviderData("codex", "Codex")], + status="loading", + ) + snapshot = {"ACTIVE_PROVIDER": "codex", "CODEX_HOME": ""} + + widget._prepare_scope_switch(loading, snapshot) + + self.assertEqual(widget._thread_pool.start.call_count, 1) + task = widget._thread_pool.start.call_args.args[0] + self.assertEqual(task.request_id, 5) + self.assertEqual(task._config, snapshot) + self.assertIs(widget._data, loading) + self.assertTrue(widget._refreshing) + self.assertFalse(widget._pending_refresh) + + widget._finish_refresh(4, TokenData(today_tokens=1)) + self.assertIs(widget._data, loading) + self.assertTrue(widget._refreshing) + + current = TokenData( + per_provider=[PerProviderData("codex", "Codex")], + today_tokens=2, + status="ok", + ) + widget._finish_refresh(5, current) + self.assertIs(widget._data, current) + self.assertFalse(widget._refreshing) + + def test_provider_switch_displays_cached_snapshot_during_refresh(self): + widget = widget_stub() + cached = TokenData( + per_provider=[PerProviderData("codex", "Codex")], + today_tokens=9, + status="ok", + last_success_at=datetime.now(), + ) + snapshot = {"ACTIVE_PROVIDER": "codex", "CODEX_HOME": ""} + + with ( + patch("ui.qt_widget.config_manager.get", return_value="deepseek"), + patch("ui.qt_widget.config_manager.save_config", return_value=snapshot), + patch.object(TokenData, "cached_snapshot", return_value=cached), + ): + widget._switch_provider("codex") + + self.assertIs(widget._data, cached) + self.assertEqual(widget._data.today_tokens, 9) + self.assertTrue(widget._refreshing) + self.assertEqual(widget._thread_pool.start.call_count, 1) + def test_older_request_does_not_replace_newer_data(self): widget = widget_stub() current = TokenData(balance_cny=2) @@ -153,6 +212,7 @@ def test_older_request_does_not_replace_newer_data(self): widget._request_id = 2 widget._finish_refresh(1, TokenData(balance_cny=1)) self.assertIs(widget._data, current) + self.assertTrue(widget._refreshing) def test_auth_expired_shows_one_tray_notification_until_recovery(self): widget = widget_stub() diff --git a/tests/test_store.py b/tests/test_store.py index 06af989..f7321a9 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -19,6 +19,7 @@ QuotaWindow, ) from data.store import ( + PerProviderData, TokenData, cost_breakdown_for_day, months_for_activity, @@ -101,6 +102,22 @@ def test_month_ranges(self): self.assertEqual(months[0], (7, 2026)) self.assertEqual(months[-1], (7, 2025)) + def test_cached_provider_snapshot_is_an_isolated_copy(self): + original = TokenData( + per_provider=[PerProviderData("deepseek", "DeepSeek")], + today_tokens=7, + status="ok", + ) + TokenData._provider_snapshots["deepseek"] = original + + cached = TokenData.cached_snapshot("deepseek") + + self.assertIsNotNone(cached) + self.assertIsNot(cached, original) + cached.today_tokens = 8 + self.assertEqual(original.today_tokens, 7) + self.assertIsNone(TokenData.cached_snapshot("mimo")) + def test_connection_uses_snapshot_without_touching_refresh_cache_or_history(self): provider = FakeProvider(payloads=[payload("2026-07-03", 7, ".2")]) snapshot = MappingProxyType({"ACTIVE_PROVIDER": "deepseek", "DEEPSEEK_AUTH": "draft"}) @@ -215,6 +232,93 @@ def fetch_quota(self): self.assertEqual(data.account_label, "a@example.com") self.assertEqual(data.account_plan, "pro") + def test_codex_network_failure_keeps_cached_quota_and_remote_success_time(self): + class QuotaProvider(FakeProvider): + id = "codex" + name = "Codex" + supports_daily_usage = False + supports_cost = False + supports_subscription_quota = True + + def fetch_balance(self): + return None, None + + def fetch_summary(self): + return None, None + + class SuccessfulQuotaProvider(QuotaProvider): + def fetch_quota(self): + return ProviderQuota( + windows=( + QuotaWindow( + "codex-weekly", "每周额度", 25, window_minutes=10_080 + ), + ), + metrics=(QuotaMetric("Credits", "12"),), + activity=(("2026-07-03", 1234),), + statistics=(QuotaMetric("累计 Token 数", "0.12万"),), + account_label="a@example.com", + plan="pro", + ), None + + class FailedQuotaProvider(QuotaProvider): + def fetch_quota(self): + return ProviderQuota( + activity=(("2026-07-03", 2345),), + statistics=(QuotaMetric("累计 Token 数", "0.23万"),), + ), FetchError( + "NETWORK_ERROR", "Codex 订阅额度", "无法连接 Codex 额度服务" + ) + + self.fetch_with(SuccessfulQuotaProvider()) + remote_success_at = datetime(2026, 7, 3, 10, 30) + cached = TokenData._provider_snapshots["codex"] + cached.last_success_at = remote_success_at + cached.last_updated = "10:30:00" + + data = self.fetch_with(FailedQuotaProvider()) + + self.assertEqual(data.status, "ok") + self.assertEqual(data.errors, []) + self.assertFalse(data.is_stale) + self.assertEqual(data.quota_windows[0].used_percent, 25) + self.assertEqual(data.quota_metrics[0].value, "12") + self.assertEqual(data.account_label, "a@example.com") + self.assertEqual(data.account_plan, "pro") + self.assertEqual(data.quota_statistics[0].value, "0.23万") + self.assertEqual(data.daily_usage[0]["tokens"], 2345) + self.assertEqual(data.last_success_at, remote_success_at) + self.assertEqual(data.last_updated, "10:30:00") + + def test_codex_network_failure_without_cached_quota_keeps_error_visible(self): + class FailedQuotaProvider(FakeProvider): + id = "codex" + name = "Codex" + supports_daily_usage = False + supports_cost = False + supports_subscription_quota = True + + def fetch_balance(self): + return None, None + + def fetch_summary(self): + return None, None + + def fetch_quota(self): + return ProviderQuota( + activity=(("2026-07-03", 2345),), + statistics=(QuotaMetric("累计 Token 数", "0.23万"),), + ), FetchError( + "NETWORK_ERROR", "Codex 订阅额度", "无法连接 Codex 额度服务" + ) + + data = self.fetch_with(FailedQuotaProvider()) + + self.assertEqual(data.status, "partial") + self.assertEqual([error.code for error in data.errors], ["NETWORK_ERROR"]) + self.assertEqual(data.quota_windows, []) + self.assertIsNone(data.last_success_at) + def test_lightweight_mimo_fetch_only_requests_current_month(self): provider = FakeProvider(payloads=[payload("2026-07-03", 30, ".23")]) provider.id = "mimo" diff --git a/tests/test_update.py b/tests/test_update.py index dc2e198..ddfbaee 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -140,6 +140,10 @@ def test_launch_installer_uses_silent_update_parameters_and_original_install_dir with ( patch("app_update.sys.executable", str(current_exe)), patch("app_update.config_manager.updates_dir", return_value=tmp_path / "data" / "updates"), + patch( + "data.history.backup_usage_database", + return_value=tmp_path / "data" / "backups" / "usage.db", + ) as backup_usage, patch("app_update.config_manager.save_pending_update_cleanup") as save_cleanup, patch("app_update.subprocess.Popen") as popen, ): @@ -155,6 +159,7 @@ def test_launch_installer_uses_silent_update_parameters_and_original_install_dir f"/DIR={current_exe.parent}", "/TOKENMETERUPDATE", ] + backup_usage.assert_called_once_with("1.3.0") save_cleanup.assert_called_once() @@ -176,6 +181,7 @@ def test_installer_launch_failure_keeps_current_program_and_clears_cleanup_state with ( patch("app_update.sys.executable", str(current_exe)), patch("app_update.config_manager.updates_dir", return_value=tmp_path / "data" / "updates"), + patch("data.history.backup_usage_database", return_value=None), patch("app_update.config_manager.save_pending_update_cleanup"), patch("app_update.config_manager.clear_pending_update_cleanup", clear), patch("app_update.subprocess.Popen", side_effect=OSError("blocked")), @@ -187,6 +193,32 @@ def test_installer_launch_failure_keeps_current_program_and_clears_cleanup_state clear.assert_called_once() +def test_pre_update_backup_failure_blocks_installer_launch(tmp_path): + release = _setup_release() + setup_path = tmp_path / "data" / "updates" / "v1.3.0" / release.setup_asset.name + setup_path.parent.mkdir(parents=True) + setup_path.write_bytes(b"setup") + current_exe = tmp_path / "TokenMeter" / "TokenMeter.exe" + bundle = DownloadBundle( + release=release, + setup_asset=DownloadedAsset(release.setup_asset, setup_path, "a" * 64), + cache_dir=setup_path.parent, + ) + + with ( + patch("app_update.sys.executable", str(current_exe)), + patch("app_update.config_manager.updates_dir", return_value=tmp_path / "data" / "updates"), + patch("data.history.backup_usage_database", side_effect=OSError("disk full")), + patch("app_update.config_manager.save_pending_update_cleanup") as save_cleanup, + patch("app_update.subprocess.Popen") as popen, + ): + with pytest.raises(UpdateError, match="usage.db"): + launch_installer(bundle) + + save_cleanup.assert_not_called() + popen.assert_not_called() + + def test_release_asset_selection_prefers_tokenmeter_and_requires_updater_removed(): """The old two-EXE protocol must not silently return through future refactors.""" release = _setup_release() diff --git a/ui/qt_panel.py b/ui/qt_panel.py index e21ae70..40386b3 100644 --- a/ui/qt_panel.py +++ b/ui/qt_panel.py @@ -2597,7 +2597,12 @@ def _set_annual_activity_data(self, data: TokenData) -> None: self._annual_activity_summary = summary self.activity_summary.setText(summary) - def update_data(self, data: TokenData, loading: bool = False) -> None: + def update_data( + self, + data: TokenData, + loading: bool = False, + refreshing: bool = False, + ) -> None: self._currency = str(data.currency or "CNY").upper() money = lambda value: "--" if loading else format_money(value, self._currency) tokens = lambda value: "--" if loading or value is None else compact_tokens(int(value)) @@ -2666,9 +2671,9 @@ def update_data(self, data: TokenData, loading: bool = False) -> None: self.statistics.set_quota_data(data) self.setFixedHeight(ANNUAL_PANEL_HEIGHT) self.activity_height_changed.emit(ANNUAL_PANEL_HEIGHT) - status, _color = self.status_summary(data, loading) + status, _color = self.status_summary(data, loading or refreshing) self.status_text.setText(status) - self.status_dot.set_role(self.status_role(data, loading)) + self.status_dot.set_role(self.status_role(data, loading or refreshing)) self.updated_text.setText(self.relative_update_time(data)) return @@ -2706,9 +2711,9 @@ def update_data(self, data: TokenData, loading: bool = False) -> None: self.trend.set_rows(data.daily_usage, currency=self._currency) self.statistics.set_data(data) - status, _color = self.status_summary(data, loading) + status, _color = self.status_summary(data, loading or refreshing) self.status_text.setText(status) - self.status_dot.set_role(self.status_role(data, loading)) + self.status_dot.set_role(self.status_role(data, loading or refreshing)) self.updated_text.setText(self.relative_update_time(data)) @staticmethod diff --git a/ui/qt_settings.py b/ui/qt_settings.py index dcd3ed9..115ad76 100644 --- a/ui/qt_settings.py +++ b/ui/qt_settings.py @@ -270,7 +270,9 @@ def __init__( self.minute_usage_retention_days = QSpinBox() self.minute_usage_retention_days.setRange(1, 365) self.minute_usage_retention_days.setSuffix(" 天") - self.minute_usage_retention_days.setToolTip("保留最近 N 天分时估算数据,包含当天;程序启动后自动清理更早数据") + self.minute_usage_retention_days.setToolTip( + "界面展示最近 N 天分时估算数据;本地数据保留双倍宽限期,超过 2N 天后才自动清理" + ) runtime_form.addRow("分时数据保存天数", self.minute_usage_retention_days) self.theme_combo = QComboBox() self.theme_combo.addItem("跟随系统", "system") diff --git a/ui/qt_widget.py b/ui/qt_widget.py index d9a598e..2b61b4f 100644 --- a/ui/qt_widget.py +++ b/ui/qt_widget.py @@ -54,15 +54,21 @@ class FetchSignals(QObject): class FetchTask(QRunnable): - def __init__(self, request_id: int, lightweight: bool = False): + def __init__( + self, + request_id: int, + config: dict[str, object], + lightweight: bool = False, + ): super().__init__() self.request_id = request_id + self._config = dict(config) self._lightweight = lightweight self.signals = FetchSignals() @Slot() def run(self) -> None: - result = _fetch_tokens_safely(self._lightweight) + result = _fetch_tokens_safely(self._config, self._lightweight) self.signals.finished.emit(self.request_id, result) @@ -121,15 +127,33 @@ def run(self) -> None: ) -def _fetch_tokens_safely(lightweight: bool = False) -> TokenData: - """Fetch token data from the active provider and keep the worker thread +def _fetch_tokens_safely( + config: dict[str, object], lightweight: bool = False +) -> TokenData: + """Fetch token data from a captured provider and keep the worker thread from dying if a provider or config error is raised.""" try: - return TokenData.fetch(lightweight=lightweight) + return TokenData.fetch(lightweight=lightweight, config=config) except Exception: config_manager.logger().exception("Background refresh failed") - data = TokenData(status="error") + provider_id = str(config.get("ACTIVE_PROVIDER", "")).strip().lower() + provider_cls = PROVIDERS.get(provider_id) + per_provider = [] + if provider_cls is not None: + per_provider.append( + PerProviderData( + provider_id, + provider_cls.name, + currency=provider_cls.default_currency, + status="error", + ) + ) + data = TokenData( + currency=provider_cls.default_currency if provider_cls is not None else "CNY", + per_provider=per_provider, + status="error", + ) data.last_attempt_at = __import__("datetime").datetime.now() data.errors.append( FetchError("UNKNOWN_ERROR", "后台刷新", "刷新数据时发生未知错误") @@ -379,7 +403,8 @@ def expand_panel(self) -> None: self.raise_() self.activateWindow() self.panel.setFocus(Qt.FocusReason.OtherFocusReason) - self.panel.update_data(self._data, self._refreshing) + loading = self._refreshing and self._data.last_success_at is None + self.panel.update_data(self._data, loading, self._refreshing) self.refresh() finally: self._transitioning = False @@ -760,15 +785,18 @@ def _switch_provider(self, provider_id: str) -> None: if not provider_id or provider_id == config_manager.get("ACTIVE_PROVIDER", ""): return try: - config_manager.save_config({"ACTIVE_PROVIDER": provider_id}) + config_snapshot = config_manager.save_config({"ACTIVE_PROVIDER": provider_id}) except Exception: config_manager.logger().exception("Quick provider switch failed") - self.panel.update_data(self._data, self._refreshing) + loading = self._refreshing and self._data.last_success_at is None + self.panel.update_data(self._data, loading, self._refreshing) return self._sync_pricing_state(notify_transition=False) provider_cls = PROVIDERS[provider_id] + cached = TokenData.cached_snapshot(provider_id) self._prepare_scope_switch( - TokenData( + cached + or TokenData( currency=provider_cls.default_currency, per_provider=[ PerProviderData( @@ -779,31 +807,49 @@ def _switch_provider(self, provider_id: str) -> None: ) ], status="loading", - ) + ), + config_snapshot, ) - def _prepare_scope_switch(self, data: TokenData) -> None: - with self._refresh_lock: - if self._refreshing: - # The in-flight result belongs to the previous provider. - # Invalidate its request id so it cannot briefly relabel stale data. - self._request_id += 1 + def _prepare_scope_switch( + self, data: TokenData, config_snapshot: dict[str, object] + ) -> None: self._data = data - self._apply_update() - self.refresh() + # A provider switch is allowed to supersede an in-flight refresh. The + # older worker can finish in the background, but its request id prevents + # it from changing either the data or the new loading state. + self.refresh(force=True, config_snapshot=config_snapshot) - def refresh(self) -> None: + def refresh( + self, + *, + force: bool = False, + config_snapshot: dict[str, object] | None = None, + ) -> None: + captured_config = dict( + config_snapshot + if config_snapshot is not None + else config_manager.all_config() + ) + provider_id = str(captured_config.get("ACTIVE_PROVIDER", "")).strip().lower() with self._refresh_lock: if self._closed: return - if self._refreshing: + if self._refreshing and not force: self._pending_refresh = True return + if force: + # Pending work belongs to the provider scope being replaced. + self._pending_refresh = False self._refreshing = True self._request_id += 1 request_id = self._request_id self._apply_update() - task = FetchTask(request_id, lightweight=self._uses_lightweight_mimo_refresh()) + task = FetchTask( + request_id, + captured_config, + lightweight=self._uses_lightweight_mimo_refresh(provider_id), + ) task.signals.finished.connect(self._finish_refresh) self._thread_pool.start(task) @@ -813,9 +859,10 @@ def _finish_refresh(self, request_id: int, result: TokenData) -> None: with self._refresh_lock: if self._closed: return - if request_id == self._request_id: - self._data = result - has_current_result = True + if request_id != self._request_id: + return + self._data = result + has_current_result = True self._refreshing = False pending = self._pending_refresh self._pending_refresh = False @@ -969,7 +1016,7 @@ def _apply_update(self) -> None: ) self.panel.set_refreshing(self._refreshing) if self._expanded: - self.panel.update_data(self._data, loading) + self.panel.update_data(self._data, loading, self._refreshing) def _periodic_refresh(self) -> None: self.refresh() @@ -1019,11 +1066,11 @@ def _sync_pricing_state(self, notify_transition: bool) -> None: ) self._pricing_timer.start(delay_ms) - def _uses_lightweight_mimo_refresh(self) -> bool: - return ( - not self._expanded - and str(config_manager.get("ACTIVE_PROVIDER", "")).strip().lower() == "mimo" - ) + def _uses_lightweight_mimo_refresh(self, provider_id: str | None = None) -> bool: + selected = provider_id or str( + config_manager.get("ACTIVE_PROVIDER", "") + ).strip().lower() + return not self._expanded and selected == "mimo" def _reschedule_refresh(self) -> None: configured = int(config_manager.get("REFRESH_INTERVAL", 60_000)) diff --git a/updater_version_info.txt b/updater_version_info.txt index 688e11f..791cd86 100644 --- a/updater_version_info.txt +++ b/updater_version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(1, 11, 1, 0), - prodvers=(1, 11, 1, 0), + filevers=(1, 11, 2, 0), + prodvers=(1, 11, 2, 0), mask=0x3f, flags=0x0, OS=0x40004, @@ -15,11 +15,11 @@ VSVersionInfo( '040904B0', [ StringStruct('FileDescription', 'TokenMeter standalone updater'), - StringStruct('FileVersion', '1.11.1'), + StringStruct('FileVersion', '1.11.2'), StringStruct('InternalName', 'TokenMeterUpdater'), StringStruct('OriginalFilename', 'TokenMeterUpdater.exe'), StringStruct('ProductName', 'TokenMeterUpdater'), - StringStruct('ProductVersion', '1.11.1') + StringStruct('ProductVersion', '1.11.2') ] ) ]), diff --git a/version_info.txt b/version_info.txt index c9f5105..ccc253d 100644 --- a/version_info.txt +++ b/version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(1, 11, 1, 0), - prodvers=(1, 11, 1, 0), + filevers=(1, 11, 2, 0), + prodvers=(1, 11, 2, 0), mask=0x3f, flags=0x0, OS=0x40004, @@ -15,11 +15,11 @@ VSVersionInfo( '040904B0', [ StringStruct('FileDescription', 'TokenMeter API usage monitor'), - StringStruct('FileVersion', '1.11.1'), + StringStruct('FileVersion', '1.11.2'), StringStruct('InternalName', 'TokenMeter'), StringStruct('OriginalFilename', 'TokenMeter.exe'), StringStruct('ProductName', 'TokenMeter'), - StringStruct('ProductVersion', '1.11.1') + StringStruct('ProductVersion', '1.11.2') ] ) ]),