From b21c04927640c761a0c4839603f61b39a01e41ed Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 18:54:22 +0800 Subject: [PATCH 01/26] feat: add cross-platform source foundation --- README.md | 10 + README_CN.md | 10 + cc_stats/cli.py | 121 ++-- cc_stats/exporter.py | 18 +- cc_stats/parser.py | 70 ++- cc_stats/reporter.py | 17 +- cc_stats/sources.py | 202 ++++++ cc_stats/webhook.py | 13 +- cc_stats_web/server.py | 224 +++---- cc_stats_web/web/index.html | 28 +- ...6-09-cross-platform-core-api-foundation.md | 589 ++++++++++++++++++ tests/test_sources.py | 178 ++++++ tests/test_web_server.py | 211 ++++++- 13 files changed, 1386 insertions(+), 305 deletions(-) create mode 100644 cc_stats/sources.py create mode 100644 docs/superpowers/plans/2026-06-09-cross-platform-core-api-foundation.md create mode 100644 tests/test_sources.py diff --git a/README.md b/README.md index 389ee87..b22c228 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,16 @@ All data is read from local files. Nothing is sent over the network. | Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | | Git Changes | `git log --numstat` in project directory | +### Path Overrides (Cross-Platform / Testing) + +Set these environment variables to read source data from custom locations. Use paths visible to the shell or environment where `cc-stats` runs. + +| Variable | Purpose | +|----------|---------| +| `CC_STATS_CLAUDE_PROJECTS_DIR` | Claude Code project log directory | +| `CC_STATS_CODEX_HOME` | Codex home; `sessions/` is read below it | +| `CC_STATS_GEMINI_HOME` | Gemini home; `tmp/*/chats/` is read below it | + --- ## Acknowledgments diff --git a/README_CN.md b/README_CN.md index efe16e2..32b7aa9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -244,6 +244,16 @@ cc-stats-app # 启动 macOS 状态栏应用 | Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | | Git 变更 | 项目目录的 `git log --numstat` | +### 路径覆盖(跨平台 / 测试) + +设置以下环境变量可从自定义位置读取源数据。请使用运行 `cc-stats` 的 shell 或环境可访问的路径。 + +| 变量 | 作用 | +|------|------| +| `CC_STATS_CLAUDE_PROJECTS_DIR` | Claude Code 项目日志目录 | +| `CC_STATS_CODEX_HOME` | Codex home;读取其下的 `sessions/` | +| `CC_STATS_GEMINI_HOME` | Gemini home;读取其下的 `tmp/*/chats/` | + --- ## 致谢 diff --git a/cc_stats/cli.py b/cc_stats/cli.py index 5342117..687ff18 100644 --- a/cc_stats/cli.py +++ b/cc_stats/cli.py @@ -11,21 +11,19 @@ from . import __version__ from .analyzer import SessionStats, TokenUsage, analyze_session, merge_stats from .formatter import format_skill_stats, format_stats -from .parser import ( - _claude_session_entry_files, - find_codex_sessions, - find_codex_sessions_by_keyword, - find_gemini_sessions, - find_gemini_sessions_by_keyword, - find_sessions, - find_sessions_by_keyword, - parse_session_file, +from .parser import _claude_session_entry_files +from .sources import ( + SourceKind, + collect_session_files, + collect_session_files_by_keyword, + list_projects, + parse_file, ) def _parse_session(path: Path): """根据文件类型选择解析器""" - return parse_session_file(path) + return parse_file(path) def _parse_time_arg(value: str, *, as_end_of_day: bool = False) -> datetime: @@ -298,66 +296,31 @@ def _compare_projects(args) -> None: def _list_projects() -> None: """列出所有已知项目(Claude + Codex + Gemini)""" - has_any = False - - # Claude 项目 - claude_projects = Path.home() / ".claude" / "projects" - if claude_projects.exists(): - print("\n可用项目 (Claude Code):") - print("─" * 60) - for proj in sorted(claude_projects.iterdir()): - if not proj.is_dir(): - continue - jsonl_files = _claude_session_entry_files(proj) - if not jsonl_files: - continue - display_name = _resolve_project_name(proj, jsonl_files) - print(f" {display_name} ({len(jsonl_files)} 个会话)") - has_any = True - - # Codex 项目 - codex_sessions = find_codex_sessions() - if codex_sessions: - from collections import defaultdict - codex_by_dir: dict[str, list[Path]] = defaultdict(list) - for cf in codex_sessions: - try: - session = _parse_session(cf) - key = session.project_path or "Unknown" - except Exception: - key = "Unknown" - codex_by_dir[key].append(cf) - - print("\n可用项目 (Codex):") - print("─" * 60) - for name, files in sorted(codex_by_dir.items()): - display = Path(name).name if "/" in name else name - print(f" {display} ({len(files)} 个会话)") - has_any = True - - # Gemini 项目 - gemini_sessions = find_gemini_sessions() - if gemini_sessions: - # 按项目目录分组 - from collections import defaultdict - gemini_by_dir: dict[str, list[Path]] = defaultdict(list) - for gf in gemini_sessions: - try: - session = _parse_session(gf) - key = session.project_path or gf.parent.parent.name - except Exception: - key = gf.parent.parent.name - gemini_by_dir[key].append(gf) + projects = list_projects() + if not projects: + print("未找到项目数据") + print() + return - print("\n可用项目 (Gemini CLI):") + labels = { + SourceKind.CLAUDE: "Claude Code", + SourceKind.CODEX: "Codex", + SourceKind.GEMINI: "Gemini CLI", + } + by_source: dict[SourceKind, list] = {} + for project in projects: + by_source.setdefault(project.source, []).append(project) + + for source in (SourceKind.CLAUDE, SourceKind.CODEX, SourceKind.GEMINI): + items = by_source.get(source, []) + if not items: + continue + print(f"\n可用项目 ({labels[source]}):") print("─" * 60) - for name, files in sorted(gemini_by_dir.items()): - display = Path(name).name if "/" in name else name - print(f" {display} ({len(files)} 个会话)") - has_any = True - - if not has_any: - print("未找到项目数据") + for project in items: + display_name = project.display_name + display = Path(display_name).name if "/" in display_name or "\\" in display_name else display_name + print(f" {display} ({project.session_count} 个会话)") print() @@ -394,9 +357,7 @@ def _show_rate_limit(args) -> None: from .rate_limiter import analyze_rate_limit # 收集所有会话文件(Claude + Codex + Gemini) - session_files: list[Path] = find_sessions() - session_files.extend(find_codex_sessions()) - session_files.extend(find_gemini_sessions()) + session_files: list[Path] = collect_session_files() if not session_files: print("未找到会话文件。", file=sys.stderr) @@ -446,9 +407,7 @@ def _show_git_integration(args) -> None: sys.exit(1) # 收集所有会话文件 - session_files: list[Path] = find_sessions() - session_files.extend(find_codex_sessions()) - session_files.extend(find_gemini_sessions()) + session_files: list[Path] = collect_session_files() if not session_files: import sys @@ -702,24 +661,18 @@ def main(argv: list[str] | None = None) -> None: if p.is_file() and p.suffix in (".jsonl", ".json"): session_files = [p] elif p.is_dir(): - session_files = find_sessions(p) - session_files.extend(find_codex_sessions(p)) + session_files = collect_session_files(project_dir=p) if not session_files: # 作为关键词模糊搜索(Claude + Codex + Gemini) - session_files = find_sessions_by_keyword(args.path) - session_files.extend(find_codex_sessions_by_keyword(args.path)) - session_files.extend(find_gemini_sessions_by_keyword(args.path)) + session_files = collect_session_files_by_keyword(args.path) if not session_files: print(f"找不到: {args.path}", file=sys.stderr) sys.exit(1) elif args.all: - session_files = find_sessions() - session_files.extend(find_codex_sessions()) - session_files.extend(find_gemini_sessions()) + session_files = collect_session_files() else: # 默认:当前目录 - session_files = find_sessions(Path.cwd()) - session_files.extend(find_codex_sessions(Path.cwd())) + session_files = collect_session_files(project_dir=Path.cwd()) # 去重(保留原顺序) session_files = list(dict.fromkeys(session_files)) diff --git a/cc_stats/exporter.py b/cc_stats/exporter.py index d4f6dd5..7d9da3f 100644 --- a/cc_stats/exporter.py +++ b/cc_stats/exporter.py @@ -5,14 +5,8 @@ from datetime import datetime, timezone from pathlib import Path -from .parser import ( - Message, - Session, - find_codex_sessions, - find_gemini_sessions, - find_sessions, - parse_session_file, -) +from .parser import Message, Session +from .sources import collect_session_files, parse_file def _extract_text(content) -> str: @@ -121,9 +115,7 @@ def find_and_export(keyword: str, output: str | None = None, include_tools: 是否包含工具调用 """ # 搜索所有会话(Claude + Codex + Gemini) - all_files: list[Path] = list(find_sessions()) - all_files.extend(find_codex_sessions()) - all_files.extend(find_gemini_sessions()) + all_files: list[Path] = collect_session_files() # 先按 session ID 前缀匹配 matched = None @@ -136,7 +128,7 @@ def find_and_export(keyword: str, output: str | None = None, if not matched: for f in sorted(all_files, key=lambda p: p.stat().st_mtime, reverse=True): try: - session = parse_session_file(f) + session = parse_file(f) for msg in session.messages: text = _extract_text(msg.content) if keyword.lower() in text.lower(): @@ -150,7 +142,7 @@ def find_and_export(keyword: str, output: str | None = None, if not matched: return None - session = parse_session_file(matched) + session = parse_file(matched) md = export_session(session, include_tools=include_tools) if output: diff --git a/cc_stats/parser.py b/cc_stats/parser.py index df77b3a..6156d9a 100644 --- a/cc_stats/parser.py +++ b/cc_stats/parser.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -164,7 +165,15 @@ def _path_to_dirname(path: Path) -> str: 例如 /Users/foo/bar → -Users-foo-bar """ - return str(path.resolve()).replace("/", "-") + return str(path.resolve()).replace("\\", "-").replace("/", "-") + + +def _normalized_project_path(path: Path | str) -> str: + try: + resolved = str(Path(path).expanduser().resolve()) + except OSError: + resolved = str(Path(path).expanduser()) + return os.path.normcase(resolved) def _is_subagent_file(path: Path) -> bool: @@ -203,12 +212,16 @@ def _claude_session_entry_files(project_path: Path) -> list[Path]: return top_level + orphan_subagents -def find_sessions(project_dir: Path | None = None) -> list[Path]: +def find_sessions( + project_dir: Path | None = None, + *, + projects_dir: Path | None = None, +) -> list[Path]: """查找 ~/.claude/projects/ 下所有 JSONL 会话文件 如果指定 project_dir,只返回匹配的项目。 """ - claude_projects = Path.home() / ".claude" / "projects" + claude_projects = projects_dir or Path.home() / ".claude" / "projects" if not claude_projects.exists(): return [] @@ -226,11 +239,15 @@ def find_sessions(project_dir: Path | None = None) -> list[Path]: return results -def find_sessions_by_keyword(keyword: str) -> list[Path]: +def find_sessions_by_keyword( + keyword: str, + *, + projects_dir: Path | None = None, +) -> list[Path]: """按关键词模糊匹配项目,在目录名和 JSONL 中的 cwd 中搜索""" import json - claude_projects = Path.home() / ".claude" / "projects" + claude_projects = projects_dir or Path.home() / ".claude" / "projects" if not claude_projects.exists(): return [] @@ -683,9 +700,14 @@ def _read_codex_session_meta(path: Path) -> dict[str, Any]: return {} -def find_codex_sessions(project_dir: Path | None = None) -> list[Path]: +def find_codex_sessions( + project_dir: Path | None = None, + *, + codex_home_dir: Path | None = None, +) -> list[Path]: """查找 ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl 会话文件""" - base = Path.home() / ".codex" / "sessions" + codex_home = codex_home_dir or Path.home() / ".codex" + base = codex_home / "sessions" if not base.exists(): return [] @@ -693,10 +715,7 @@ def find_codex_sessions(project_dir: Path | None = None) -> list[Path]: if project_dir is None: return all_files - try: - target = str(project_dir.expanduser().resolve()) - except OSError: - target = str(project_dir) + target = _normalized_project_path(project_dir) results: list[Path] = [] for path in all_files: @@ -704,21 +723,22 @@ def find_codex_sessions(project_dir: Path | None = None) -> list[Path]: cwd = meta.get("cwd", "") if not isinstance(cwd, str) or not cwd: continue - try: - normalized = str(Path(cwd).expanduser().resolve()) - except OSError: - normalized = cwd + normalized = _normalized_project_path(cwd) if normalized == target: results.append(path) return results -def find_codex_sessions_by_keyword(keyword: str) -> list[Path]: +def find_codex_sessions_by_keyword( + keyword: str, + *, + codex_home_dir: Path | None = None, +) -> list[Path]: """按关键词搜索 Codex 会话(路径/cwd/用户消息内容)""" keyword_lower = keyword.lower() results: list[Path] = [] - for path in find_codex_sessions(): + for path in find_codex_sessions(codex_home_dir=codex_home_dir): if keyword_lower in str(path).lower(): results.append(path) continue @@ -858,9 +878,13 @@ def _extract_gemini_content(raw: Any) -> Any: return raw or "" -def find_gemini_sessions() -> list[Path]: +def find_gemini_sessions( + *, + gemini_home_dir: Path | None = None, +) -> list[Path]: """查找 ~/.gemini/tmp/*/chats/*.json 会话文件""" - gemini_dir = Path.home() / ".gemini" / "tmp" + gemini_home = gemini_home_dir or Path.home() / ".gemini" + gemini_dir = gemini_home / "tmp" if not gemini_dir.exists(): return [] @@ -874,9 +898,13 @@ def find_gemini_sessions() -> list[Path]: return results -def find_gemini_sessions_by_keyword(keyword: str) -> list[Path]: +def find_gemini_sessions_by_keyword( + keyword: str, + *, + gemini_home_dir: Path | None = None, +) -> list[Path]: """按关键词搜索 Gemini 会话(在 directories 和内容中搜索)""" - all_sessions = find_gemini_sessions() + all_sessions = find_gemini_sessions(gemini_home_dir=gemini_home_dir) if not all_sessions: return [] diff --git a/cc_stats/reporter.py b/cc_stats/reporter.py index dd59d7b..708bb28 100644 --- a/cc_stats/reporter.py +++ b/cc_stats/reporter.py @@ -7,17 +7,12 @@ from pathlib import Path from .analyzer import SessionStats, TokenUsage, analyze_session, merge_stats -from .parser import ( - find_codex_sessions, - find_gemini_sessions, - find_sessions, - parse_session_file, -) from .pricing import ( Pricing, estimate_cost_from_token_by_model, match_model_pricing, ) +from .sources import collect_session_files, parse_file def _match_pricing(model: str) -> Pricing: @@ -109,11 +104,7 @@ def generate_report(period: str = "week") -> str: end_str = now.astimezone().strftime("%Y-%m-%d") # 收集所有会话(Claude + Codex + Gemini) - session_files: list[Path] = [ - f for f in find_sessions() if not f.name.startswith("agent-") - ] - session_files.extend(find_codex_sessions()) - session_files.extend(find_gemini_sessions()) + session_files: list[Path] = collect_session_files() session_files.sort(key=lambda f: f.stat().st_mtime) all_stats: list[SessionStats] = [] @@ -121,7 +112,7 @@ def generate_report(period: str = "week") -> str: for f in session_files: try: - session = parse_session_file(f) + session = parse_file(f) stats = analyze_session(session) if stats.end_time and stats.end_time < since: continue @@ -268,7 +259,7 @@ def generate_report(period: str = "week") -> str: prev_stats: list[SessionStats] = [] for f in session_files: try: - session = parse_session_file(f) + session = parse_file(f) stats_item = analyze_session(session) if stats_item.end_time and prev_since <= stats_item.end_time < since: prev_stats.append(stats_item) diff --git a/cc_stats/sources.py b/cc_stats/sources.py new file mode 100644 index 0000000..1916ecf --- /dev/null +++ b/cc_stats/sources.py @@ -0,0 +1,202 @@ +"""Unified session source registry for Claude, Codex, and Gemini.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from cc_stats.parser import ( + Session, + find_codex_sessions, + find_codex_sessions_by_keyword, + find_gemini_sessions, + find_gemini_sessions_by_keyword, + find_sessions, + find_sessions_by_keyword, + parse_session_file, +) + + +class SourceKind(str, Enum): + ALL = "all" + CLAUDE = "claude" + CODEX = "codex" + GEMINI = "gemini" + + +@dataclass(frozen=True) +class SourceProject: + source: SourceKind + key: str + display_name: str + session_count: int + last_modified: float + + +def claude_projects_dir() -> Path: + return _env_path("CC_STATS_CLAUDE_PROJECTS_DIR", Path.home() / ".claude" / "projects") + + +def codex_home() -> Path: + return _env_path("CC_STATS_CODEX_HOME", Path.home() / ".codex") + + +def gemini_home() -> Path: + return _env_path("CC_STATS_GEMINI_HOME", Path.home() / ".gemini") + + +def _env_path(name: str, default: Path) -> Path: + raw = os.environ.get(name, "").strip() + return Path(raw).expanduser() if raw else default + + +def normalize_source(source: SourceKind | str | None) -> SourceKind: + if source is None or source == "": + return SourceKind.ALL + if isinstance(source, SourceKind): + return source + value = str(source).strip().lower() + if value in {"claude-code", "claude_code"}: + value = SourceKind.CLAUDE.value + try: + return SourceKind(value) + except ValueError as exc: + allowed = ", ".join(kind.value for kind in SourceKind) + raise ValueError(f"Unknown source {source!r}; expected one of: {allowed}") from exc + + +def active_sources(source: SourceKind | str | None = None) -> tuple[SourceKind, ...]: + normalized = normalize_source(source) + if normalized == SourceKind.ALL: + return (SourceKind.CLAUDE, SourceKind.CODEX, SourceKind.GEMINI) + return (normalized,) + + +def collect_session_files( + source: SourceKind | str | None = None, + project_dir: Path | None = None, +) -> list[Path]: + files: list[Path] = [] + for kind in active_sources(source): + if kind == SourceKind.CLAUDE: + files.extend(find_sessions(project_dir, projects_dir=claude_projects_dir())) + elif kind == SourceKind.CODEX: + files.extend(find_codex_sessions(project_dir, codex_home_dir=codex_home())) + elif kind == SourceKind.GEMINI: + if project_dir is None: + files.extend(find_gemini_sessions(gemini_home_dir=gemini_home())) + else: + files.extend(_filter_sessions_by_project( + find_gemini_sessions(gemini_home_dir=gemini_home()), + project_dir, + )) + return list(dict.fromkeys(files)) + + +def collect_session_files_by_keyword( + keyword: str, + source: SourceKind | str | None = None, +) -> list[Path]: + files: list[Path] = [] + for kind in active_sources(source): + if kind == SourceKind.CLAUDE: + files.extend(find_sessions_by_keyword(keyword, projects_dir=claude_projects_dir())) + elif kind == SourceKind.CODEX: + files.extend(find_codex_sessions_by_keyword(keyword, codex_home_dir=codex_home())) + elif kind == SourceKind.GEMINI: + files.extend(find_gemini_sessions_by_keyword(keyword, gemini_home_dir=gemini_home())) + return list(dict.fromkeys(files)) + + +def list_projects(source: SourceKind | str | None = None) -> list[SourceProject]: + groups: dict[tuple[SourceKind, str], _ProjectGroup] = {} + for path in collect_session_files(source=source): + try: + session = parse_file(path) + except (OSError, ValueError): + continue + kind = normalize_source(session.source) + key = _project_key(path, session, kind) + display_name = session.project_path or key + last_modified = _mtime(path) + group_key = (kind, key) + if group_key not in groups: + groups[group_key] = _ProjectGroup( + source=kind, + key=key, + display_name=display_name, + session_count=0, + last_modified=last_modified, + ) + group = groups[group_key] + group.session_count += 1 + group.last_modified = max(group.last_modified, last_modified) + if session.project_path: + group.display_name = session.project_path + + return [ + SourceProject( + source=group.source, + key=group.key, + display_name=group.display_name, + session_count=group.session_count, + last_modified=group.last_modified, + ) + for group in sorted( + groups.values(), + key=lambda group: (group.source.value, group.display_name.lower(), group.key), + ) + ] + + +def parse_file(path: Path) -> Session: + return parse_session_file(path) + + +@dataclass +class _ProjectGroup: + source: SourceKind + key: str + display_name: str + session_count: int + last_modified: float + + +def _filter_sessions_by_project(paths: list[Path], project_dir: Path) -> list[Path]: + target = _normalized_path(project_dir) + results: list[Path] = [] + for path in paths: + try: + session = parse_file(path) + except (OSError, ValueError): + continue + if not session.project_path: + continue + if _normalized_path(Path(session.project_path)) == target: + results.append(path) + return results + + +def _project_key(path: Path, session: Session, source: SourceKind) -> str: + if source == SourceKind.CLAUDE: + return path.parent.name + if session.project_path: + return session.project_path + return str(path.parent) + + +def _normalized_path(path: Path) -> str: + try: + resolved = str(path.expanduser().resolve()) + except OSError: + resolved = str(path.expanduser()) + return os.path.normcase(resolved) + + +def _mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 diff --git a/cc_stats/webhook.py b/cc_stats/webhook.py index bd0adcf..5ac8b8f 100644 --- a/cc_stats/webhook.py +++ b/cc_stats/webhook.py @@ -8,13 +8,8 @@ from datetime import datetime, timezone from .analyzer import SessionStats, analyze_session, merge_stats -from .parser import ( - find_codex_sessions, - find_gemini_sessions, - find_sessions, - parse_session_file, -) from .pricing import estimate_cost_from_token_by_model +from .sources import collect_session_files, parse_file def _collect_today_stats() -> SessionStats | None: @@ -24,14 +19,12 @@ def _collect_today_stats() -> SessionStats | None: 落在今天,该 session 就会被纳入统计。 """ today_key = datetime.now().strftime("%Y-%m-%d") - all_files: list = list(find_sessions()) - all_files.extend(find_codex_sessions()) - all_files.extend(find_gemini_sessions()) + all_files = collect_session_files() today_stats = [] for f in all_files: try: - session = parse_session_file(f) + session = parse_file(f) stats = analyze_session(session) # 按消息时间戳归日:token_by_date 包含今天的 key has_today_tokens = today_key in stats.token_by_date diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index aa85f78..751d448 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -17,45 +17,14 @@ compute_cache_stats, merge_stats, ) -from cc_stats.parser import ( - find_gemini_sessions, - find_sessions, - parse_gemini_json, - parse_jsonl, -) +from cc_stats.pricing import match_model_pricing +from cc_stats.sources import collect_session_files, list_projects, parse_file _web_dir = os.path.join(os.path.dirname(__file__), "web") -# Model pricing ($/M tokens) -_PRICING = { - "opus": {"input": 15, "output": 75, "cache_read": 1.5, "cache_create": 18.75}, - "sonnet": {"input": 3, "output": 15, "cache_read": 0.3, "cache_create": 3.75}, - "haiku": {"input": 0.8, "output": 4, "cache_read": 0.08, "cache_create": 1.0}, - "gpt-4o": {"input": 2.5, "output": 10, "cache_read": 1.25, "cache_create": 2.5}, - "o1": {"input": 15, "output": 60, "cache_read": 7.5, "cache_create": 15}, - "o3": {"input": 10, "output": 40, "cache_read": 2.5, "cache_create": 10}, - "gemini-2.5-pro": {"input": 1.25, "output": 10, "cache_read": 0.31, "cache_create": 1.25}, - "gemini-2.5-flash": {"input": 0.15, "output": 0.60, "cache_read": 0.04, "cache_create": 0.15}, - "gemini-2.0-flash": {"input": 0.10, "output": 0.40, "cache_read": 0.025, "cache_create": 0.10}, -} - - -def _match_pricing(model: str) -> dict: - lower = model.lower() - # Gemini models (exact match first) - for key in ("gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"): - if key in lower: - return _PRICING[key] - if "gemini" in lower: - return _PRICING["gemini-2.5-flash"] - for key in ["opus", "haiku", "sonnet", "gpt-4o", "o1", "o3"]: - if key in lower: - return _PRICING[key] - return _PRICING["sonnet"] - def _estimate_cost(tu: TokenUsage, model: str = "") -> float: - p = _match_pricing(model) + p = match_model_pricing(model) cost = 0.0 cost += tu.input_tokens / 1e6 * p["input"] cost += tu.output_tokens / 1e6 * p["output"] @@ -64,22 +33,6 @@ def _estimate_cost(tu: TokenUsage, model: str = "") -> float: return cost -def _resolve_project_name(proj_dir, jsonl_files): - for jf in jsonl_files: - try: - with open(jf, encoding="utf-8") as fh: - for ln in fh: - try: - obj = json.loads(ln) - if obj.get("cwd"): - return obj["cwd"] - except (json.JSONDecodeError, UnicodeDecodeError): - continue - except OSError: - continue - return proj_dir.name - - def _stats_to_dict(stats: SessionStats, session_count: int = 1) -> dict: def _td_seconds(td): return td.total_seconds() @@ -161,87 +114,47 @@ def _token_dict(tu): } -def _get_projects(): - from pathlib import Path - projects = [] - - # Claude projects - claude_projects = Path.home() / ".claude" / "projects" - if claude_projects.exists(): - for proj in sorted(claude_projects.iterdir()): - if not proj.is_dir(): - continue - jsonl_files = [f for f in proj.glob("*.jsonl") if not f.name.startswith("agent-")] - if not jsonl_files: - continue - display_name = _resolve_project_name(proj, jsonl_files) - projects.append({ - "dir_name": proj.name, - "display_name": display_name, - "session_count": len(jsonl_files), - "source": "claude", - }) - - # Gemini projects - gemini_files = find_gemini_sessions() - if gemini_files: - gemini_by_dir: dict[str, list] = {} - for gf in gemini_files: - dir_key = gf.parent.parent.name # project hash - gemini_by_dir.setdefault(dir_key, []).append(gf) - for dir_key, files in gemini_by_dir.items(): - # Try to get project path from first session - display_name = dir_key - try: - session = parse_gemini_json(files[0]) - if session.project_path: - display_name = session.project_path - except Exception: - pass - projects.append({ - "dir_name": f"gemini:{dir_key}", - "display_name": display_name, - "session_count": len(files), - "source": "gemini", - }) - +def _get_projects(source: str | None = None): + projects = [ + { + "dir_name": project.key, + "display_name": project.display_name, + "session_count": project.session_count, + "source": project.source.value, + } + for project in list_projects(source=source) + ] projects.sort(key=lambda x: x["session_count"], reverse=True) return projects -def _collect_session_files(project_dir_name=None): - """Collect session files (Claude JSONL + Gemini JSON)""" - from pathlib import Path - files = [] - - if project_dir_name and project_dir_name.startswith("gemini:"): - # Gemini project - dir_key = project_dir_name[7:] - for gf in find_gemini_sessions(): - if gf.parent.parent.name == dir_key: - files.append(gf) - elif project_dir_name: - # Claude project - claude_projects = Path.home() / ".claude" / "projects" - proj_dir = claude_projects / project_dir_name - files = sorted(f for f in proj_dir.glob("*.jsonl") if not f.name.startswith("agent-")) - else: - # All sources - files = [f for f in find_sessions() if not f.name.startswith("agent-")] - files.extend(find_gemini_sessions()) +def _collect_session_files(project_dir_name=None, source: str | None = None): + """Collect session files from the shared source registry.""" + files = collect_session_files(source=source) + if not project_dir_name: + return files - return files + filtered = [] + for f in files: + try: + session = _parse_session_file(f) + except Exception: + continue + if session.project_path == project_dir_name: + filtered.append(f) + continue + if session.source == "claude" and f.parent.name == project_dir_name: + filtered.append(f) + return filtered def _parse_session_file(f): - """Parse a session file based on its extension""" - if f.suffix == ".json": - return parse_gemini_json(f) - return parse_jsonl(f) + """Parse a session file through the shared source parser.""" + return parse_file(f) -def _get_stats(project_dir_name=None, since_days=None): - files = _collect_session_files(project_dir_name) +def _get_stats(project_dir_name=None, since_days=None, source: str | None = None): + files = _collect_session_files(project_dir_name, source=source) if not files: return {"error": "No sessions found"} @@ -269,8 +182,8 @@ def _get_stats(project_dir_name=None, since_days=None): return _stats_to_dict(result, session_count=len(all_stats)) -def _get_daily_stats(project_dir_name=None, days=14): - files = _collect_session_files(project_dir_name) +def _get_daily_stats(project_dir_name=None, days=14, source: str | None = None): + files = _collect_session_files(project_dir_name, source=source) since_dt = datetime.now(tz=timezone.utc) - timedelta(days=days) daily: dict[str, list] = defaultdict(list) @@ -316,13 +229,13 @@ def _get_daily_stats(project_dir_name=None, days=14): return result -def _get_skill_stats(project_dir_name=None, since_days=None): +def _get_skill_stats(project_dir_name=None, since_days=None, source: str | None = None): """Return skill usage statistics as a list sorted by call_count. Skill stats always cover ALL sessions (ignoring since_days) because skill usage patterns are more meaningful at the all-time level. """ - files = _collect_session_files(project_dir_name) + files = _collect_session_files(project_dir_name, source=source) if not files: return [] @@ -386,34 +299,41 @@ def do_GET(self): parsed = urlparse(self.path) path = parsed.path params = parse_qs(parsed.query) + source = params.get("source", [None])[0] - if path == "/api/projects": - self._json(_get_projects()) - elif path == "/api/stats": - project = params.get("project", [None])[0] - days = params.get("days", [None])[0] - self._json(_get_stats( - project_dir_name=project or None, - since_days=int(days) if days and days != "0" else None, - )) - elif path == "/api/daily_stats": - project = params.get("project", [None])[0] - days = params.get("days", ["14"])[0] - self._json(_get_daily_stats( - project_dir_name=project or None, - days=int(days), - )) - elif path == "/api/skills": - project = params.get("project", [None])[0] - days = params.get("days", [None])[0] - self._json(_get_skill_stats( - project_dir_name=project or None, - since_days=int(days) if days and days != "0" else None, - )) - elif path == "/api/version_check": - self._json(_get_version_update()) - else: - super().do_GET() + try: + if path == "/api/projects": + self._json(_get_projects(source=source)) + elif path == "/api/stats": + project = params.get("project", [None])[0] + days = params.get("days", [None])[0] + self._json(_get_stats( + project_dir_name=project or None, + since_days=int(days) if days and days != "0" else None, + source=source, + )) + elif path == "/api/daily_stats": + project = params.get("project", [None])[0] + days = params.get("days", ["14"])[0] + self._json(_get_daily_stats( + project_dir_name=project or None, + days=int(days), + source=source, + )) + elif path == "/api/skills": + project = params.get("project", [None])[0] + days = params.get("days", [None])[0] + self._json(_get_skill_stats( + project_dir_name=project or None, + since_days=int(days) if days and days != "0" else None, + source=source, + )) + elif path == "/api/version_check": + self._json(_get_version_update()) + else: + super().do_GET() + except ValueError as exc: + self._json({"error": str(exc)}) def _json(self, data): body = json.dumps(data, ensure_ascii=False).encode("utf-8") diff --git a/cc_stats_web/web/index.html b/cc_stats_web/web/index.html index 03986fe..bc65731 100644 --- a/cc_stats_web/web/index.html +++ b/cc_stats_web/web/index.html @@ -150,7 +150,7 @@

CC Statistics

+ + diff --git a/desktop/cc-stats-tauri/src/main.js b/desktop/cc-stats-tauri/src/main.js new file mode 100644 index 0000000..594592e --- /dev/null +++ b/desktop/cc-stats-tauri/src/main.js @@ -0,0 +1,33 @@ +import { apiStatus, openDashboard, restartApi } from "./apiClient.js"; +import { dashboardUrl, statusLabel } from "./dashboard.js"; + +const statusEl = document.querySelector("[data-status]"); +const frameEl = document.querySelector("[data-dashboard-frame]"); +const openBtn = document.querySelector("[data-open-dashboard]"); +const restartBtn = document.querySelector("[data-restart-api]"); + +async function refreshStatus() { + const status = await apiStatus(); + statusEl.textContent = statusLabel(status.state); + + if (status.url) { + frameEl.src = dashboardUrl(status.url); + } +} + +openBtn?.addEventListener("click", () => { + openDashboard().then(refreshStatus).catch((error) => { + statusEl.textContent = `Open failed: ${error}`; + }); +}); + +restartBtn?.addEventListener("click", () => { + statusEl.textContent = statusLabel("starting"); + restartApi().then(refreshStatus).catch((error) => { + statusEl.textContent = `Restart failed: ${error}`; + }); +}); + +refreshStatus().catch((error) => { + statusEl.textContent = `Status unavailable: ${error}`; +}); diff --git a/desktop/cc-stats-tauri/tests/frontend.test.mjs b/desktop/cc-stats-tauri/tests/frontend.test.mjs new file mode 100644 index 0000000..c882ec6 --- /dev/null +++ b/desktop/cc-stats-tauri/tests/frontend.test.mjs @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + dashboardUrl, + normalizeApiBaseUrl, + statusLabel, +} from "../src/dashboard.js"; + +test("normalizeApiBaseUrl trims trailing slash", () => { + assert.equal(normalizeApiBaseUrl("http://127.0.0.1:61234/"), "http://127.0.0.1:61234"); +}); + +test("dashboardUrl points at the Python dashboard root", () => { + assert.equal(dashboardUrl("http://127.0.0.1:61234/"), "http://127.0.0.1:61234/"); +}); + +test("statusLabel maps api process states", () => { + assert.equal(statusLabel("starting"), "Starting API..."); + assert.equal(statusLabel("running"), "API running"); + assert.equal(statusLabel("failed"), "API failed"); + assert.equal(statusLabel("unknown"), "Unknown status"); +}); diff --git a/desktop/cc-stats-tauri/tsconfig.json b/desktop/cc-stats-tauri/tsconfig.json new file mode 100644 index 0000000..e4541ca --- /dev/null +++ b/desktop/cc-stats-tauri/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "checkJs": false, + "noEmit": true + }, + "include": ["src"] +} diff --git a/desktop/cc-stats-tauri/vite.config.js b/desktop/cc-stats-tauri/vite.config.js new file mode 100644 index 0000000..ba90673 --- /dev/null +++ b/desktop/cc-stats-tauri/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "src", + clearScreen: false, + build: { + outDir: "../dist", + emptyOutDir: true, + }, + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + }, +}); From 7949c21289c8035a9486a34348bfab192022baa7 Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 19:20:54 +0800 Subject: [PATCH 06/26] feat: add tauri tray process shell --- desktop/cc-stats-tauri/.gitignore | 4 + desktop/cc-stats-tauri/src-tauri/Cargo.lock | 5147 +++++++++++++++++ desktop/cc-stats-tauri/src-tauri/Cargo.toml | 16 + desktop/cc-stats-tauri/src-tauri/build.rs | 3 + .../cc-stats-tauri/src-tauri/icons/icon.png | Bin 0 -> 158 bytes .../src-tauri/src/api_process.rs | 209 + .../cc-stats-tauri/src-tauri/src/health.rs | 23 + desktop/cc-stats-tauri/src-tauri/src/main.rs | 57 + desktop/cc-stats-tauri/src-tauri/src/tray.rs | 43 + .../cc-stats-tauri/src-tauri/src/window.rs | 12 + .../cc-stats-tauri/src-tauri/tauri.conf.json | 30 + 11 files changed, 5544 insertions(+) create mode 100644 desktop/cc-stats-tauri/.gitignore create mode 100644 desktop/cc-stats-tauri/src-tauri/Cargo.lock create mode 100644 desktop/cc-stats-tauri/src-tauri/Cargo.toml create mode 100644 desktop/cc-stats-tauri/src-tauri/build.rs create mode 100644 desktop/cc-stats-tauri/src-tauri/icons/icon.png create mode 100644 desktop/cc-stats-tauri/src-tauri/src/api_process.rs create mode 100644 desktop/cc-stats-tauri/src-tauri/src/health.rs create mode 100644 desktop/cc-stats-tauri/src-tauri/src/main.rs create mode 100644 desktop/cc-stats-tauri/src-tauri/src/tray.rs create mode 100644 desktop/cc-stats-tauri/src-tauri/src/window.rs create mode 100644 desktop/cc-stats-tauri/src-tauri/tauri.conf.json diff --git a/desktop/cc-stats-tauri/.gitignore b/desktop/cc-stats-tauri/.gitignore new file mode 100644 index 0000000..8224285 --- /dev/null +++ b/desktop/cc-stats-tauri/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src-tauri/target/ +src-tauri/gen/ diff --git a/desktop/cc-stats-tauri/src-tauri/Cargo.lock b/desktop/cc-stats-tauri/src-tauri/Cargo.lock new file mode 100644 index 0000000..916cbe3 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.lock @@ -0,0 +1,5147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cc-stats-tauri" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-notification", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "mac-notification-sys" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "notify-rust" +version = "4.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/desktop/cc-stats-tauri/src-tauri/Cargo.toml b/desktop/cc-stats-tauri/src-tauri/Cargo.toml new file mode 100644 index 0000000..c320a87 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cc-stats-tauri" +version = "0.1.0" +description = "Windows tray shell for CC Statistics" +authors = ["androidZzT"] +license = "MIT" +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2.5.1", features = [] } + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tauri = { version = "2.11.2", features = ["tray-icon"] } +tauri-plugin-notification = "2.3.3" diff --git a/desktop/cc-stats-tauri/src-tauri/build.rs b/desktop/cc-stats-tauri/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/cc-stats-tauri/src-tauri/icons/icon.png b/desktop/cc-stats-tauri/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8d65e88e89b51cd09b81a85884770dbc7cfa4046 GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=sh%#5Ar-gYUT_p-U|?Y|nAj2= wCRJonz2<<@>gSsU6dW2D7@1f&C}O;eZoP8IYojIO6`+L-p00i_>zopr09r>P1poj5 literal 0 HcmV?d00001 diff --git a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs new file mode 100644 index 0000000..8b3d876 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs @@ -0,0 +1,209 @@ +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + sync::mpsc, + thread, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; + +use crate::health::ApiState; + +#[derive(Clone, Debug, Serialize)] +pub struct ApiStatus { + pub state: ApiState, + pub url: Option, + pub error: Option, +} + +pub struct ApiProcessManager { + child: Option, + status: ApiStatus, +} + +impl ApiProcessManager { + pub fn start_default() -> Self { + let mut manager = Self { + child: None, + status: ApiStatus { + state: ApiState::Starting, + url: None, + error: None, + }, + }; + match start_python_api() { + Ok((child, url)) => { + manager.child = Some(child); + manager.status = ApiStatus { + state: ApiState::Running, + url: Some(url), + error: None, + }; + manager + } + Err(error) => Self::failed(error), + } + } + + pub fn failed(error: String) -> Self { + Self { + child: None, + status: ApiStatus { + state: ApiState::Failed, + url: None, + error: Some(error), + }, + } + } + + pub fn status(&self) -> ApiStatus { + self.status.clone() + } + + pub fn restart(&mut self) -> ApiStatus { + self.stop(); + let mut next = Self::start_default(); + self.child = next.child.take(); + self.status = next.status.clone(); + self.status() + } + + pub fn stop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + self.status.state = ApiState::Stopped; + } +} + +impl Drop for ApiProcessManager { + fn drop(&mut self) { + self.stop(); + } +} + +pub fn candidate_python_commands() -> Vec> { + if cfg!(windows) { + vec![ + vec!["py".to_string(), "-3".to_string()], + vec!["python".to_string()], + vec!["python3".to_string()], + ] + } else { + vec![vec!["python3".to_string()], vec!["python".to_string()]] + } +} + +pub fn build_api_command(python_command: &[String]) -> Command { + let mut command = Command::new(&python_command[0]); + for arg in &python_command[1..] { + command.arg(arg); + } + command.args(["-m", "cc_stats_web", "--no-browser", "--json"]); + command +} + +pub fn parse_startup_url(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(payload) = serde_json::from_str::(trimmed) { + if payload.event == "cc_stats_web_started" && payload.url.starts_with("http://127.0.0.1:") { + return Some(payload.url); + } + } + if let Some(idx) = trimmed.find("http://127.0.0.1:") { + return Some(trimmed[idx..].trim().to_string()); + } + None +} + +fn start_python_api() -> Result<(Child, String), String> { + for python in candidate_python_commands() { + match spawn_with_python(&python) { + Ok(started) => return Ok(started), + Err(_) => continue, + } + } + Err("Unable to start cc_stats_web with python, python3, or py -3".to_string()) +} + +fn spawn_with_python(python: &[String]) -> Result<(Child, String), String> { + let mut command = build_api_command(python); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command + .spawn() + .map_err(|err| format!("failed to spawn {}: {err}", python.join(" ")))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| "cc_stats_web stdout was not captured".to_string())?; + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if let Some(url) = parse_startup_url(&line) { + let _ = tx.send(url); + return; + } + } + }); + + match rx.recv_timeout(Duration::from_secs(8)) { + Ok(url) => Ok((child, url)), + Err(err) => { + let _ = child.kill(); + let _ = child.wait(); + Err(format!("cc_stats_web did not report a startup URL: {err}")) + } + } +} + +#[derive(Debug, Deserialize)] +struct StartupPayload { + event: String, + url: String, +} + +#[cfg(test)] +mod tests { + use super::{build_api_command, candidate_python_commands, parse_startup_url}; + + #[test] + fn parses_structured_startup_json() { + let url = parse_startup_url( + r#"{"event":"cc_stats_web_started","host":"127.0.0.1","port":61234,"url":"http://127.0.0.1:61234/"}"#, + ); + + assert_eq!(url.as_deref(), Some("http://127.0.0.1:61234/")); + } + + #[test] + fn parses_legacy_human_startup_line() { + let url = parse_startup_url("CC Stats Web Dashboard: http://127.0.0.1:61234/"); + + assert_eq!(url.as_deref(), Some("http://127.0.0.1:61234/")); + } + + #[test] + fn api_command_uses_structured_no_browser_startup() { + let python = vec!["python3".to_string()]; + let command = build_api_command(&python); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!(command.get_program().to_string_lossy(), "python3"); + assert_eq!(args, ["-m", "cc_stats_web", "--no-browser", "--json"]); + } + + #[test] + fn candidate_python_commands_are_not_empty() { + assert!(!candidate_python_commands().is_empty()); + } +} diff --git a/desktop/cc-stats-tauri/src-tauri/src/health.rs b/desktop/cc-stats-tauri/src-tauri/src/health.rs new file mode 100644 index 0000000..9ffbde7 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/health.rs @@ -0,0 +1,23 @@ +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ApiState { + Starting, + Running, + Failed, + Stopped, +} + +#[cfg(test)] +mod tests { + use super::ApiState; + + #[test] + fn api_state_serializes_to_frontend_contract() { + assert_eq!(serde_json::to_string(&ApiState::Starting).unwrap(), "\"starting\""); + assert_eq!(serde_json::to_string(&ApiState::Running).unwrap(), "\"running\""); + assert_eq!(serde_json::to_string(&ApiState::Failed).unwrap(), "\"failed\""); + assert_eq!(serde_json::to_string(&ApiState::Stopped).unwrap(), "\"stopped\""); + } +} diff --git a/desktop/cc-stats-tauri/src-tauri/src/main.rs b/desktop/cc-stats-tauri/src-tauri/src/main.rs new file mode 100644 index 0000000..c40c8cd --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -0,0 +1,57 @@ +use std::sync::Mutex; + +use api_process::{ApiProcessManager, ApiStatus}; +use tauri::{AppHandle, Manager, State}; + +mod api_process; +mod health; +mod tray; +mod window; + +struct AppState { + api: Mutex, +} + +#[tauri::command] +fn api_status(state: State<'_, AppState>) -> ApiStatus { + state.api.lock().expect("api state poisoned").status() +} + +#[tauri::command] +fn restart_api(app: AppHandle, state: State<'_, AppState>) -> Result { + let status = state.api.lock().expect("api state poisoned").restart(); + if status.error.is_none() { + let _ = window::show_dashboard_window(&app); + } + Ok(status) +} + +#[tauri::command] +fn open_dashboard(app: AppHandle) -> Result<(), String> { + window::show_dashboard_window(&app) +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_notification::init()) + .setup(|app| { + app.manage(AppState { + api: Mutex::new(ApiProcessManager::start_default()), + }); + tray::build_tray(app.handle())?; + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + api_status, + restart_api, + open_dashboard + ]) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + }) + .run(tauri::generate_context!()) + .expect("error while running cc-statistics Windows tray app"); +} diff --git a/desktop/cc-stats-tauri/src-tauri/src/tray.rs b/desktop/cc-stats-tauri/src-tauri/src/tray.rs new file mode 100644 index 0000000..7b5fc50 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/tray.rs @@ -0,0 +1,43 @@ +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, +}; + +use crate::{restart_api, window}; + +pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { + let open_i = MenuItem::with_id(app, "open_dashboard", "Open Dashboard", true, None::<&str>)?; + let restart_i = MenuItem::with_id(app, "restart_api", "Restart API", true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&open_i, &restart_i, &quit_i])?; + + TrayIconBuilder::new() + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "open_dashboard" => { + let _ = window::show_dashboard_window(app); + } + "restart_api" => { + let _ = restart_api(app.clone(), app.state()); + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let _ = window::show_dashboard_window(tray.app_handle()); + } + }) + .build(app)?; + + Ok(()) +} diff --git a/desktop/cc-stats-tauri/src-tauri/src/window.rs b/desktop/cc-stats-tauri/src-tauri/src/window.rs new file mode 100644 index 0000000..beac437 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/window.rs @@ -0,0 +1,12 @@ +use tauri::{AppHandle, Manager}; + +pub fn show_dashboard_window(app: &AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "main window is not available".to_string())?; + + window.unminimize().map_err(|err| err.to_string())?; + window.show().map_err(|err| err.to_string())?; + window.set_focus().map_err(|err| err.to_string())?; + Ok(()) +} diff --git a/desktop/cc-stats-tauri/src-tauri/tauri.conf.json b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json new file mode 100644 index 0000000..c18afd9 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "CC Statistics", + "version": "0.1.0", + "identifier": "com.androidzzt.ccstatistics.windows", + "build": { + "beforeDevCommand": "npm run dev:web", + "beforeBuildCommand": "npm run build:web", + "devUrl": "http://127.0.0.1:5173", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "CC Statistics", + "width": 1100, + "height": 780, + "visible": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all" + } +} From a3c1506cdf97b25c79fc53f69c7db1b1d60ee327 Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 19:33:50 +0800 Subject: [PATCH 07/26] feat: add tray api health monitoring --- README.md | 17 +++ README_CN.md | 17 +++ cc_stats_web/server.py | 4 +- .../src-tauri/src/api_process.rs | 105 +++++++++++++++++- .../cc-stats-tauri/src-tauri/src/health.rs | 89 ++++++++++++++- desktop/cc-stats-tauri/src-tauri/src/main.rs | 25 ++++- .../src-tauri/src/notifications.rs | 18 +++ desktop/cc-stats-tauri/src/dashboard.js | 11 +- desktop/cc-stats-tauri/src/main.js | 9 +- .../cc-stats-tauri/tests/frontend.test.mjs | 16 +++ tests/test_web_server.py | 23 ++++ 11 files changed, 313 insertions(+), 21 deletions(-) create mode 100644 desktop/cc-stats-tauri/src-tauri/src/notifications.rs diff --git a/README.md b/README.md index b22c228..710f79f 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,23 @@ pipx install cc-statistics brew install androidZzT/tap/cc-statistics ``` +### Windows Tray Development Preview + +A Windows tray MVP lives in `desktop/cc-stats-tauri/`. It is a Tauri shell that starts the Python web dashboard with `python -m cc_stats_web --no-browser --json`, shows a tray menu, and opens the existing dashboard UI. Statistics, source discovery, parsing, pricing, and API responses stay in Python. + +This preview is for development builds. It does not replace the macOS Swift app, and it does not bundle Python, signing, automatic updates, or installer polish yet. + +```bash +cd desktop/cc-stats-tauri +npm install +npm test +npm run build:web + +cd src-tauri +cargo test +cargo check +``` + --- ## 📖 CLI Reference diff --git a/README_CN.md b/README_CN.md index 32b7aa9..1fe93e0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -212,6 +212,23 @@ pipx install cc-statistics brew install androidZzT/tap/cc-statistics ``` +### Windows 托盘开发预览 + +Windows 托盘 MVP 位于 `desktop/cc-stats-tauri/`。它是一个 Tauri 平台壳,启动 `python -m cc_stats_web --no-browser --json`,提供托盘菜单,并打开现有 Web 仪表盘。统计、数据源发现、解析、定价和 API 响应仍然由 Python 负责。 + +这个预览面向开发构建,不替代现有 macOS Swift 应用,也暂不包含 Python 打包、签名、自动更新或安装器打磨。 + +```bash +cd desktop/cc-stats-tauri +npm install +npm test +npm run build:web + +cd src-tauri +cargo test +cargo check +``` + --- ## 📖 CLI 参考 diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index 751d448..c5af446 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -302,7 +302,9 @@ def do_GET(self): source = params.get("source", [None])[0] try: - if path == "/api/projects": + if path == "/api/health": + self._json({"status": "ok"}) + elif path == "/api/projects": self._json(_get_projects(source=source)) elif path == "/api/stats": project = params.get("project", [None])[0] diff --git a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs index 8b3d876..fbb04d4 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs @@ -3,12 +3,12 @@ use std::{ process::{Child, Command, Stdio}, sync::mpsc, thread, - time::Duration, + time::{Duration, Instant}, }; use serde::{Deserialize, Serialize}; -use crate::health::ApiState; +use crate::health::{is_api_healthy, ApiState}; #[derive(Clone, Debug, Serialize)] pub struct ApiStatus { @@ -57,7 +57,8 @@ impl ApiProcessManager { } } - pub fn status(&self) -> ApiStatus { + pub fn status(&mut self) -> ApiStatus { + self.refresh_status(); self.status.clone() } @@ -76,6 +77,53 @@ impl ApiProcessManager { } self.status.state = ApiState::Stopped; } + + fn refresh_status(&mut self) { + if self.status.state != ApiState::Running { + return; + } + + if let Some(child) = self.child.as_mut() { + match child.try_wait() { + Ok(Some(exit_status)) => { + self.child = None; + self.status.state = ApiState::Failed; + self.status.error = Some(format!("cc_stats_web exited with {exit_status}")); + return; + } + Ok(None) => {} + Err(error) => { + self.status.state = ApiState::Failed; + self.status.error = Some(format!("failed to inspect cc_stats_web: {error}")); + return; + } + } + } + + let Some(url) = self.status.url.as_deref() else { + self.status.state = ApiState::Failed; + self.status.error = + Some("cc_stats_web health check failed: missing API URL".to_string()); + return; + }; + + if !is_api_healthy(url) { + self.status.state = ApiState::Failed; + self.status.error = Some(format!("cc_stats_web health check failed for {url}")); + } + } + + #[cfg(test)] + fn running_for_test(url: &str) -> Self { + Self { + child: None, + status: ApiStatus { + state: ApiState::Running, + url: Some(url.to_string()), + error: None, + }, + } + } } impl Drop for ApiProcessManager { @@ -154,7 +202,14 @@ fn spawn_with_python(python: &[String]) -> Result<(Child, String), String> { }); match rx.recv_timeout(Duration::from_secs(8)) { - Ok(url) => Ok((child, url)), + Ok(url) => match wait_for_api_health(&mut child, &url) { + Ok(()) => Ok((child, url)), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + Err(error) + } + }, Err(err) => { let _ = child.kill(); let _ = child.wait(); @@ -163,6 +218,32 @@ fn spawn_with_python(python: &[String]) -> Result<(Child, String), String> { } } +fn wait_for_api_health(child: &mut Child, url: &str) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + match child.try_wait() { + Ok(Some(exit_status)) => { + return Err(format!( + "cc_stats_web exited before becoming healthy: {exit_status}" + )); + } + Ok(None) => {} + Err(error) => { + return Err(format!( + "failed to inspect cc_stats_web during startup: {error}" + )); + } + } + + if is_api_healthy(url) { + return Ok(()); + } + thread::sleep(Duration::from_millis(150)); + } + + Err(format!("cc_stats_web did not become healthy at {url}")) +} + #[derive(Debug, Deserialize)] struct StartupPayload { event: String, @@ -171,7 +252,10 @@ struct StartupPayload { #[cfg(test)] mod tests { - use super::{build_api_command, candidate_python_commands, parse_startup_url}; + use super::{ + build_api_command, candidate_python_commands, parse_startup_url, ApiProcessManager, + }; + use crate::health::ApiState; #[test] fn parses_structured_startup_json() { @@ -206,4 +290,15 @@ mod tests { fn candidate_python_commands_are_not_empty() { assert!(!candidate_python_commands().is_empty()); } + + #[test] + fn status_marks_running_manager_failed_when_health_probe_fails() { + let mut manager = ApiProcessManager::running_for_test("http://localhost:61234/"); + + let status = manager.status(); + + assert_eq!(status.state, ApiState::Failed); + assert_eq!(status.url.as_deref(), Some("http://localhost:61234/")); + assert!(status.error.unwrap().contains("health check failed")); + } } diff --git a/desktop/cc-stats-tauri/src-tauri/src/health.rs b/desktop/cc-stats-tauri/src-tauri/src/health.rs index 9ffbde7..19d35d0 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/health.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/health.rs @@ -1,3 +1,9 @@ +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + time::Duration, +}; + use serde::Serialize; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -9,15 +15,90 @@ pub enum ApiState { Stopped, } +pub fn is_api_healthy(api_url: &str) -> bool { + let Some(port) = parse_local_api_port(api_url) else { + return false; + }; + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(800))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(800))); + + let request = + format!("GET /api/health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + + let mut response = [0; 64]; + let Ok(read) = stream.read(&mut response) else { + return false; + }; + let status = String::from_utf8_lossy(&response[..read]); + status.starts_with("HTTP/1.0 200") || status.starts_with("HTTP/1.1 200") +} + +fn parse_local_api_port(api_url: &str) -> Option { + let rest = api_url.strip_prefix("http://127.0.0.1:")?; + let port = rest + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if port.is_empty() { + return None; + } + port.parse().ok() +} + #[cfg(test)] mod tests { use super::ApiState; #[test] fn api_state_serializes_to_frontend_contract() { - assert_eq!(serde_json::to_string(&ApiState::Starting).unwrap(), "\"starting\""); - assert_eq!(serde_json::to_string(&ApiState::Running).unwrap(), "\"running\""); - assert_eq!(serde_json::to_string(&ApiState::Failed).unwrap(), "\"failed\""); - assert_eq!(serde_json::to_string(&ApiState::Stopped).unwrap(), "\"stopped\""); + assert_eq!( + serde_json::to_string(&ApiState::Starting).unwrap(), + "\"starting\"" + ); + assert_eq!( + serde_json::to_string(&ApiState::Running).unwrap(), + "\"running\"" + ); + assert_eq!( + serde_json::to_string(&ApiState::Failed).unwrap(), + "\"failed\"" + ); + assert_eq!( + serde_json::to_string(&ApiState::Stopped).unwrap(), + "\"stopped\"" + ); + } + + #[test] + fn api_health_probe_detects_ok_response() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 512]; + let read = std::io::Read::read(&mut stream, &mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.starts_with("GET /api/health HTTP/1.1")); + std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}", + ) + .unwrap(); + }); + + assert!(super::is_api_healthy(&format!("http://127.0.0.1:{port}/"))); + handle.join().unwrap(); + } + + #[test] + fn api_health_probe_rejects_non_local_url() { + assert!(!super::is_api_healthy("http://localhost:61234/")); } } diff --git a/desktop/cc-stats-tauri/src-tauri/src/main.rs b/desktop/cc-stats-tauri/src-tauri/src/main.rs index c40c8cd..9c48a99 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/main.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -1,10 +1,12 @@ use std::sync::Mutex; use api_process::{ApiProcessManager, ApiStatus}; +use health::ApiState; use tauri::{AppHandle, Manager, State}; mod api_process; mod health; +mod notifications; mod tray; mod window; @@ -19,10 +21,22 @@ fn api_status(state: State<'_, AppState>) -> ApiStatus { #[tauri::command] fn restart_api(app: AppHandle, state: State<'_, AppState>) -> Result { - let status = state.api.lock().expect("api state poisoned").restart(); - if status.error.is_none() { + let (previous, status) = { + let mut api = state.api.lock().expect("api state poisoned"); + let previous = api.status(); + let status = api.restart(); + (previous, status) + }; + + if status.state == ApiState::Running { + if previous.state == ApiState::Failed { + notifications::api_recovered(&app); + } let _ = window::show_dashboard_window(&app); + } else if let Some(error) = status.error.as_deref() { + notifications::api_start_failed(&app, error); } + Ok(status) } @@ -35,8 +49,13 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) .setup(|app| { + let mut api = ApiProcessManager::start_default(); + let initial_status = api.status(); + if let Some(error) = initial_status.error.as_deref() { + notifications::api_start_failed(app.handle(), error); + } app.manage(AppState { - api: Mutex::new(ApiProcessManager::start_default()), + api: Mutex::new(api), }); tray::build_tray(app.handle())?; Ok(()) diff --git a/desktop/cc-stats-tauri/src-tauri/src/notifications.rs b/desktop/cc-stats-tauri/src-tauri/src/notifications.rs new file mode 100644 index 0000000..889052c --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/notifications.rs @@ -0,0 +1,18 @@ +use tauri::AppHandle; +use tauri_plugin_notification::NotificationExt; + +pub fn api_start_failed(app: &AppHandle, error: &str) { + notify(app, "CC Statistics API failed", error); +} + +pub fn api_recovered(app: &AppHandle) { + notify( + app, + "CC Statistics API recovered", + "The local statistics dashboard is running again.", + ); +} + +fn notify(app: &AppHandle, title: &str, body: &str) { + let _ = app.notification().builder().title(title).body(body).show(); +} diff --git a/desktop/cc-stats-tauri/src/dashboard.js b/desktop/cc-stats-tauri/src/dashboard.js index 53ee60a..75f174d 100644 --- a/desktop/cc-stats-tauri/src/dashboard.js +++ b/desktop/cc-stats-tauri/src/dashboard.js @@ -7,14 +7,21 @@ export function dashboardUrl(apiBaseUrl) { return base ? `${base}/` : ""; } -export function statusLabel(status) { +export function frameUrlForStatus(status) { + if (status?.state !== "running") { + return ""; + } + return dashboardUrl(status.url); +} + +export function statusLabel(status, error = "") { switch (status) { case "starting": return "Starting API..."; case "running": return "API running"; case "failed": - return "API failed"; + return error ? `API failed: ${error}` : "API failed"; case "stopped": return "API stopped"; default: diff --git a/desktop/cc-stats-tauri/src/main.js b/desktop/cc-stats-tauri/src/main.js index 594592e..99d473d 100644 --- a/desktop/cc-stats-tauri/src/main.js +++ b/desktop/cc-stats-tauri/src/main.js @@ -1,5 +1,5 @@ import { apiStatus, openDashboard, restartApi } from "./apiClient.js"; -import { dashboardUrl, statusLabel } from "./dashboard.js"; +import { frameUrlForStatus, statusLabel } from "./dashboard.js"; const statusEl = document.querySelector("[data-status]"); const frameEl = document.querySelector("[data-dashboard-frame]"); @@ -8,11 +8,8 @@ const restartBtn = document.querySelector("[data-restart-api]"); async function refreshStatus() { const status = await apiStatus(); - statusEl.textContent = statusLabel(status.state); - - if (status.url) { - frameEl.src = dashboardUrl(status.url); - } + statusEl.textContent = statusLabel(status.state, status.error); + frameEl.src = frameUrlForStatus(status); } openBtn?.addEventListener("click", () => { diff --git a/desktop/cc-stats-tauri/tests/frontend.test.mjs b/desktop/cc-stats-tauri/tests/frontend.test.mjs index c882ec6..ff3efb8 100644 --- a/desktop/cc-stats-tauri/tests/frontend.test.mjs +++ b/desktop/cc-stats-tauri/tests/frontend.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { dashboardUrl, + frameUrlForStatus, normalizeApiBaseUrl, statusLabel, } from "../src/dashboard.js"; @@ -21,3 +22,18 @@ test("statusLabel maps api process states", () => { assert.equal(statusLabel("failed"), "API failed"); assert.equal(statusLabel("unknown"), "Unknown status"); }); + +test("statusLabel includes api errors for failed state", () => { + assert.equal( + statusLabel("failed", "python module missing"), + "API failed: python module missing", + ); +}); + +test("frameUrlForStatus clears stale dashboard on api failure", () => { + assert.equal(frameUrlForStatus({ state: "failed", url: "http://127.0.0.1:61234/" }), ""); + assert.equal( + frameUrlForStatus({ state: "running", url: "http://127.0.0.1:61234/" }), + "http://127.0.0.1:61234/", + ); +}); diff --git a/tests/test_web_server.py b/tests/test_web_server.py index d09b49e..c1c79bc 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import threading +import urllib.request from pathlib import Path from cc_stats.analyzer import SessionStats, TokenUsage @@ -8,6 +10,7 @@ _collect_session_files, _get_projects, _get_stats, + start_server, _stats_to_dict, ) @@ -128,6 +131,26 @@ def test_get_projects_source_codex_includes_codex_project( }] +def test_health_endpoint_returns_ok() -> None: + server, port = start_server() + thread = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.1}, + daemon=True, + ) + thread.start() + + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=2) as resp: + payload = json.loads(resp.read().decode("utf-8")) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert payload == {"status": "ok"} + + def test_collect_session_files_source_codex_returns_codex_file( tmp_path: Path, monkeypatch, From 5664a26428e7f37041322e9e153a54151f973180 Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 19:41:11 +0800 Subject: [PATCH 08/26] docs: finalize windows tray implementation notes --- README.md | 2 +- README_CN.md | 2 +- ...6-06-09-windows-tray-mvp-implementation.md | 32 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 710f79f..bc09a5a 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Most tools only answer this for Claude Code. cc-statistics answers it for all fo ### Prerequisites -- Python 3.8+ +- Python 3.10+ - At least one of: Claude Code CLI, Gemini CLI, Codex CLI, or Cursor installed and used ### 3 steps diff --git a/README_CN.md b/README_CN.md index 1fe93e0..8c91762 100644 --- a/README_CN.md +++ b/README_CN.md @@ -184,7 +184,7 @@ ### 前置条件 -- Python 3.8+ +- Python 3.10+ - 已安装并使用过以下至少一种工具:Claude Code CLI、Gemini CLI、Codex CLI 或 Cursor ### 3 步搞定 diff --git a/docs/superpowers/plans/2026-06-09-windows-tray-mvp-implementation.md b/docs/superpowers/plans/2026-06-09-windows-tray-mvp-implementation.md index 0b34e64..d940137 100644 --- a/docs/superpowers/plans/2026-06-09-windows-tray-mvp-implementation.md +++ b/docs/superpowers/plans/2026-06-09-windows-tray-mvp-implementation.md @@ -30,36 +30,36 @@ ## Task 1: Python Structured Web Startup -- [ ] Write tests for `_build_startup_payload()` and `_parse_args()`. -- [ ] Run the new tests and confirm they fail because helpers do not exist. -- [ ] Add `main(argv=None)`, `--no-browser`, `--json`, `_build_startup_payload()`, `_parse_args()`. -- [ ] Run `python -m pytest tests/test_web_entrypoint.py tests/test_web_server.py -q`. +- [x] Write tests for `_build_startup_payload()` and `_parse_args()`. +- [x] Run the new tests and confirm they fail because helpers do not exist. +- [x] Add `main(argv=None)`, `--no-browser`, `--json`, `_build_startup_payload()`, `_parse_args()`. +- [x] Run `python -m pytest tests/test_web_entrypoint.py tests/test_web_server.py -q`. ## Task 2: Tauri Frontend Shell -- [ ] Create `desktop/cc-stats-tauri/package.json`, Vite config, TypeScript config, and frontend files. -- [ ] Write Node tests for dashboard URL normalization and status label helpers. -- [ ] Run `npm test` and confirm helper tests pass. +- [x] Create `desktop/cc-stats-tauri/package.json`, Vite config, TypeScript config, and frontend files. +- [x] Write Node tests for dashboard URL normalization and status label helpers. +- [x] Run `npm test` and confirm helper tests pass. ## Task 3: Tauri Rust Shell -- [ ] Create `src-tauri/Cargo.toml`, `tauri.conf.json`, `build.rs`, and Rust modules. -- [ ] Implement: +- [x] Create `src-tauri/Cargo.toml`, `tauri.conf.json`, `build.rs`, and Rust modules. +- [x] Implement: - API command construction: `python -m cc_stats_web --no-browser --json`. - Startup JSON parsing. - Health state enum. - Dashboard window open/focus command. - Tray menu commands: `Open Dashboard`, `Restart API`, `Quit`. -- [ ] Run `cargo test` if Rust is available. -- [ ] Run `cargo check` if Rust is available. +- [x] Run `cargo test` if Rust is available. +- [x] Run `cargo check` if Rust is available. ## Task 4: Docs and Verification -- [ ] Add Windows tray development notes to English and Chinese README files. -- [ ] Run full Python test suite. -- [ ] Run frontend Node tests. -- [ ] Run Rust tests/checks if toolchain is available. -- [ ] Run `git status` and confirm only intended source/docs files changed. +- [x] Add Windows tray development notes to English and Chinese README files. +- [x] Run full Python test suite. +- [x] Run frontend Node tests. +- [x] Run Rust tests/checks if toolchain is available. +- [x] Run `git status` and confirm only intended source/docs files changed. ## Self-Review From 08ecddf46820d34a52cad2edf9ffe6f218fb167b Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 20:57:58 +0800 Subject: [PATCH 09/26] fix: harden windows tray build and monitoring --- cc_stats/parser.py | 15 +++++++--- .../cc-stats-tauri/src-tauri/icons/icon.ico | Bin 0 -> 180 bytes desktop/cc-stats-tauri/src-tauri/src/main.rs | 28 +++++++++++++++++- desktop/cc-stats-tauri/src-tauri/src/tray.rs | 1 + .../cc-stats-tauri/src-tauri/tauri.conf.json | 3 ++ desktop/cc-stats-tauri/src/dashboard.js | 2 ++ desktop/cc-stats-tauri/src/main.js | 8 ++++- .../cc-stats-tauri/tests/frontend.test.mjs | 5 ++++ tests/test_token_cache.py | 2 ++ 9 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 desktop/cc-stats-tauri/src-tauri/icons/icon.ico diff --git a/cc_stats/parser.py b/cc_stats/parser.py index 6156d9a..da42448 100644 --- a/cc_stats/parser.py +++ b/cc_stats/parser.py @@ -176,6 +176,13 @@ def _normalized_project_path(path: Path | str) -> str: return os.path.normcase(resolved) +def _home_dir() -> Path: + home = os.environ.get("HOME") + if home: + return Path(home).expanduser() + return Path.home() + + def _is_subagent_file(path: Path) -> bool: return path.parent.name == "subagents" and path.name.startswith("agent-") @@ -221,7 +228,7 @@ def find_sessions( 如果指定 project_dir,只返回匹配的项目。 """ - claude_projects = projects_dir or Path.home() / ".claude" / "projects" + claude_projects = projects_dir or _home_dir() / ".claude" / "projects" if not claude_projects.exists(): return [] @@ -247,7 +254,7 @@ def find_sessions_by_keyword( """按关键词模糊匹配项目,在目录名和 JSONL 中的 cwd 中搜索""" import json - claude_projects = projects_dir or Path.home() / ".claude" / "projects" + claude_projects = projects_dir or _home_dir() / ".claude" / "projects" if not claude_projects.exists(): return [] @@ -706,7 +713,7 @@ def find_codex_sessions( codex_home_dir: Path | None = None, ) -> list[Path]: """查找 ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl 会话文件""" - codex_home = codex_home_dir or Path.home() / ".codex" + codex_home = codex_home_dir or _home_dir() / ".codex" base = codex_home / "sessions" if not base.exists(): return [] @@ -883,7 +890,7 @@ def find_gemini_sessions( gemini_home_dir: Path | None = None, ) -> list[Path]: """查找 ~/.gemini/tmp/*/chats/*.json 会话文件""" - gemini_home = gemini_home_dir or Path.home() / ".gemini" + gemini_home = gemini_home_dir or _home_dir() / ".gemini" gemini_dir = gemini_home / "tmp" if not gemini_dir.exists(): return [] diff --git a/desktop/cc-stats-tauri/src-tauri/icons/icon.ico b/desktop/cc-stats-tauri/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1e31dc51bc61d36d078e46e203f064c253b0feda GIT binary patch literal 180 zcmZQzU<5)32L>Riz%Y-2fk6z2I|KaOdAX#xfJ|Ob50@Yy?Eu0YY(R2VX5l>`mFnr@ z7*cWT?FC0c1_l-egNZG{VNyjF)oTtYt$w~)K*6Dbfsu)YgCfSe=+-NTyf#`gUIALj N;OXk;vd$@?2>?8pBU}Ig literal 0 HcmV?d00001 diff --git a/desktop/cc-stats-tauri/src-tauri/src/main.rs b/desktop/cc-stats-tauri/src-tauri/src/main.rs index 9c48a99..7be14b3 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/main.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -1,4 +1,8 @@ -use std::sync::Mutex; +use std::{ + sync::Mutex, + thread, + time::Duration, +}; use api_process::{ApiProcessManager, ApiStatus}; use health::ApiState; @@ -45,6 +49,27 @@ fn open_dashboard(app: AppHandle) -> Result<(), String> { window::show_dashboard_window(&app) } +fn start_api_health_monitor(app: AppHandle, initial_state: ApiState) { + thread::spawn(move || { + let mut last_state = initial_state; + loop { + thread::sleep(Duration::from_secs(3)); + let status = { + let state = app.state::(); + let mut api = state.api.lock().expect("api state poisoned"); + api.status() + }; + + if status.state == ApiState::Failed && last_state != ApiState::Failed { + if let Some(error) = status.error.as_deref() { + notifications::api_start_failed(&app, error); + } + } + last_state = status.state; + } + }); +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) @@ -57,6 +82,7 @@ fn main() { app.manage(AppState { api: Mutex::new(api), }); + start_api_health_monitor(app.handle().clone(), initial_status.state); tray::build_tray(app.handle())?; Ok(()) }) diff --git a/desktop/cc-stats-tauri/src-tauri/src/tray.rs b/desktop/cc-stats-tauri/src-tauri/src/tray.rs index 7b5fc50..8cec70a 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/tray.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/tray.rs @@ -14,6 +14,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { TrayIconBuilder::new() .menu(&menu) + .tooltip("CC Statistics") .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "open_dashboard" => { diff --git a/desktop/cc-stats-tauri/src-tauri/tauri.conf.json b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json index c18afd9..5eea77a 100644 --- a/desktop/cc-stats-tauri/src-tauri/tauri.conf.json +++ b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json @@ -25,6 +25,9 @@ }, "bundle": { "active": true, + "icon": [ + "icons/icon.ico" + ], "targets": "all" } } diff --git a/desktop/cc-stats-tauri/src/dashboard.js b/desktop/cc-stats-tauri/src/dashboard.js index 75f174d..12e0f54 100644 --- a/desktop/cc-stats-tauri/src/dashboard.js +++ b/desktop/cc-stats-tauri/src/dashboard.js @@ -1,3 +1,5 @@ +export const STATUS_POLL_INTERVAL_MS = 3000; + export function normalizeApiBaseUrl(url) { return String(url || "").replace(/\/+$/, ""); } diff --git a/desktop/cc-stats-tauri/src/main.js b/desktop/cc-stats-tauri/src/main.js index 99d473d..07e4a38 100644 --- a/desktop/cc-stats-tauri/src/main.js +++ b/desktop/cc-stats-tauri/src/main.js @@ -1,5 +1,5 @@ import { apiStatus, openDashboard, restartApi } from "./apiClient.js"; -import { frameUrlForStatus, statusLabel } from "./dashboard.js"; +import { frameUrlForStatus, STATUS_POLL_INTERVAL_MS, statusLabel } from "./dashboard.js"; const statusEl = document.querySelector("[data-status]"); const frameEl = document.querySelector("[data-dashboard-frame]"); @@ -28,3 +28,9 @@ restartBtn?.addEventListener("click", () => { refreshStatus().catch((error) => { statusEl.textContent = `Status unavailable: ${error}`; }); + +setInterval(() => { + refreshStatus().catch((error) => { + statusEl.textContent = `Status unavailable: ${error}`; + }); +}, STATUS_POLL_INTERVAL_MS); diff --git a/desktop/cc-stats-tauri/tests/frontend.test.mjs b/desktop/cc-stats-tauri/tests/frontend.test.mjs index ff3efb8..dc86cf4 100644 --- a/desktop/cc-stats-tauri/tests/frontend.test.mjs +++ b/desktop/cc-stats-tauri/tests/frontend.test.mjs @@ -5,6 +5,7 @@ import { dashboardUrl, frameUrlForStatus, normalizeApiBaseUrl, + STATUS_POLL_INTERVAL_MS, statusLabel, } from "../src/dashboard.js"; @@ -37,3 +38,7 @@ test("frameUrlForStatus clears stale dashboard on api failure", () => { "http://127.0.0.1:61234/", ); }); + +test("status polling interval is responsive without hammering the api", () => { + assert.equal(STATUS_POLL_INTERVAL_MS, 3000); +}); diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 81f6d4a..2d00339 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -99,6 +99,8 @@ def test_write_and_read(self) -> None: self.assertEqual(result.cached_at, 1000.0) def test_write_sets_permissions(self) -> None: + if os.name == "nt": + self.skipTest("POSIX file mode bits are not meaningful on Windows") td = TokenData(access_token="secret", cached_at=1.0) write_cached_token(td) mode = self.tmp_file.stat().st_mode & 0o777 From a6d587667c93b7a60c322c49e5772d5ee2dc8a92 Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 21:53:18 +0800 Subject: [PATCH 10/26] fix: keep tray dashboard health responsive --- cc_stats_web/server.py | 10 ++- .../src-tauri/src/api_process.rs | 78 +++++++++++++++++-- .../cc-stats-tauri/src-tauri/src/health.rs | 42 +++++++--- tests/test_web_server.py | 38 +++++++++ 4 files changed, 150 insertions(+), 18 deletions(-) diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index c5af446..4a9ae91 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -7,7 +7,7 @@ import socket from collections import defaultdict from datetime import datetime, timedelta, timezone -from http.server import HTTPServer, SimpleHTTPRequestHandler +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from cc_stats.analyzer import ( @@ -349,13 +349,17 @@ def log_message(self, format, *args): pass +class CcStatsHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] -def start_server() -> tuple[HTTPServer, int]: +def start_server() -> tuple[CcStatsHTTPServer, int]: port = find_free_port() - server = HTTPServer(("127.0.0.1", port), ApiHandler) + server = CcStatsHTTPServer(("127.0.0.1", port), ApiHandler) return server, port diff --git a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs index fbb04d4..ef4cc65 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; use crate::health::{is_api_healthy, ApiState}; +const HEALTH_FAILURE_THRESHOLD: u8 = 3; + #[derive(Clone, Debug, Serialize)] pub struct ApiStatus { pub state: ApiState, @@ -19,6 +21,7 @@ pub struct ApiStatus { pub struct ApiProcessManager { child: Option, + health_failures: u8, status: ApiStatus, } @@ -26,6 +29,7 @@ impl ApiProcessManager { pub fn start_default() -> Self { let mut manager = Self { child: None, + health_failures: 0, status: ApiStatus { state: ApiState::Starting, url: None, @@ -49,6 +53,7 @@ impl ApiProcessManager { pub fn failed(error: String) -> Self { Self { child: None, + health_failures: 0, status: ApiStatus { state: ApiState::Failed, url: None, @@ -66,6 +71,7 @@ impl ApiProcessManager { self.stop(); let mut next = Self::start_default(); self.child = next.child.take(); + self.health_failures = next.health_failures; self.status = next.status.clone(); self.status() } @@ -75,11 +81,12 @@ impl ApiProcessManager { let _ = child.kill(); let _ = child.wait(); } + self.health_failures = 0; self.status.state = ApiState::Stopped; } fn refresh_status(&mut self) { - if self.status.state != ApiState::Running { + if !matches!(self.status.state, ApiState::Running | ApiState::Failed) { return; } @@ -87,12 +94,14 @@ impl ApiProcessManager { match child.try_wait() { Ok(Some(exit_status)) => { self.child = None; + self.health_failures = HEALTH_FAILURE_THRESHOLD; self.status.state = ApiState::Failed; self.status.error = Some(format!("cc_stats_web exited with {exit_status}")); return; } Ok(None) => {} Err(error) => { + self.health_failures = HEALTH_FAILURE_THRESHOLD; self.status.state = ApiState::Failed; self.status.error = Some(format!("failed to inspect cc_stats_web: {error}")); return; @@ -101,15 +110,26 @@ impl ApiProcessManager { } let Some(url) = self.status.url.as_deref() else { + self.health_failures = HEALTH_FAILURE_THRESHOLD; self.status.state = ApiState::Failed; self.status.error = Some("cc_stats_web health check failed: missing API URL".to_string()); return; }; - if !is_api_healthy(url) { - self.status.state = ApiState::Failed; - self.status.error = Some(format!("cc_stats_web health check failed for {url}")); + if is_api_healthy(url) { + self.health_failures = 0; + self.status.state = ApiState::Running; + self.status.error = None; + return; + } + + if self.status.state == ApiState::Running { + self.health_failures = self.health_failures.saturating_add(1); + if self.health_failures >= HEALTH_FAILURE_THRESHOLD { + self.status.state = ApiState::Failed; + self.status.error = Some(format!("cc_stats_web health check failed for {url}")); + } } } @@ -117,6 +137,7 @@ impl ApiProcessManager { fn running_for_test(url: &str) -> Self { Self { child: None, + health_failures: 0, status: ApiStatus { state: ApiState::Running, url: Some(url.to_string()), @@ -254,6 +275,7 @@ struct StartupPayload { mod tests { use super::{ build_api_command, candidate_python_commands, parse_startup_url, ApiProcessManager, + ApiStatus, }; use crate::health::ApiState; @@ -292,13 +314,59 @@ mod tests { } #[test] - fn status_marks_running_manager_failed_when_health_probe_fails() { + fn status_tolerates_transient_health_probe_failures() { + let mut manager = ApiProcessManager::running_for_test("http://localhost:61234/"); + + let status = manager.status(); + + assert_eq!(status.state, ApiState::Running); + assert_eq!(status.url.as_deref(), Some("http://localhost:61234/")); + assert_eq!(status.error, None); + } + + #[test] + fn status_marks_running_manager_failed_after_repeated_health_probe_failures() { let mut manager = ApiProcessManager::running_for_test("http://localhost:61234/"); + manager.status(); + manager.status(); let status = manager.status(); assert_eq!(status.state, ApiState::Failed); assert_eq!(status.url.as_deref(), Some("http://localhost:61234/")); assert!(status.error.unwrap().contains("health check failed")); } + + #[test] + fn failed_manager_recovers_when_health_probe_succeeds() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 512]; + let _ = std::io::Read::read(&mut stream, &mut request).unwrap(); + std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}", + ) + .unwrap(); + }); + let url = format!("http://127.0.0.1:{port}/"); + let mut manager = ApiProcessManager { + child: None, + health_failures: 3, + status: ApiStatus { + state: ApiState::Failed, + url: Some(url.clone()), + error: Some("cc_stats_web health check failed".to_string()), + }, + }; + + let status = manager.status(); + + assert_eq!(status.state, ApiState::Running); + assert_eq!(status.url.as_deref(), Some(url.as_str())); + assert_eq!(status.error, None); + handle.join().unwrap(); + } } diff --git a/desktop/cc-stats-tauri/src-tauri/src/health.rs b/desktop/cc-stats-tauri/src-tauri/src/health.rs index 19d35d0..aede5c9 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/health.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/health.rs @@ -6,6 +6,8 @@ use std::{ use serde::Serialize; +const HEALTH_TIMEOUT: Duration = Duration::from_secs(5); + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum ApiState { @@ -20,24 +22,23 @@ pub fn is_api_healthy(api_url: &str) -> bool { return false; }; let addr = SocketAddr::from(([127, 0, 0, 1], port)); - let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { + let Ok(mut stream) = TcpStream::connect_timeout(&addr, HEALTH_TIMEOUT) else { return false; }; - let _ = stream.set_read_timeout(Some(Duration::from_millis(800))); - let _ = stream.set_write_timeout(Some(Duration::from_millis(800))); + let _ = stream.set_read_timeout(Some(HEALTH_TIMEOUT)); + let _ = stream.set_write_timeout(Some(HEALTH_TIMEOUT)); let request = - format!("GET /api/health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); + format!("GET /api/health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); if stream.write_all(request.as_bytes()).is_err() { return false; } - let mut response = [0; 64]; - let Ok(read) = stream.read(&mut response) else { + let mut response = String::new(); + if stream.read_to_string(&mut response).is_err() { return false; - }; - let status = String::from_utf8_lossy(&response[..read]); - status.starts_with("HTTP/1.0 200") || status.starts_with("HTTP/1.1 200") + } + response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200") } fn parse_local_api_port(api_url: &str) -> Option { @@ -85,7 +86,7 @@ mod tests { let mut request = [0; 512]; let read = std::io::Read::read(&mut stream, &mut request).unwrap(); let request = String::from_utf8_lossy(&request[..read]); - assert!(request.starts_with("GET /api/health HTTP/1.1")); + assert!(request.starts_with("GET /api/health HTTP/1.0")); std::io::Write::write_all( &mut stream, b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}", @@ -97,6 +98,27 @@ mod tests { handle.join().unwrap(); } + #[test] + fn api_health_probe_reads_split_status_response() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 512]; + let _ = std::io::Read::read(&mut stream, &mut request).unwrap(); + std::io::Write::write_all(&mut stream, b"HTTP/1.1 ").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(75)); + std::io::Write::write_all( + &mut stream, + b"200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\n{\"status\":\"ok\"}", + ) + .unwrap(); + }); + + assert!(super::is_api_healthy(&format!("http://127.0.0.1:{port}/"))); + handle.join().unwrap(); + } + #[test] fn api_health_probe_rejects_non_local_url() { assert!(!super::is_api_healthy("http://localhost:61234/")); diff --git a/tests/test_web_server.py b/tests/test_web_server.py index c1c79bc..7099808 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -2,6 +2,7 @@ import json import threading +import time import urllib.request from pathlib import Path @@ -151,6 +152,43 @@ def test_health_endpoint_returns_ok() -> None: assert payload == {"status": "ok"} +def test_health_endpoint_responds_while_stats_request_is_busy(monkeypatch) -> None: + def slow_stats(*args, **kwargs): + time.sleep(0.4) + return {"ok": True} + + monkeypatch.setattr("cc_stats_web.server._get_stats", slow_stats) + server, port = start_server() + thread = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.1}, + daemon=True, + ) + thread.start() + stats_thread = threading.Thread( + target=lambda: urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/stats", timeout=2 + ).read(), + daemon=True, + ) + + try: + stats_thread.start() + time.sleep(0.05) + start = time.monotonic() + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1) as resp: + payload = json.loads(resp.read().decode("utf-8")) + elapsed = time.monotonic() - start + finally: + stats_thread.join(timeout=2) + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert payload == {"status": "ok"} + assert elapsed < 0.3 + + def test_collect_session_files_source_codex_returns_codex_file( tmp_path: Path, monkeypatch, From d655daed4333b96f9e42e98183c6765950aa4224 Mon Sep 17 00:00:00 2001 From: efunyang Date: Tue, 9 Jun 2026 23:47:51 +0800 Subject: [PATCH 11/26] fix: show dashboard loading and partial results --- cc_stats_web/web/index.html | 70 +++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/cc_stats_web/web/index.html b/cc_stats_web/web/index.html index bc65731..569633b 100644 --- a/cc_stats_web/web/index.html +++ b/cc_stats_web/web/index.html @@ -133,10 +133,10 @@

CC Statistics

-
Instructions
-
Tool Calls
-
Active Time
-
Est. Cost
+
Instructions
...
+
Tool Calls
...
+
Active Time
...
+
Est. Cost
...
Daily Trend
@@ -150,7 +150,7 @@

CC Statistics

diff --git a/tests/test_web_dashboard_markup.py b/tests/test_web_dashboard_markup.py new file mode 100644 index 0000000..5f06147 --- /dev/null +++ b/tests/test_web_dashboard_markup.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + + +DASHBOARD_HTML = Path(__file__).resolve().parents[1] / "cc_stats_web" / "web" / "index.html" + + +def test_dashboard_markup_exposes_compact_tray_layout_sections() -> None: + html = DASHBOARD_HTML.read_text(encoding="utf-8") + + required_fragments = [ + 'class="dashboard-shell"', + 'class="top-toolbar"', + 'class="period-tabs"', + 'data-days="1">今天', + 'data-days="7">本周', + 'data-days="30">本月', + 'data-days="0">全部', + 'id="token-card"', + 'id="cache-section"', + 'id="trend-card"', + 'data-trend="cost"', + 'data-trend="tokens"', + 'data-trend="sessions"', + 'data-trend="time"', + 'id="forecast-card"', + 'id="dashboard-footer"', + 'id="refresh-button"', + ] + + for fragment in required_fragments: + assert fragment in html From 5fadf26cb8c88e5ec2b9d873235bad1703a20012 Mon Sep 17 00:00:00 2001 From: efunyang Date: Wed, 10 Jun 2026 01:12:48 +0800 Subject: [PATCH 16/26] fix: open dashboard button in browser --- desktop/cc-stats-tauri/src-tauri/Cargo.lock | 1 + desktop/cc-stats-tauri/src-tauri/Cargo.toml | 3 + .../src-tauri/src/external_browser.rs | 120 ++++++++++++++++++ desktop/cc-stats-tauri/src-tauri/src/main.rs | 10 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 desktop/cc-stats-tauri/src-tauri/src/external_browser.rs diff --git a/desktop/cc-stats-tauri/src-tauri/Cargo.lock b/desktop/cc-stats-tauri/src-tauri/Cargo.lock index 916cbe3..6848283 100644 --- a/desktop/cc-stats-tauri/src-tauri/Cargo.lock +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.lock @@ -429,6 +429,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-notification", + "windows-sys 0.61.2", ] [[package]] diff --git a/desktop/cc-stats-tauri/src-tauri/Cargo.toml b/desktop/cc-stats-tauri/src-tauri/Cargo.toml index c320a87..f0c594d 100644 --- a/desktop/cc-stats-tauri/src-tauri/Cargo.toml +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.toml @@ -14,3 +14,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-notification = "2.3.3" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/desktop/cc-stats-tauri/src-tauri/src/external_browser.rs b/desktop/cc-stats-tauri/src-tauri/src/external_browser.rs new file mode 100644 index 0000000..6e56753 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/external_browser.rs @@ -0,0 +1,120 @@ +use crate::{api_process::ApiStatus, health::ApiState}; + +pub fn dashboard_url_for_open(status: &ApiStatus) -> Result { + if status.state != ApiState::Running { + return Err(format_not_ready(status.error.as_deref())); + } + + let url = status + .url + .as_deref() + .ok_or_else(|| format_not_ready(Some("missing dashboard URL")))?; + if !is_local_dashboard_url(url) { + return Err(format!("dashboard URL is not local: {url}")); + } + Ok(url.to_string()) +} + +pub fn open_dashboard_url(url: &str) -> Result<(), String> { + if !is_local_dashboard_url(url) { + return Err(format!("dashboard URL is not local: {url}")); + } + open_url(url) +} + +fn format_not_ready(error: Option<&str>) -> String { + match error { + Some(error) if !error.is_empty() => format!("dashboard is not ready: {error}"), + _ => "dashboard is not ready".to_string(), + } +} + +fn is_local_dashboard_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("http://127.0.0.1:") else { + return false; + }; + let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count(); + if digit_count == 0 { + return false; + } + let suffix = &rest[digit_count..]; + suffix.is_empty() || suffix.starts_with('/') +} + +#[cfg(target_os = "windows")] +fn open_url(url: &str) -> Result<(), String> { + use std::{ffi::OsStr, iter, os::windows::ffi::OsStrExt, ptr}; + + use windows_sys::Win32::{ + UI::Shell::ShellExecuteW, + UI::WindowsAndMessaging::SW_SHOWNORMAL, + }; + + fn wide(value: &str) -> Vec { + OsStr::new(value).encode_wide().chain(iter::once(0)).collect() + } + + let operation = wide("open"); + let target = wide(url); + let result = unsafe { + ShellExecuteW( + ptr::null_mut(), + operation.as_ptr(), + target.as_ptr(), + ptr::null(), + ptr::null(), + SW_SHOWNORMAL, + ) + }; + + let code = result as isize; + if code <= 32 { + return Err(format!("failed to open dashboard URL: ShellExecuteW returned {code}")); + } + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn open_url(_url: &str) -> Result<(), String> { + Err("opening the dashboard externally is only implemented for Windows".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn status(state: ApiState, url: Option<&str>, error: Option<&str>) -> ApiStatus { + ApiStatus { + state, + url: url.map(str::to_string), + error: error.map(str::to_string), + } + } + + #[test] + fn dashboard_url_for_open_uses_running_local_api_url() { + let status = status(ApiState::Running, Some("http://127.0.0.1:61234/"), None); + + assert_eq!( + dashboard_url_for_open(&status), + Ok("http://127.0.0.1:61234/".to_string()) + ); + } + + #[test] + fn dashboard_url_for_open_rejects_non_local_urls() { + let status = status(ApiState::Running, Some("https://example.com/"), None); + + assert!(dashboard_url_for_open(&status).is_err()); + } + + #[test] + fn dashboard_url_for_open_reports_failed_api_error() { + let status = status(ApiState::Failed, None, Some("python missing")); + + assert_eq!( + dashboard_url_for_open(&status), + Err("dashboard is not ready: python missing".to_string()) + ); + } +} diff --git a/desktop/cc-stats-tauri/src-tauri/src/main.rs b/desktop/cc-stats-tauri/src-tauri/src/main.rs index f70d15a..35d2677 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/main.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -11,6 +11,7 @@ use health::ApiState; use tauri::{AppHandle, Manager, State}; mod api_process; +mod external_browser; mod health; mod notifications; mod tray; @@ -47,8 +48,13 @@ fn restart_api(app: AppHandle, state: State<'_, AppState>) -> Result Result<(), String> { - window::show_dashboard_window(&app) +fn open_dashboard(state: State<'_, AppState>) -> Result<(), String> { + let status = { + let mut api = state.api.lock().expect("api state poisoned"); + api.status() + }; + let url = external_browser::dashboard_url_for_open(&status)?; + external_browser::open_dashboard_url(&url) } fn start_api_health_monitor(app: AppHandle, initial_state: ApiState) { From e83e1f685f9f98bd604fba5ce67300588eadecaf Mon Sep 17 00:00:00 2001 From: efunyang Date: Wed, 10 Jun 2026 01:39:19 +0800 Subject: [PATCH 17/26] fix: support gemini cli jsonl sessions --- cc_stats/parser.py | 141 ++++++++++++++++++++++++++++++++++++--- tests/test_sources.py | 76 +++++++++++++++++++++ tests/test_web_server.py | 56 ++++++++++++++++ 3 files changed, 264 insertions(+), 9 deletions(-) diff --git a/cc_stats/parser.py b/cc_stats/parser.py index da42448..c6a794c 100644 --- a/cc_stats/parser.py +++ b/cc_stats/parser.py @@ -872,6 +872,110 @@ def parse_gemini_json(path: Path) -> Session: ) +def parse_gemini_jsonl(path: Path) -> Session: + """Parse Gemini CLI JSONL session files written under ~/.gemini/tmp/*/chats.""" + session_id = path.stem + project_path = "" + messages: list[Message] = [] + + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + + if "sessionId" in record: + session_id = record.get("sessionId") or session_id + if not project_path: + project_path = _gemini_jsonl_project_path(path) + continue + + msg_type = record.get("type", "") + timestamp = record.get("timestamp", "") + if msg_type == "user": + messages.append(Message( + role="user", + timestamp=timestamp, + content=_extract_gemini_content(record.get("content")), + session_id=session_id, + message_id=record.get("id", ""), + )) + elif msg_type == "gemini": + tool_calls: list[ToolCall] = [] + for tc in record.get("toolCalls", []) or []: + if not isinstance(tc, dict): + continue + raw_name = tc.get("name", "") + mapped_name = _GEMINI_TOOL_MAP.get(raw_name, raw_name) + tool_calls.append(ToolCall( + name=mapped_name, + input=tc.get("args", {}), + timestamp=tc.get("timestamp", timestamp), + tool_use_id=tc.get("id", ""), + )) + + usage: dict[str, Any] = {} + tokens = record.get("tokens") + if isinstance(tokens, dict): + usage = { + "input_tokens": tokens.get("input", 0), + "output_tokens": tokens.get("output", 0), + "cache_read_input_tokens": tokens.get("cached", 0), + "cache_creation_input_tokens": 0, + } + + messages.append(Message( + role="assistant", + timestamp=timestamp, + content=_extract_gemini_content(record.get("content")), + model=record.get("model"), + usage=usage, + tool_calls=tool_calls, + session_id=session_id, + message_id=record.get("id", ""), + )) + + if not project_path: + project_path = _gemini_jsonl_project_path(path) + + return Session( + session_id=session_id, + project_path=project_path, + file_path=path, + source="gemini", + messages=messages, + ) + + +def _gemini_jsonl_project_path(path: Path) -> str: + project_root = path.parent.parent / ".project_root" + try: + value = project_root.read_text(encoding="utf-8").strip() + if value: + return value + except OSError: + pass + + project_slug = path.parent.parent.name + projects_json = path.parent.parent.parent.parent / "projects.json" + try: + data = json.loads(projects_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return "" + + projects = data.get("projects", {}) + if not isinstance(projects, dict): + return "" + for project_path, slug in projects.items(): + if slug == project_slug: + return project_path + return "" + + def _extract_gemini_content(raw: Any) -> Any: """提取 Gemini 消息内容(可能是字符串或 Part 列表)""" if isinstance(raw, str): @@ -899,8 +1003,8 @@ def find_gemini_sessions( for chats_dir in gemini_dir.glob("*/chats"): if not chats_dir.is_dir(): continue - for json_file in sorted(chats_dir.glob("*.json")): - results.append(json_file) + results.extend(sorted(chats_dir.glob("*.json"))) + results.extend(sorted(chats_dir.glob("*.jsonl"))) return results @@ -920,25 +1024,44 @@ def find_gemini_sessions_by_keyword( for path in all_sessions: try: - with open(path, encoding="utf-8") as f: - data = json.load(f) - dirs = data.get("directories", []) - if any(keyword_lower in d.lower() for d in dirs): + session = parse_session_file(path) + if keyword_lower in session.project_path.lower(): results.append(path) continue - summary = data.get("summary", "") - if summary and keyword_lower in summary.lower(): + content = "\n".join( + str(message.content) + for message in session.messages + if message.content + ) + if keyword_lower in content.lower(): results.append(path) - except (json.JSONDecodeError, OSError): + except (ValueError, OSError): continue return results +def _looks_like_gemini_jsonl(path: Path) -> bool: + if path.suffix != ".jsonl" or path.parent.name != "chats": + return False + try: + with open(path, encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + obj = json.loads(line) + return "sessionId" in obj and "projectHash" in obj + except (json.JSONDecodeError, OSError): + return False + return False + + def parse_session_file(path: Path) -> Session: """自动识别并解析会话文件(Claude / Codex / Gemini)""" if path.suffix == ".json": return parse_gemini_json(path) + if _looks_like_gemini_jsonl(path): + return parse_gemini_jsonl(path) if _looks_like_codex_jsonl(path): return parse_codex_jsonl(path) return parse_jsonl(path) diff --git a/tests/test_sources.py b/tests/test_sources.py index a215a68..1845afb 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -8,6 +8,7 @@ collect_session_files, collect_session_files_by_keyword, list_projects, + parse_file, ) @@ -62,6 +63,45 @@ def _write_gemini_session(gemini_home: Path, name: str, cwd: Path) -> Path: return path +def _write_gemini_jsonl_session(gemini_home: Path, name: str, cwd: Path) -> Path: + project_dir = gemini_home / "tmp" / "demo-project" + (project_dir / "chats").mkdir(parents=True, exist_ok=True) + (project_dir / ".project_root").write_text(str(cwd), encoding="utf-8") + path = project_dir / "chats" / f"session-2026-06-09T17-21-{name}.jsonl" + return _write_jsonl(path, [ + { + "sessionId": name, + "projectHash": "project-hash", + "startTime": "2026-06-09T17:21:00Z", + "lastUpdated": "2026-06-09T17:22:00Z", + "kind": "main", + }, + {"$set": {"summary": "needle summary"}}, + { + "id": "user-1", + "timestamp": "2026-06-09T17:21:01Z", + "type": "user", + "content": [{"text": "hello from gemini"}], + }, + { + "id": "gemini-1", + "timestamp": "2026-06-09T17:21:02Z", + "type": "gemini", + "content": "done", + "model": "gemini-2.5-pro", + "tokens": {"input": 100, "output": 20, "cached": 30, "tool": 5}, + "toolCalls": [ + { + "id": "tool-1", + "name": "read_file", + "args": {"path": "README.md"}, + "timestamp": "2026-06-09T17:21:02Z", + } + ], + }, + ]) + + def _write_claude_session(claude_projects: Path, project_name: str, cwd: Path) -> Path: path = claude_projects / project_name / "session-a.jsonl" return _write_jsonl(path, [ @@ -100,6 +140,42 @@ def test_collect_session_files_all_includes_codex_synthetic_sessions( assert gemini_file in files +def test_collect_session_files_source_gemini_includes_jsonl_sessions( + tmp_path: Path, + monkeypatch, +) -> None: + _, _, gemini_home = _set_source_homes(monkeypatch, tmp_path) + gemini_file = _write_gemini_jsonl_session(gemini_home, "gemini-jsonl", tmp_path / "demo") + + files = collect_session_files(source=SourceKind.GEMINI) + + assert files == [gemini_file] + + +def test_parse_gemini_jsonl_maps_project_tokens_and_tools( + tmp_path: Path, + monkeypatch, +) -> None: + _, _, gemini_home = _set_source_homes(monkeypatch, tmp_path) + project_dir = tmp_path / "demo" + gemini_file = _write_gemini_jsonl_session(gemini_home, "gemini-jsonl", project_dir) + + session = parse_file(gemini_file) + + assert session.source == "gemini" + assert session.session_id == "gemini-jsonl" + assert session.project_path == str(project_dir) + assert [message.role for message in session.messages] == ["user", "assistant"] + assert session.messages[1].model == "gemini-2.5-pro" + assert session.messages[1].usage == { + "input_tokens": 100, + "output_tokens": 20, + "cache_read_input_tokens": 30, + "cache_creation_input_tokens": 0, + } + assert session.messages[1].tool_calls[0].name == "Read" + + def test_list_projects_source_codex_groups_by_cwd(tmp_path: Path, monkeypatch) -> None: _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) project_dir = tmp_path / "demo" diff --git a/tests/test_web_server.py b/tests/test_web_server.py index ad65cf7..3f0bea4 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -127,6 +127,44 @@ def _write_gemini_session(gemini_home: Path, name: str, cwd: Path) -> Path: return path +def _write_gemini_jsonl_session(gemini_home: Path, name: str, cwd: Path) -> Path: + project_dir = gemini_home / "tmp" / "demo-project" + (project_dir / "chats").mkdir(parents=True, exist_ok=True) + (project_dir / ".project_root").write_text(str(cwd), encoding="utf-8") + path = project_dir / "chats" / f"session-2026-06-09T17-21-{name}.jsonl" + return _write_jsonl(path, [ + { + "sessionId": name, + "projectHash": "project-hash", + "startTime": "2026-06-09T17:21:00Z", + "lastUpdated": "2026-06-09T17:22:00Z", + "kind": "main", + }, + { + "id": "user-1", + "timestamp": "2026-06-09T17:21:01Z", + "type": "user", + "content": [{"text": "hello from gemini"}], + }, + { + "id": "gemini-1", + "timestamp": "2026-06-09T17:21:02Z", + "type": "gemini", + "content": "done", + "model": "gemini-2.5-pro", + "tokens": {"input": 100, "output": 20, "cached": 30}, + "toolCalls": [ + { + "id": "tool-1", + "name": "read_file", + "args": {"path": "README.md"}, + "timestamp": "2026-06-09T17:21:02Z", + } + ], + }, + ]) + + def _write_claude_session(claude_projects: Path, project_name: str, cwd: Path) -> Path: path = claude_projects / project_name / "session-a.jsonl" return _write_jsonl(path, [ @@ -255,6 +293,24 @@ def test_get_stats_source_codex_parses_user_message_and_token_usage( assert stats["token_usage"]["total"] == 110 +def test_get_stats_source_gemini_parses_windows_jsonl_sessions( + tmp_path: Path, + monkeypatch, +) -> None: + _, _, gemini_home = _set_source_homes(monkeypatch, tmp_path) + _write_gemini_jsonl_session(gemini_home, "gemini-jsonl", tmp_path / "demo") + + stats = _get_stats(source="gemini") + + assert stats["session_count"] == 1 + assert stats["user_message_count"] == 1 + assert stats["tool_calls"] == [{"name": "Read", "count": 1}] + assert stats["token_usage"]["input_tokens"] == 100 + assert stats["token_usage"]["cache_read"] == 30 + assert stats["token_usage"]["output_tokens"] == 20 + assert stats["token_usage"]["total"] == 150 + + def test_get_stats_prefilters_old_mtime_before_parsing( tmp_path: Path, monkeypatch, From de51eeb154cd96a5ee24af9a41b30dcf6fed14b7 Mon Sep 17 00:00:00 2001 From: efunyang Date: Wed, 10 Jun 2026 02:14:57 +0800 Subject: [PATCH 18/26] fix: address windows tray p1 p2 review items --- cc_stats/parser.py | 296 ++++++++++++++++++ cc_stats/sources.py | 105 +++++-- cc_stats_web/server.py | 217 ++++++++++--- cc_stats_web/web/index.html | 24 +- .../src-tauri/src/api_process.rs | 229 ++++++++++---- .../cc-stats-tauri/src-tauri/src/health.rs | 25 +- desktop/cc-stats-tauri/src-tauri/src/main.rs | 61 +++- desktop/cc-stats-tauri/src-tauri/src/tray.rs | 8 +- .../cc-stats-tauri/src-tauri/tauri.conf.json | 4 + tests/test_sources.py | 114 +++++++ tests/test_web_server.py | 97 ++++++ tests/test_windows_tauri_shell.py | 33 ++ 12 files changed, 1062 insertions(+), 151 deletions(-) diff --git a/cc_stats/parser.py b/cc_stats/parser.py index c6a794c..a709861 100644 --- a/cc_stats/parser.py +++ b/cc_stats/parser.py @@ -4,9 +4,11 @@ import json import os +import sqlite3 from dataclasses import dataclass, field from pathlib import Path from typing import Any +from urllib.parse import unquote, urlparse @dataclass @@ -1056,8 +1058,302 @@ def _looks_like_gemini_jsonl(path: Path) -> bool: return False +# Cursor SQLite parsing + + +def find_cursor_sessions( + *, + cursor_state_db_path: Path | None = None, +) -> list[Path]: + db_path = cursor_state_db_path or _home_dir() / ".config" / "Cursor" / "User" / "globalStorage" / "state.vscdb" + return [db_path] if db_path.exists() else [] + + +def parse_cursor_db(path: Path) -> Session: + """Parse Cursor's global SQLite state DB as one aggregate session.""" + sessions = parse_cursor_sessions(path) + messages: list[Message] = [] + project_path = "" + for session in sessions: + if not project_path and session.project_path: + project_path = session.project_path + messages.extend(session.messages) + return Session( + session_id="cursor", + project_path=project_path or "Cursor", + file_path=path, + source="cursor", + messages=messages, + ) + + +def parse_cursor_sessions(path: Path) -> list[Session]: + """Parse Cursor composer sessions from User/globalStorage/state.vscdb.""" + if not path.exists(): + return [] + + try: + con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + except sqlite3.Error: + return [] + + try: + rows = con.execute( + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'" + ).fetchall() + bubbles_by_composer = _cursor_bubbles_by_composer(con) + sessions: list[Session] = [] + for key, raw_value in rows: + composer = _cursor_json(raw_value) + if not isinstance(composer, dict): + continue + composer_id = str(composer.get("composerId") or str(key).split(":", 1)[-1]) + session = _parse_cursor_composer( + bubbles_by_composer.get(composer_id, {}), + path, + str(key), + composer, + ) + if session is not None: + sessions.append(session) + return sorted( + sessions, + key=lambda s: next((m.timestamp for m in s.messages if m.timestamp), ""), + ) + except sqlite3.Error: + return [] + finally: + con.close() + + +def _parse_cursor_composer( + bubbles: dict[str, dict[str, Any]], + db_path: Path, + key: str, + composer: dict[str, Any], +) -> Session | None: + composer_id = str(composer.get("composerId") or key.split(":", 1)[-1]) + if not composer_id: + return None + + model = _cursor_model(composer) + default_ts = _cursor_timestamp(composer.get("createdAt")) + messages: list[Message] = [] + project_path = "" + + headers = composer.get("fullConversationHeadersOnly") + if not isinstance(headers, list) or not headers: + conversation_map = composer.get("conversationMap") + if isinstance(conversation_map, dict): + headers = [ + {"bubbleId": bubble_id} + for bubble_id in conversation_map.keys() + if isinstance(bubble_id, str) + ] + else: + headers = [] + + for header in headers: + if not isinstance(header, dict): + continue + bubble_id = header.get("bubbleId") + if not isinstance(bubble_id, str) or not bubble_id: + continue + bubble = bubbles.get(bubble_id, {}) + if not isinstance(bubble, dict): + bubble = {} + bubble_type = bubble.get("type", header.get("type")) + role = "user" if bubble_type == 1 else "assistant" if bubble_type == 2 else "" + if not role: + continue + + if not project_path: + project_path = _cursor_project_path(bubble) or _cursor_project_path(composer) + + timestamp = _cursor_timestamp(bubble.get("createdAt")) or default_ts + bubble_model = _cursor_model(bubble) or model + usage: dict[str, Any] = {} + if role == "assistant": + usage = _cursor_usage(bubble.get("tokenCount")) + + messages.append(Message( + role=role, + timestamp=timestamp, + content=_cursor_text(bubble), + model=bubble_model or None, + usage=usage, + session_id=composer_id, + message_id=bubble_id, + )) + + added = _to_int(composer.get("totalLinesAdded", 0)) + removed = _to_int(composer.get("totalLinesRemoved", 0)) + if added or removed: + timestamp = _cursor_timestamp(composer.get("lastUpdatedAt")) or default_ts + messages.append(Message( + role="assistant", + timestamp=timestamp, + content="", + model=model or None, + tool_calls=[ + ToolCall( + name="Edit", + input={ + "target_file": "cursor://composer", + "old_string": _cursor_line_blob(removed), + "new_string": _cursor_line_blob(added), + }, + timestamp=timestamp, + ) + ], + is_meta=True, + session_id=composer_id, + )) + + if not messages: + return None + if not project_path: + project_path = _cursor_project_path(composer) or "Cursor" + + return Session( + session_id=composer_id, + project_path=project_path, + file_path=db_path, + source="cursor", + messages=messages, + ) + + +def _cursor_bubbles_by_composer( + con: sqlite3.Connection, +) -> dict[str, dict[str, dict[str, Any]]]: + bubbles: dict[str, dict[str, dict[str, Any]]] = {} + try: + rows = con.execute( + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'", + ).fetchall() + except sqlite3.Error: + return bubbles + + for key, raw_value in rows: + parts = str(key).split(":", 2) + if len(parts) != 3: + continue + _, composer_id, bubble_id = parts + bubble = _cursor_json(raw_value) + if isinstance(bubble, dict): + bubbles.setdefault(composer_id, {})[bubble_id] = bubble + return bubbles + + +def _cursor_json(value: Any) -> Any: + if isinstance(value, bytes): + text = value.decode("utf-8", errors="replace") + else: + text = str(value) + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +def _cursor_model(record: dict[str, Any]) -> str: + model_info = record.get("modelInfo") + if isinstance(model_info, dict): + model_name = model_info.get("modelName") + if isinstance(model_name, str) and model_name: + return model_name + model_config = record.get("modelConfig") + if isinstance(model_config, dict): + model_name = model_config.get("modelName") + if isinstance(model_name, str) and model_name: + return model_name + return "" + + +def _cursor_usage(token_count: Any) -> dict[str, Any]: + if not isinstance(token_count, dict): + return {} + return { + "input_tokens": _to_int(token_count.get("inputTokens", 0)), + "output_tokens": _to_int(token_count.get("outputTokens", 0)), + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + } + + +def _cursor_timestamp(value: Any) -> str: + if isinstance(value, (int, float)): + try: + from datetime import datetime, timezone + + return datetime.fromtimestamp(value / 1000, tz=timezone.utc).isoformat() + except (OSError, ValueError): + return "" + if isinstance(value, str): + return value + return "" + + +def _cursor_text(record: dict[str, Any]) -> str: + text = record.get("text") + if isinstance(text, str) and text: + return text + rich = record.get("richText") + if isinstance(rich, str) and rich: + return rich + return "" + + +def _cursor_project_path(record: dict[str, Any]) -> str: + uris = record.get("workspaceUris") + if isinstance(uris, list): + for uri in uris: + if not isinstance(uri, str): + continue + path = _file_uri_to_path(uri) + if path: + return path + + workspace = record.get("workspaceProjectDir") + if isinstance(workspace, str) and workspace: + return workspace + + attached = record.get("allAttachedFileCodeChunksUris") + if isinstance(attached, list): + for uri in attached: + if isinstance(uri, str): + path = _file_uri_to_path(uri) + if path: + return str(Path(path).parent) + + return "" + + +def _file_uri_to_path(uri: str) -> str: + parsed = urlparse(uri) + if parsed.scheme != "file": + return "" + raw_path = unquote(parsed.path) + if os.name == "nt" and raw_path.startswith("/") and len(raw_path) > 2 and raw_path[2] == ":": + raw_path = raw_path[1:] + return os.path.normpath(raw_path) + + +def _cursor_line_blob(count: int) -> str: + if count <= 0: + return "" + return "\n".join("x" for _ in range(count)) + + +def _looks_like_cursor_db(path: Path) -> bool: + return path.name == "state.vscdb" + + def parse_session_file(path: Path) -> Session: """自动识别并解析会话文件(Claude / Codex / Gemini)""" + if _looks_like_cursor_db(path): + return parse_cursor_db(path) if path.suffix == ".json": return parse_gemini_json(path) if _looks_like_gemini_jsonl(path): diff --git a/cc_stats/sources.py b/cc_stats/sources.py index 1916ecf..2e80b8a 100644 --- a/cc_stats/sources.py +++ b/cc_stats/sources.py @@ -9,12 +9,14 @@ from cc_stats.parser import ( Session, + find_cursor_sessions, find_codex_sessions, find_codex_sessions_by_keyword, find_gemini_sessions, find_gemini_sessions_by_keyword, find_sessions, find_sessions_by_keyword, + parse_cursor_sessions, parse_session_file, ) @@ -24,6 +26,7 @@ class SourceKind(str, Enum): CLAUDE = "claude" CODEX = "codex" GEMINI = "gemini" + CURSOR = "cursor" @dataclass(frozen=True) @@ -47,6 +50,25 @@ def gemini_home() -> Path: return _env_path("CC_STATS_GEMINI_HOME", Path.home() / ".gemini") +def cursor_state_db() -> Path: + raw = os.environ.get("CC_STATS_CURSOR_STATE_DB", "").strip() + if raw: + return Path(raw).expanduser() + user_dir = _env_path("CC_STATS_CURSOR_USER_DIR", _default_cursor_user_dir()) + return user_dir / "globalStorage" / "state.vscdb" + + +def _default_cursor_user_dir() -> Path: + appdata = os.environ.get("APPDATA", "").strip() + if appdata: + return Path(appdata) / "Cursor" / "User" + if os.name == "nt": + return Path.home() / "AppData" / "Roaming" / "Cursor" / "User" + if sys_platform := os.environ.get("XDG_CONFIG_HOME", "").strip(): + return Path(sys_platform) / "Cursor" / "User" + return Path.home() / ".config" / "Cursor" / "User" + + def _env_path(name: str, default: Path) -> Path: raw = os.environ.get(name, "").strip() return Path(raw).expanduser() if raw else default @@ -70,7 +92,7 @@ def normalize_source(source: SourceKind | str | None) -> SourceKind: def active_sources(source: SourceKind | str | None = None) -> tuple[SourceKind, ...]: normalized = normalize_source(source) if normalized == SourceKind.ALL: - return (SourceKind.CLAUDE, SourceKind.CODEX, SourceKind.GEMINI) + return (SourceKind.CLAUDE, SourceKind.CODEX, SourceKind.GEMINI, SourceKind.CURSOR) return (normalized,) @@ -92,6 +114,12 @@ def collect_session_files( find_gemini_sessions(gemini_home_dir=gemini_home()), project_dir, )) + elif kind == SourceKind.CURSOR: + cursor_files = find_cursor_sessions(cursor_state_db_path=cursor_state_db()) + if project_dir is None: + files.extend(cursor_files) + else: + files.extend(_filter_sessions_by_project(cursor_files, project_dir)) return list(dict.fromkeys(files)) @@ -107,6 +135,8 @@ def collect_session_files_by_keyword( files.extend(find_codex_sessions_by_keyword(keyword, codex_home_dir=codex_home())) elif kind == SourceKind.GEMINI: files.extend(find_gemini_sessions_by_keyword(keyword, gemini_home_dir=gemini_home())) + elif kind == SourceKind.CURSOR: + files.extend(_find_cursor_sessions_by_keyword(keyword)) return list(dict.fromkeys(files)) @@ -114,27 +144,28 @@ def list_projects(source: SourceKind | str | None = None) -> list[SourceProject] groups: dict[tuple[SourceKind, str], _ProjectGroup] = {} for path in collect_session_files(source=source): try: - session = parse_file(path) + sessions = parse_sessions(path) except (OSError, ValueError): continue - kind = normalize_source(session.source) - key = _project_key(path, session, kind) - display_name = session.project_path or key - last_modified = _mtime(path) - group_key = (kind, key) - if group_key not in groups: - groups[group_key] = _ProjectGroup( - source=kind, - key=key, - display_name=display_name, - session_count=0, - last_modified=last_modified, - ) - group = groups[group_key] - group.session_count += 1 - group.last_modified = max(group.last_modified, last_modified) - if session.project_path: - group.display_name = session.project_path + for session in sessions: + kind = normalize_source(session.source) + key = _project_key(path, session, kind) + display_name = session.project_path or key + last_modified = _mtime(path) + group_key = (kind, key) + if group_key not in groups: + groups[group_key] = _ProjectGroup( + source=kind, + key=key, + display_name=display_name, + session_count=0, + last_modified=last_modified, + ) + group = groups[group_key] + group.session_count += 1 + group.last_modified = max(group.last_modified, last_modified) + if session.project_path: + group.display_name = session.project_path return [ SourceProject( @@ -155,6 +186,12 @@ def parse_file(path: Path) -> Session: return parse_session_file(path) +def parse_sessions(path: Path) -> list[Session]: + if path.name == "state.vscdb": + return parse_cursor_sessions(path) + return [parse_session_file(path)] + + @dataclass class _ProjectGroup: source: SourceKind @@ -169,16 +206,36 @@ def _filter_sessions_by_project(paths: list[Path], project_dir: Path) -> list[Pa results: list[Path] = [] for path in paths: try: - session = parse_file(path) + sessions = parse_sessions(path) except (OSError, ValueError): continue - if not session.project_path: - continue - if _normalized_path(Path(session.project_path)) == target: + if any( + session.project_path + and _normalized_path(Path(session.project_path)) == target + for session in sessions + ): results.append(path) return results +def _find_cursor_sessions_by_keyword(keyword: str) -> list[Path]: + keyword_lower = keyword.lower() + db_files = find_cursor_sessions(cursor_state_db_path=cursor_state_db()) + if not db_files: + return [] + for db_file in db_files: + try: + sessions = parse_cursor_sessions(db_file) + except (OSError, ValueError): + continue + for session in sessions: + if keyword_lower in session.project_path.lower(): + return [db_file] + if any(keyword_lower in str(message.content).lower() for message in session.messages): + return [db_file] + return [] + + def _project_key(path: Path, session: Session, source: SourceKind) -> str: if source == SourceKind.CLAUDE: return path.parent.name diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index 68fd0fc..b325093 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -18,7 +18,7 @@ merge_stats, ) from cc_stats.pricing import match_model_pricing -from cc_stats.sources import collect_session_files, list_projects, parse_file +from cc_stats.sources import collect_session_files, list_projects, parse_file, parse_sessions _web_dir = os.path.join(os.path.dirname(__file__), "web") @@ -142,13 +142,16 @@ def _collect_session_files(project_dir_name=None, source: str | None = None): filtered = [] for f in files: try: - session = _parse_session_file(f) + sessions = _parse_sessions_from_file(f) except Exception: continue - if session.project_path == project_dir_name: + if any(session.project_path == project_dir_name for session in sessions): filtered.append(f) continue - if session.source == "claude" and f.parent.name == project_dir_name: + if ( + any(session.source == "claude" for session in sessions) + and f.parent.name == project_dir_name + ): filtered.append(f) return filtered @@ -158,6 +161,13 @@ def _parse_session_file(f): return parse_file(f) +def _parse_sessions_from_file(f): + """Parse one source entry into one or more sessions.""" + if getattr(f, "name", "") == "state.vscdb": + return parse_sessions(f) + return [_parse_session_file(f)] + + def _filter_files_by_mtime(files: list, since_dt: datetime | None): if since_dt is None: return files @@ -173,6 +183,41 @@ def _filter_files_by_mtime(files: list, since_dt: datetime | None): return filtered +def _session_matches_project(session, path, project_dir_name) -> bool: + if not project_dir_name: + return True + if session.project_path == project_dir_name: + return True + return session.source == "claude" and path.parent.name == project_dir_name + + +def _analyze_session_files( + files: list, + since_dt: datetime | None = None, + project_dir_name=None, +) -> list[SessionStats]: + all_stats = [] + for f in files: + try: + sessions = _parse_sessions_from_file(f) + for session in sessions: + if not _session_matches_project(session, f, project_dir_name): + continue + stats = analyze_session(session, include_git=False) + if since_dt and stats.end_time and stats.end_time < since_dt: + continue + all_stats.append(stats) + except Exception: + continue + return all_stats + + +def _merged_stats(all_stats: list[SessionStats]) -> SessionStats | None: + if not all_stats: + return None + return all_stats[0] if len(all_stats) == 1 else merge_stats(all_stats) + + def _daily_date_keys( since_dt: datetime, days: int, @@ -209,21 +254,14 @@ def _get_stats(project_dir_name=None, since_days=None, source: str | None = None files = _filter_files_by_mtime(files, since_dt) files.sort(key=lambda f: f.stat().st_mtime) - all_stats = [] - for f in files: - try: - session = _parse_session_file(f) - stats = analyze_session(session, include_git=False) - if since_dt and stats.end_time and stats.end_time < since_dt: - continue - all_stats.append(stats) - except Exception: - continue + all_stats = _analyze_session_files(files, since_dt, project_dir_name) if not all_stats: return {"error": "No valid sessions"} - result = all_stats[0] if len(all_stats) == 1 else merge_stats(all_stats) + result = _merged_stats(all_stats) + if result is None: + return {"error": "No valid sessions"} return _stats_to_dict( result, session_count=len(all_stats), @@ -238,18 +276,50 @@ def _get_daily_stats(project_dir_name=None, days=14, source: str | None = None): files = _filter_files_by_mtime(files, since_dt) daily: dict[str, list] = defaultdict(list) - for f in files: - try: - session = _parse_session_file(f) - stats = analyze_session(session, include_git=False) - if stats.end_time and stats.end_time < since_dt: - continue - if not stats.start_time: - continue - day_key = stats.start_time.astimezone().strftime("%Y-%m-%d") - daily[day_key].append(stats) - except Exception: + for stats in _analyze_session_files(files, since_dt, project_dir_name): + if not stats.start_time: + continue + day_key = stats.start_time.astimezone().strftime("%Y-%m-%d") + daily[day_key].append(stats) + + result = [] + for day_key in _daily_date_keys(since_dt, days): + day_stats = daily.get(day_key, []) + if day_stats: + merged = merge_stats(day_stats) if len(day_stats) > 1 else day_stats[0] + cost = sum(_estimate_cost(u, m) for m, u in merged.token_by_model.items()) + result.append({ + "date": day_key, + "sessions": len(day_stats), + "messages": merged.user_message_count, + "tool_calls": merged.tool_call_total, + "active_minutes": round(merged.active_duration.total_seconds() / 60, 1), + "lines_added": merged.total_added, + "lines_removed": merged.total_removed, + "tokens": merged.token_usage.total, + "cost": round(cost, 2), + }) + else: + result.append({ + "date": day_key, "sessions": 0, "messages": 0, "tool_calls": 0, + "active_minutes": 0, "lines_added": 0, "lines_removed": 0, "tokens": 0, "cost": 0, + }) + return result + + +def _daily_stats_from_analyzed( + all_stats: list[SessionStats], + since_dt: datetime, + days: int, +) -> list[dict]: + daily: dict[str, list] = defaultdict(list) + for stats in all_stats: + if stats.end_time and stats.end_time < since_dt: continue + if not stats.start_time: + continue + day_key = stats.start_time.astimezone().strftime("%Y-%m-%d") + daily[day_key].append(stats) result = [] for day_key in _daily_date_keys(since_dt, days): @@ -288,19 +358,38 @@ def _get_skill_stats(project_dir_name=None, since_days=None, source: str | None files.sort(key=lambda f: f.stat().st_mtime) - all_stats = [] - for f in files: - try: - session = _parse_session_file(f) - stats = analyze_session(session, include_git=False) - all_stats.append(stats) - except Exception: - continue + all_stats = _analyze_session_files(files, project_dir_name=project_dir_name) if not all_stats: return [] - result = all_stats[0] if len(all_stats) == 1 else merge_stats(all_stats) + result = _merged_stats(all_stats) + if result is None: + return [] + + skills = [] + for name, su in sorted( + result.skill_stats.items(), key=lambda x: x[1].call_count, reverse=True + ): + resolved = su.success_count + su.error_count + success_rate = ( + round(su.success_count / resolved * 100) if resolved > 0 else None + ) + skills.append({ + "name": name, + "call_count": su.call_count, + "success_count": su.success_count, + "error_count": su.error_count, + "unknown_count": su.unknown_count, + "success_rate": success_rate, + }) + return skills + + +def _skill_stats_from_analyzed(all_stats: list[SessionStats]) -> list[dict]: + result = _merged_stats(all_stats) + if result is None: + return [] skills = [] for name, su in sorted( @@ -321,6 +410,54 @@ def _get_skill_stats(project_dir_name=None, since_days=None, source: str | None return skills +def _get_dashboard_payload( + project_dir_name=None, + since_days=None, + daily_days=30, + source: str | None = None, +): + files = _collect_session_files(project_dir_name, source=source) + if not files: + return { + "stats": {"error": "No sessions found"}, + "daily_stats": [], + "skills": [], + } + + files.sort(key=lambda f: f.stat().st_mtime) + all_stats = _analyze_session_files(files, project_dir_name=project_dir_name) + if not all_stats: + return { + "stats": {"error": "No valid sessions"}, + "daily_stats": [], + "skills": [], + } + + since_dt = None + if since_days: + since_dt = datetime.now(tz=timezone.utc) - timedelta(days=since_days) + stats_for_range = [ + stats for stats in all_stats + if not since_dt or not stats.end_time or stats.end_time >= since_dt + ] + merged = _merged_stats(stats_for_range) + if merged is None: + stats_payload = {"error": "No valid sessions"} + else: + stats_payload = _stats_to_dict( + merged, + session_count=len(stats_for_range), + git_scan_skipped=True, + ) + + daily_since = datetime.now(tz=timezone.utc) - timedelta(days=daily_days) + return { + "stats": stats_payload, + "daily_stats": _daily_stats_from_analyzed(all_stats, daily_since, daily_days), + "skills": _skill_stats_from_analyzed(all_stats), + } + + def _get_version_update(): """检查版本更新(供 Web API 使用)""" try: @@ -361,6 +498,16 @@ def do_GET(self): since_days=int(days) if days and days != "0" else None, source=source, )) + elif path == "/api/dashboard": + project = params.get("project", [None])[0] + days = params.get("days", [None])[0] + daily_days = params.get("daily_days", ["30"])[0] + self._json(_get_dashboard_payload( + project_dir_name=project or None, + since_days=int(days) if days and days != "0" else None, + daily_days=int(daily_days), + source=source, + )) elif path == "/api/daily_stats": project = params.get("project", [None])[0] days = params.get("days", ["14"])[0] diff --git a/cc_stats_web/web/index.html b/cc_stats_web/web/index.html index c9f7b8a..5cd1908 100644 --- a/cc_stats_web/web/index.html +++ b/cc_stats_web/web/index.html @@ -789,6 +789,7 @@ + @@ -975,7 +976,7 @@

成本预测 o.value=p.dir_name; o.dataset.source=p.source||''; const parts=String(p.display_name||p.dir_name).split(/[\\/]/); - const tag=p.source==='gemini'?' · Gemini':p.source==='codex'?' · Codex':p.source==='claude'?' · Claude':''; + const tag=p.source==='cursor'?' · Cursor':p.source==='gemini'?' · Gemini':p.source==='codex'?' · Codex':p.source==='claude'?' · Claude':''; o.textContent=parts.slice(-1)[0]+tag+' ('+p.session_count+')'; sel.appendChild(o); }); @@ -987,35 +988,28 @@

成本预测 const days=currentDays>0?currentDays:null; const source=currentSource||currentProjectSource||null; setLoading(); - const statsPromise=api('/api/stats',{project:currentProject||null,days,source}); - const dailyPromise=api('/api/daily_stats',{project:currentProject||null,days:currentDays>0?currentDays:30,source}); - const skillsPromise=api('/api/skills',{project:currentProject||null,days,source}); + const dailyDays=currentDays>0?currentDays:30; try{ - const stats=await statsPromise; + const payload=await api('/api/dashboard',{project:currentProject||null,days,daily_days:dailyDays,source}); if(seq!==loadSeq)return; + const stats=payload.stats||{}; if(stats.error){showError(stats.error);return;} + const daily=Array.isArray(payload.daily_stats)?payload.daily_stats:[]; lastStats=stats; + lastDaily=daily; updateMetrics(stats); updateTime(stats); updateCode(stats); updateTokens(stats); updateCache(stats); + renderTrend(); + updateSkills(payload.skills||[]); updateTools(stats); updateForecast(lastDaily,stats); markUpdated(); }catch(e){ if(seq===loadSeq)showError(e.message||String(e)); - return; } - dailyPromise.then(daily=>{ - if(seq!==loadSeq)return; - lastDaily=Array.isArray(daily)?daily:[]; - renderTrend(); - updateForecast(lastDaily,lastStats); - }).catch(e=>{ - if(seq===loadSeq)document.getElementById('trend').innerHTML='
'+escapeHtml(e.message||String(e))+'
'; - }); - skillsPromise.then(skills=>{if(seq===loadSeq)updateSkills(skills)}).catch(()=>{if(seq===loadSeq)updateSkills([])}); } function setLoading(){ diff --git a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs index de4ee97..586ca7a 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/api_process.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs @@ -1,5 +1,7 @@ use std::{ + env, io::{self, BufRead, BufReader}, + path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::mpsc, thread, @@ -28,21 +30,23 @@ pub struct ApiStatus { pub struct ApiProcessManager { child: Option, health_failures: u8, + python_source_dir: Option, status: ApiStatus, } impl ApiProcessManager { - pub fn start_default() -> Self { + pub fn start_default_with_python_source(python_source_dir: Option) -> Self { let mut manager = Self { child: None, health_failures: 0, + python_source_dir, status: ApiStatus { state: ApiState::Starting, url: None, error: None, }, }; - match start_python_api() { + match start_python_api(manager.python_source_dir.as_deref()) { Ok((child, url)) => { manager.child = Some(child); manager.status = ApiStatus { @@ -52,14 +56,17 @@ impl ApiProcessManager { }; manager } - Err(error) => Self::failed(error), + Err(error) => { + Self::failed_with_python_source(error, manager.python_source_dir.clone()) + } } } - pub fn failed(error: String) -> Self { + fn failed_with_python_source(error: String, python_source_dir: Option) -> Self { Self { child: None, health_failures: 0, + python_source_dir, status: ApiStatus { state: ApiState::Failed, url: None, @@ -69,13 +76,48 @@ impl ApiProcessManager { } pub fn status(&mut self) -> ApiStatus { - self.refresh_status(); + self.refresh_child_status(); + self.status.clone() + } + + pub fn health_probe_url(&mut self) -> Option { + self.refresh_child_status(); + if self.child.is_none() { + return None; + } + if !matches!(self.status.state, ApiState::Running | ApiState::Failed) { + return None; + } + self.status.url.clone() + } + + pub fn apply_health_probe(&mut self, url: &str, healthy: bool) -> ApiStatus { + self.refresh_child_status(); + if self.child.is_none() || self.status.url.as_deref() != Some(url) { + return self.status.clone(); + } + + if healthy { + self.health_failures = 0; + self.status.state = ApiState::Running; + self.status.error = None; + return self.status.clone(); + } + + if self.status.state == ApiState::Running { + self.health_failures = self.health_failures.saturating_add(1); + if self.health_failures >= HEALTH_FAILURE_THRESHOLD { + self.status.state = ApiState::Failed; + self.status.error = Some(format!("cc_stats_web health check failed for {url}")); + } + } + self.status.clone() } pub fn restart(&mut self) -> ApiStatus { self.stop(); - let mut next = Self::start_default(); + let mut next = Self::start_default_with_python_source(self.python_source_dir.clone()); self.child = next.child.take(); self.health_failures = next.health_failures; self.status = next.status.clone(); @@ -91,8 +133,11 @@ impl ApiProcessManager { self.status.state = ApiState::Stopped; } - fn refresh_status(&mut self) { - if !matches!(self.status.state, ApiState::Running | ApiState::Failed) { + fn refresh_child_status(&mut self) { + if !matches!( + self.status.state, + ApiState::Running | ApiState::Failed | ApiState::Starting + ) { return; } @@ -115,27 +160,21 @@ impl ApiProcessManager { } } - let Some(url) = self.status.url.as_deref() else { + if self.child.is_none() { + if self.status.state == ApiState::Running { + self.health_failures = HEALTH_FAILURE_THRESHOLD; + self.status.state = ApiState::Failed; + self.status.error = + Some("cc_stats_web process is not running".to_string()); + } + return; + } + + if self.status.url.is_none() { self.health_failures = HEALTH_FAILURE_THRESHOLD; self.status.state = ApiState::Failed; self.status.error = Some("cc_stats_web health check failed: missing API URL".to_string()); - return; - }; - - if is_api_healthy(url) { - self.health_failures = 0; - self.status.state = ApiState::Running; - self.status.error = None; - return; - } - - if self.status.state == ApiState::Running { - self.health_failures = self.health_failures.saturating_add(1); - if self.health_failures >= HEALTH_FAILURE_THRESHOLD { - self.status.state = ApiState::Failed; - self.status.error = Some(format!("cc_stats_web health check failed for {url}")); - } } } @@ -144,6 +183,21 @@ impl ApiProcessManager { Self { child: None, health_failures: 0, + python_source_dir: None, + status: ApiStatus { + state: ApiState::Running, + url: Some(url.to_string()), + error: None, + }, + } + } + + #[cfg(test)] + fn running_with_child_for_test(url: &str, child: Child) -> Self { + Self { + child: Some(child), + health_failures: 0, + python_source_dir: None, status: ApiStatus { state: ApiState::Running, url: Some(url.to_string()), @@ -162,8 +216,9 @@ impl Drop for ApiProcessManager { pub fn candidate_python_commands() -> Vec> { if cfg!(windows) { vec![ - vec!["py".to_string(), "-3".to_string()], + vec!["pythonw".to_string()], vec!["python".to_string()], + vec!["py".to_string(), "-3".to_string()], vec!["python3".to_string()], ] } else { @@ -171,17 +226,39 @@ pub fn candidate_python_commands() -> Vec> { } } +#[cfg(test)] pub fn build_api_command(python_command: &[String]) -> Command { + build_api_command_with_python_source(python_command, None) +} + +pub fn build_api_command_with_python_source( + python_command: &[String], + python_source_dir: Option<&Path>, +) -> Command { let mut command = Command::new(&python_command[0]); for arg in &python_command[1..] { command.arg(arg); } command.args(["-m", "cc_stats_web", "--no-browser", "--json"]); + apply_python_source_dir(&mut command, python_source_dir); #[cfg(windows)] command.creation_flags(CREATE_NO_WINDOW); command } +fn apply_python_source_dir(command: &mut Command, python_source_dir: Option<&Path>) { + let Some(source_dir) = python_source_dir else { + return; + }; + let mut paths = vec![source_dir.to_path_buf()]; + if let Some(existing) = env::var_os("PYTHONPATH") { + paths.extend(env::split_paths(&existing)); + } + if let Ok(value) = env::join_paths(paths) { + command.env("PYTHONPATH", value); + } +} + pub fn parse_startup_url(line: &str) -> Option { let trimmed = line.trim(); if trimmed.is_empty() { @@ -198,18 +275,21 @@ pub fn parse_startup_url(line: &str) -> Option { None } -fn start_python_api() -> Result<(Child, String), String> { +fn start_python_api(python_source_dir: Option<&Path>) -> Result<(Child, String), String> { for python in candidate_python_commands() { - match spawn_with_python(&python) { + match spawn_with_python(&python, python_source_dir) { Ok(started) => return Ok(started), Err(_) => continue, } } - Err("Unable to start cc_stats_web with python, python3, or py -3".to_string()) + Err("Unable to start cc_stats_web with pythonw, python, py -3, or python3".to_string()) } -fn spawn_with_python(python: &[String]) -> Result<(Child, String), String> { - let mut command = build_api_command(python); +fn spawn_with_python( + python: &[String], + python_source_dir: Option<&Path>, +) -> Result<(Child, String), String> { + let mut command = build_api_command_with_python_source(python, python_source_dir); command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = command .spawn() @@ -294,11 +374,14 @@ struct StartupPayload { #[cfg(test)] mod tests { use super::{ - build_api_command, candidate_python_commands, drain_stderr, parse_startup_url, - ApiProcessManager, ApiStatus, + build_api_command, build_api_command_with_python_source, candidate_python_commands, + drain_stderr, parse_startup_url, ApiProcessManager, ApiStatus, }; use crate::health::ApiState; - use std::process::{Command, Stdio}; + use std::{ + path::PathBuf, + process::{Child, Command, Stdio}, + }; #[test] fn parses_structured_startup_json() { @@ -329,6 +412,17 @@ mod tests { assert_eq!(args, ["-m", "cc_stats_web", "--no-browser", "--json"]); } + #[test] + fn api_command_sets_pythonpath_to_bundled_source_dir() { + let python = vec!["python3".to_string()]; + let source_dir = PathBuf::from("C:/cc-statistics/resources/python"); + let command = build_api_command_with_python_source(&python, Some(&source_dir)); + + assert!(command + .get_envs() + .any(|(key, value)| key == "PYTHONPATH" && value.is_some())); + } + #[test] fn drain_stderr_takes_child_pipe_to_prevent_blocking_api() { let mut command = if cfg!(windows) { @@ -355,23 +449,35 @@ mod tests { } #[test] - fn status_tolerates_transient_health_probe_failures() { + fn windows_prefers_pythonw_to_avoid_console_window() { + if cfg!(windows) { + assert_eq!(candidate_python_commands()[0], ["pythonw"]); + } + } + + #[test] + fn status_marks_running_without_owned_child_failed() { let mut manager = ApiProcessManager::running_for_test("http://localhost:61234/"); let status = manager.status(); - assert_eq!(status.state, ApiState::Running); + assert_eq!(status.state, ApiState::Failed); assert_eq!(status.url.as_deref(), Some("http://localhost:61234/")); - assert_eq!(status.error, None); + assert_eq!( + status.error.as_deref(), + Some("cc_stats_web process is not running") + ); } #[test] fn status_marks_running_manager_failed_after_repeated_health_probe_failures() { - let mut manager = ApiProcessManager::running_for_test("http://localhost:61234/"); + let child = sleep_child(); + let mut manager = + ApiProcessManager::running_with_child_for_test("http://localhost:61234/", child); - manager.status(); - manager.status(); - let status = manager.status(); + manager.apply_health_probe("http://localhost:61234/", false); + manager.apply_health_probe("http://localhost:61234/", false); + let status = manager.apply_health_probe("http://localhost:61234/", false); assert_eq!(status.state, ApiState::Failed); assert_eq!(status.url.as_deref(), Some("http://localhost:61234/")); @@ -379,23 +485,12 @@ mod tests { } #[test] - fn failed_manager_recovers_when_health_probe_succeeds() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0; 512]; - let _ = std::io::Read::read(&mut stream, &mut request).unwrap(); - std::io::Write::write_all( - &mut stream, - b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}", - ) - .unwrap(); - }); - let url = format!("http://127.0.0.1:{port}/"); + fn failed_manager_without_owned_child_does_not_recover_from_reused_port() { + let url = "http://127.0.0.1:61234/".to_string(); let mut manager = ApiProcessManager { child: None, health_failures: 3, + python_source_dir: None, status: ApiStatus { state: ApiState::Failed, url: Some(url.clone()), @@ -403,11 +498,27 @@ mod tests { }, }; - let status = manager.status(); + let status = manager.apply_health_probe(&url, true); - assert_eq!(status.state, ApiState::Running); + assert_eq!(status.state, ApiState::Failed); assert_eq!(status.url.as_deref(), Some(url.as_str())); - assert_eq!(status.error, None); - handle.join().unwrap(); + assert_eq!( + status.error.as_deref(), + Some("cc_stats_web health check failed") + ); + } + + fn sleep_child() -> Child { + if cfg!(windows) { + let mut command = Command::new("cmd"); + command.args(["/C", "ping -n 6 127.0.0.1 > nul"]); + command + } else { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 5"]); + command + } + .spawn() + .unwrap() } } diff --git a/desktop/cc-stats-tauri/src-tauri/src/health.rs b/desktop/cc-stats-tauri/src-tauri/src/health.rs index aede5c9..32c08c6 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/health.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/health.rs @@ -6,7 +6,7 @@ use std::{ use serde::Serialize; -const HEALTH_TIMEOUT: Duration = Duration::from_secs(5); +const HEALTH_TIMEOUT: Duration = Duration::from_millis(800); #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -38,7 +38,9 @@ pub fn is_api_healthy(api_url: &str) -> bool { if stream.read_to_string(&mut response).is_err() { return false; } - response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200") + let compact = response.chars().filter(|ch| !ch.is_whitespace()).collect::(); + (response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200")) + && compact.contains("\"status\":\"ok\"") } fn parse_local_api_port(api_url: &str) -> Option { @@ -119,6 +121,25 @@ mod tests { handle.join().unwrap(); } + #[test] + fn api_health_probe_rejects_unrelated_local_200_response() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 512]; + let _ = std::io::Read::read(&mut stream, &mut request).unwrap(); + std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nhello world", + ) + .unwrap(); + }); + + assert!(!super::is_api_healthy(&format!("http://127.0.0.1:{port}/"))); + handle.join().unwrap(); + } + #[test] fn api_health_probe_rejects_non_local_url() { assert!(!super::is_api_healthy("http://localhost:61234/")); diff --git a/desktop/cc-stats-tauri/src-tauri/src/main.rs b/desktop/cc-stats-tauri/src-tauri/src/main.rs index 35d2677..a619390 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/main.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -1,13 +1,14 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::{ + path::PathBuf, sync::Mutex, thread, time::Duration, }; use api_process::{ApiProcessManager, ApiStatus}; -use health::ApiState; +use health::{is_api_healthy, ApiState}; use tauri::{AppHandle, Manager, State}; mod api_process; @@ -23,7 +24,7 @@ struct AppState { #[tauri::command] fn api_status(state: State<'_, AppState>) -> ApiStatus { - state.api.lock().expect("api state poisoned").status() + probe_api_status(&state.api) } #[tauri::command] @@ -49,24 +50,58 @@ fn restart_api(app: AppHandle, state: State<'_, AppState>) -> Result) -> Result<(), String> { - let status = { - let mut api = state.api.lock().expect("api state poisoned"); - api.status() - }; + let status = probe_api_status(&state.api); let url = external_browser::dashboard_url_for_open(&status)?; external_browser::open_dashboard_url(&url) } +pub fn open_dashboard_for_app(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let status = probe_api_status(&state.api); + let url = external_browser::dashboard_url_for_open(&status)?; + external_browser::open_dashboard_url(&url) +} + +pub fn quit_app(app: &AppHandle) { + let state = app.state::(); + if let Ok(mut api) = state.api.lock() { + api.stop(); + } + app.exit(0); +} + +fn probe_api_status(api: &Mutex) -> ApiStatus { + let (snapshot, probe_url) = { + let mut api = api.lock().expect("api state poisoned"); + let snapshot = api.status(); + let probe_url = api.health_probe_url(); + (snapshot, probe_url) + }; + + let Some(url) = probe_url else { + return snapshot; + }; + + let healthy = is_api_healthy(&url); + let mut api = api.lock().expect("api state poisoned"); + api.apply_health_probe(&url, healthy) +} + +fn bundled_python_source_dir(app: &AppHandle) -> Option { + app.path() + .resource_dir() + .ok() + .map(|resource_dir| resource_dir.join("python")) + .filter(|python_dir| python_dir.exists()) +} + fn start_api_health_monitor(app: AppHandle, initial_state: ApiState) { thread::spawn(move || { let mut last_state = initial_state; loop { thread::sleep(Duration::from_secs(3)); - let status = { - let state = app.state::(); - let mut api = state.api.lock().expect("api state poisoned"); - api.status() - }; + let state = app.state::(); + let status = probe_api_status(&state.api); if status.state == ApiState::Failed && last_state != ApiState::Failed { if let Some(error) = status.error.as_deref() { @@ -82,7 +117,9 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) .setup(|app| { - let mut api = ApiProcessManager::start_default(); + let mut api = ApiProcessManager::start_default_with_python_source( + bundled_python_source_dir(app.handle()), + ); let initial_status = api.status(); if let Some(error) = initial_status.error.as_deref() { notifications::api_start_failed(app.handle(), error); diff --git a/desktop/cc-stats-tauri/src-tauri/src/tray.rs b/desktop/cc-stats-tauri/src-tauri/src/tray.rs index 2397232..8b7a32c 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/tray.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/tray.rs @@ -4,7 +4,7 @@ use tauri::{ AppHandle, Manager, }; -use crate::{restart_api, window}; +use crate::{open_dashboard_for_app, quit_app, restart_api}; pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { let open_i = MenuItem::with_id(app, "open_dashboard", "Open Dashboard", true, None::<&str>)?; @@ -23,13 +23,13 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { builder .on_menu_event(|app, event| match event.id.as_ref() { "open_dashboard" => { - let _ = window::show_dashboard_window(app); + let _ = open_dashboard_for_app(app); } "restart_api" => { let _ = restart_api(app.clone(), app.state()); } "quit" => { - app.exit(0); + quit_app(app); } _ => {} }) @@ -40,7 +40,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { .. } = event { - let _ = window::show_dashboard_window(tray.app_handle()); + let _ = open_dashboard_for_app(tray.app_handle()); } }) .build(app)?; diff --git a/desktop/cc-stats-tauri/src-tauri/tauri.conf.json b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json index 5eea77a..3bda8b9 100644 --- a/desktop/cc-stats-tauri/src-tauri/tauri.conf.json +++ b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json @@ -28,6 +28,10 @@ "icon": [ "icons/icon.ico" ], + "resources": { + "../../../cc_stats": "python/cc_stats", + "../../../cc_stats_web": "python/cc_stats_web" + }, "targets": "all" } } diff --git a/tests/test_sources.py b/tests/test_sources.py index 1845afb..22f03ad 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sqlite3 from pathlib import Path from cc_stats.sources import ( @@ -9,6 +10,7 @@ collect_session_files_by_keyword, list_projects, parse_file, + parse_sessions, ) @@ -119,13 +121,67 @@ def _set_source_homes(monkeypatch, tmp_path: Path) -> tuple[Path, Path, Path]: claude_projects = tmp_path / "synthetic-claude" / "projects" codex_home = tmp_path / "synthetic-codex" gemini_home = tmp_path / "synthetic-gemini" + cursor_db = tmp_path / "synthetic-cursor" / "state.vscdb" monkeypatch.setenv("CC_STATS_CLAUDE_PROJECTS_DIR", str(claude_projects)) monkeypatch.setenv("CC_STATS_CODEX_HOME", str(codex_home)) monkeypatch.setenv("CC_STATS_GEMINI_HOME", str(gemini_home)) + monkeypatch.setenv("CC_STATS_CURSOR_STATE_DB", str(cursor_db)) monkeypatch.setenv("HOME", str(tmp_path / "unused-real-home")) return claude_projects, codex_home, gemini_home +def _write_cursor_state_db(cursor_db: Path, project_dir: Path, composer_id: str = "cursor-a") -> Path: + cursor_db.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(cursor_db) + try: + con.execute("CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)") + con.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)") + composer = { + "composerId": composer_id, + "createdAt": 1780963200000, + "lastUpdatedAt": 1780963202000, + "modelConfig": {"modelName": "claude-4.5-sonnet-thinking"}, + "unifiedMode": "agent", + "totalLinesAdded": 3, + "totalLinesRemoved": 1, + "fullConversationHeadersOnly": [ + {"bubbleId": "user-1", "type": 1}, + {"bubbleId": "assistant-1", "type": 2}, + ], + } + user_bubble = { + "bubbleId": "user-1", + "type": 1, + "createdAt": "2026-06-09T00:00:00Z", + "text": "build this in Cursor", + "workspaceUris": [project_dir.as_uri()], + "workspaceProjectDir": str(project_dir), + } + assistant_bubble = { + "bubbleId": "assistant-1", + "type": 2, + "createdAt": "2026-06-09T00:00:02Z", + "text": "done", + "modelInfo": {"modelName": "claude-4.5-sonnet-thinking"}, + "tokenCount": {"inputTokens": 10, "outputTokens": 5}, + "workspaceUris": [project_dir.as_uri()], + "workspaceProjectDir": str(project_dir), + } + rows = [ + (f"composerData:{composer_id}", composer), + (f"bubbleId:{composer_id}:user-1", user_bubble), + (f"bubbleId:{composer_id}:assistant-1", assistant_bubble), + ] + con.executemany( + "INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)", + [(key, json.dumps(value).encode("utf-8")) for key, value in rows], + ) + con.commit() + finally: + con.close() + return cursor_db + + def test_collect_session_files_all_includes_codex_synthetic_sessions( tmp_path: Path, monkeypatch, @@ -152,6 +208,64 @@ def test_collect_session_files_source_gemini_includes_jsonl_sessions( assert files == [gemini_file] +def test_collect_session_files_source_cursor_includes_state_db( + tmp_path: Path, + monkeypatch, +) -> None: + _set_source_homes(monkeypatch, tmp_path) + cursor_db = tmp_path / "synthetic-cursor" / "state.vscdb" + _write_cursor_state_db(cursor_db, tmp_path / "demo") + + files = collect_session_files(source=SourceKind.CURSOR) + + assert files == [cursor_db] + + +def test_parse_cursor_state_db_maps_sessions_messages_tokens_and_code_changes( + tmp_path: Path, + monkeypatch, +) -> None: + _set_source_homes(monkeypatch, tmp_path) + cursor_db = tmp_path / "synthetic-cursor" / "state.vscdb" + project_dir = tmp_path / "demo" + _write_cursor_state_db(cursor_db, project_dir) + + sessions = parse_sessions(cursor_db) + + assert len(sessions) == 1 + session = sessions[0] + assert session.source == "cursor" + assert session.session_id == "cursor-a" + assert session.project_path == str(project_dir) + assert [message.role for message in session.messages[:2]] == ["user", "assistant"] + assert session.messages[1].model == "claude-4.5-sonnet-thinking" + assert session.messages[1].usage == { + "input_tokens": 10, + "output_tokens": 5, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + } + assert session.messages[-1].tool_calls[0].name == "Edit" + + +def test_list_projects_source_cursor_groups_by_workspace_path( + tmp_path: Path, + monkeypatch, +) -> None: + _set_source_homes(monkeypatch, tmp_path) + cursor_db = tmp_path / "synthetic-cursor" / "state.vscdb" + project_dir = tmp_path / "demo" + _write_cursor_state_db(cursor_db, project_dir) + + projects = list_projects(source=SourceKind.CURSOR) + + assert len(projects) == 1 + assert projects[0].source == SourceKind.CURSOR + assert projects[0].key == str(project_dir) + assert projects[0].display_name == str(project_dir) + assert projects[0].session_count == 1 + + def test_parse_gemini_jsonl_maps_project_tokens_and_tools( tmp_path: Path, monkeypatch, diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 3f0bea4..450536e 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -2,6 +2,7 @@ import json import os +import sqlite3 import threading import time import urllib.request @@ -182,13 +183,66 @@ def _set_source_homes(monkeypatch, tmp_path: Path) -> tuple[Path, Path, Path]: claude_projects = tmp_path / "synthetic-claude" / "projects" codex_home = tmp_path / "synthetic-codex" gemini_home = tmp_path / "synthetic-gemini" + cursor_db = tmp_path / "synthetic-cursor" / "state.vscdb" monkeypatch.setenv("CC_STATS_CLAUDE_PROJECTS_DIR", str(claude_projects)) monkeypatch.setenv("CC_STATS_CODEX_HOME", str(codex_home)) monkeypatch.setenv("CC_STATS_GEMINI_HOME", str(gemini_home)) + monkeypatch.setenv("CC_STATS_CURSOR_STATE_DB", str(cursor_db)) monkeypatch.setenv("HOME", str(tmp_path / "unused-real-home")) return claude_projects, codex_home, gemini_home +def _write_cursor_state_db(cursor_db: Path, project_dir: Path, composer_id: str = "cursor-a") -> Path: + cursor_db.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(cursor_db) + try: + con.execute("CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)") + con.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)") + composer = { + "composerId": composer_id, + "createdAt": 1780963200000, + "lastUpdatedAt": 1780963202000, + "modelConfig": {"modelName": "claude-4.5-sonnet-thinking"}, + "totalLinesAdded": 3, + "totalLinesRemoved": 1, + "fullConversationHeadersOnly": [ + {"bubbleId": "user-1", "type": 1}, + {"bubbleId": "assistant-1", "type": 2}, + ], + } + user_bubble = { + "bubbleId": "user-1", + "type": 1, + "createdAt": "2026-06-09T00:00:00Z", + "text": "build this in Cursor", + "workspaceUris": [project_dir.as_uri()], + "workspaceProjectDir": str(project_dir), + } + assistant_bubble = { + "bubbleId": "assistant-1", + "type": 2, + "createdAt": "2026-06-09T00:00:02Z", + "text": "done", + "modelInfo": {"modelName": "claude-4.5-sonnet-thinking"}, + "tokenCount": {"inputTokens": 10, "outputTokens": 5}, + "workspaceUris": [project_dir.as_uri()], + "workspaceProjectDir": str(project_dir), + } + rows = [ + (f"composerData:{composer_id}", composer), + (f"bubbleId:{composer_id}:user-1", user_bubble), + (f"bubbleId:{composer_id}:assistant-1", assistant_bubble), + ] + con.executemany( + "INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)", + [(key, json.dumps(value).encode("utf-8")) for key, value in rows], + ) + con.commit() + finally: + con.close() + return cursor_db + + def test_get_projects_source_codex_includes_codex_project( tmp_path: Path, monkeypatch, @@ -311,6 +365,49 @@ def test_get_stats_source_gemini_parses_windows_jsonl_sessions( assert stats["token_usage"]["total"] == 150 +def test_get_stats_source_cursor_parses_state_db( + tmp_path: Path, + monkeypatch, +) -> None: + _set_source_homes(monkeypatch, tmp_path) + cursor_db = tmp_path / "synthetic-cursor" / "state.vscdb" + _write_cursor_state_db(cursor_db, tmp_path / "demo") + + stats = _get_stats(source="cursor") + + assert stats["session_count"] == 1 + assert stats["user_message_count"] == 1 + assert stats["token_usage"]["input_tokens"] == 10 + assert stats["token_usage"]["output_tokens"] == 5 + assert stats["token_usage"]["total"] == 15 + assert stats["total_added"] == 3 + assert stats["total_removed"] == 1 + + +def test_get_dashboard_payload_analyzes_sessions_once_for_summary_daily_and_skills( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + analyzed: list[str] = [] + + def fake_analyze_session(session, *, include_git=True): + analyzed.append(session.session_id) + stats = SessionStats(session_id=session.session_id, project_path=session.project_path) + stats.user_message_count = 1 + return stats + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + payload = web_server._get_dashboard_payload(source="codex", daily_days=30) + + assert analyzed == ["codex-a"] + assert payload["stats"]["session_count"] == 1 + assert isinstance(payload["daily_stats"], list) + assert payload["skills"] == [] + + def test_get_stats_prefilters_old_mtime_before_parsing( tmp_path: Path, monkeypatch, diff --git a/tests/test_windows_tauri_shell.py b/tests/test_windows_tauri_shell.py index c1f33d2..4549613 100644 --- a/tests/test_windows_tauri_shell.py +++ b/tests/test_windows_tauri_shell.py @@ -98,6 +98,39 @@ def test_tray_uses_explicit_default_window_icon() -> None: assert "default_window_icon" in tray_rs +def test_tray_open_dashboard_uses_external_dashboard_command() -> None: + tray_rs = (TAURI_DIR / "src" / "tray.rs").read_text(encoding="utf-8") + + assert "open_dashboard_for_app" in tray_rs + assert 'event.id.as_ref() {\n "open_dashboard" => {\n let _ = window::show_dashboard_window(app);' not in tray_rs + + +def test_tray_quit_stops_api_before_app_exit() -> None: + main_rs = (TAURI_DIR / "src" / "main.rs").read_text(encoding="utf-8") + tray_rs = (TAURI_DIR / "src" / "tray.rs").read_text(encoding="utf-8") + + assert "pub fn quit_app" in main_rs + assert "api.stop();" in main_rs + assert "quit_app(app);" in tray_rs + assert '"quit" => {\n app.exit(0);' not in tray_rs + + +def test_python_api_bundles_python_sources_as_tauri_resources() -> None: + config = (TAURI_DIR / "tauri.conf.json").read_text(encoding="utf-8") + + assert "../../../cc_stats" in config + assert "../../../cc_stats_web" in config + assert "python/cc_stats" in config + assert "python/cc_stats_web" in config + + +def test_python_api_child_process_uses_bundled_pythonpath() -> None: + api_process_rs = (TAURI_DIR / "src" / "api_process.rs").read_text(encoding="utf-8") + + assert "PYTHONPATH" in api_process_rs + assert "python_source_dir" in api_process_rs + + def test_tray_icon_assets_are_multi_size_and_non_monochrome() -> None: icon_png = TAURI_DIR / "icons" / "icon.png" icon_ico = TAURI_DIR / "icons" / "icon.ico" From 85997d864b53667434f68b83e67c1bb82c783de8 Mon Sep 17 00:00:00 2001 From: efunyang Date: Wed, 10 Jun 2026 10:09:18 +0800 Subject: [PATCH 19/26] fix: prevent duplicate windows tray instances --- desktop/cc-stats-tauri/src-tauri/Cargo.lock | 92 +++++++++++++++++++- desktop/cc-stats-tauri/src-tauri/Cargo.toml | 1 + desktop/cc-stats-tauri/src-tauri/src/main.rs | 3 + tests/test_windows_tauri_shell.py | 9 ++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/desktop/cc-stats-tauri/src-tauri/Cargo.lock b/desktop/cc-stats-tauri/src-tauri/Cargo.lock index 6848283..e798821 100644 --- a/desktop/cc-stats-tauri/src-tauri/Cargo.lock +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.lock @@ -429,6 +429,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-notification", + "tauri-plugin-single-instance", "windows-sys 0.61.2", ] @@ -3501,6 +3502,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -4596,6 +4612,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4629,13 +4654,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4666,6 +4708,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4678,6 +4726,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4690,12 +4744,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4708,6 +4774,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4720,6 +4792,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4732,6 +4810,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4744,6 +4828,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/desktop/cc-stats-tauri/src-tauri/Cargo.toml b/desktop/cc-stats-tauri/src-tauri/Cargo.toml index f0c594d..9d5225f 100644 --- a/desktop/cc-stats-tauri/src-tauri/Cargo.toml +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-notification = "2.3.3" +tauri-plugin-single-instance = "2.3.5" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/desktop/cc-stats-tauri/src-tauri/src/main.rs b/desktop/cc-stats-tauri/src-tauri/src/main.rs index a619390..4aef58c 100644 --- a/desktop/cc-stats-tauri/src-tauri/src/main.rs +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -115,6 +115,9 @@ fn start_api_health_monitor(app: AppHandle, initial_state: ApiState) { fn main() { tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + let _ = open_dashboard_for_app(app); + })) .plugin(tauri_plugin_notification::init()) .setup(|app| { let mut api = ApiProcessManager::start_default_with_python_source( diff --git a/tests/test_windows_tauri_shell.py b/tests/test_windows_tauri_shell.py index 4549613..ec8ad25 100644 --- a/tests/test_windows_tauri_shell.py +++ b/tests/test_windows_tauri_shell.py @@ -115,6 +115,15 @@ def test_tray_quit_stops_api_before_app_exit() -> None: assert '"quit" => {\n app.exit(0);' not in tray_rs +def test_tauri_shell_uses_single_instance_plugin_to_prevent_api_conflicts() -> None: + cargo_toml = (TAURI_DIR / "Cargo.toml").read_text(encoding="utf-8") + main_rs = (TAURI_DIR / "src" / "main.rs").read_text(encoding="utf-8") + + assert "tauri-plugin-single-instance" in cargo_toml + assert "tauri_plugin_single_instance::init" in main_rs + assert "open_dashboard_for_app(app);" in main_rs + + def test_python_api_bundles_python_sources_as_tauri_resources() -> None: config = (TAURI_DIR / "tauri.conf.json").read_text(encoding="utf-8") From 9b4166d0c36cbe50392ab1764c46db259116a39f Mon Sep 17 00:00:00 2001 From: efunyang Date: Wed, 10 Jun 2026 10:28:09 +0800 Subject: [PATCH 20/26] fix: cache dashboard range switching --- cc_stats_web/server.py | 162 +++++++++++++++++++++++++++++++----- cc_stats_web/web/index.html | 107 +++++++++++++++++++----- tests/test_web_server.py | 120 +++++++++++++++++++++++++- 3 files changed, 342 insertions(+), 47 deletions(-) diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index b325093..891bddb 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -5,8 +5,12 @@ import json import os import socket +import threading +import time from collections import defaultdict +from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from pathlib import Path from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse @@ -23,6 +27,58 @@ _web_dir = os.path.join(os.path.dirname(__file__), "web") +@dataclass(frozen=True) +class _AnalyzedCacheEntry: + signature: tuple[tuple[str, int | None, int | None], ...] + stats: list[SessionStats] + created_at: float + + +@dataclass(frozen=True) +class _ProjectsCacheEntry: + signature: tuple[tuple[str, int | None, int | None], ...] + projects: list[dict] + created_at: float + + +_CACHE_TTL_SECONDS = 45.0 +_ANALYZED_CACHE_LOCK = threading.Lock() +_PROJECTS_CACHE_LOCK = threading.Lock() +_ANALYZED_CACHE: dict[tuple[str, str], _AnalyzedCacheEntry] = {} +_PROJECTS_CACHE: dict[str, _ProjectsCacheEntry] = {} + + +def _session_files_signature(files: list[Path]) -> tuple[tuple[str, int | None, int | None], ...]: + signature = [] + for path in files: + try: + stat = path.stat() + signature.append((str(path), stat.st_mtime_ns, stat.st_size)) + except OSError: + signature.append((str(path), None, None)) + return tuple(signature) + + +def _cache_source_key(source: str | None) -> str: + env_parts = [ + os.environ.get("CC_STATS_CLAUDE_PROJECTS_DIR", ""), + os.environ.get("CC_STATS_CODEX_HOME", ""), + os.environ.get("CC_STATS_GEMINI_HOME", ""), + os.environ.get("CC_STATS_CURSOR_STATE_DB", ""), + os.environ.get("CC_STATS_CURSOR_USER_DIR", ""), + os.environ.get("HOME", ""), + ] + return "\0".join([source or "", *env_parts]) + + +def _cache_project_key(project_dir_name) -> str: + return str(project_dir_name or "") + + +def _is_cache_fresh(created_at: float) -> bool: + return time.monotonic() - created_at <= _CACHE_TTL_SECONDS + + def _estimate_cost(tu: TokenUsage, model: str = "") -> float: p = match_model_pricing(model) cost = 0.0 @@ -72,6 +128,8 @@ def _token_dict(tu): total_cost = 0.0 model_tokens = [] for model, usage in sorted(stats.token_by_model.items(), key=lambda x: x[1].total, reverse=True): + if usage.total <= 0: + continue cost = _estimate_cost(usage, model) total_cost += cost model_tokens.append({ @@ -120,17 +178,40 @@ def _token_dict(tu): def _get_projects(source: str | None = None): - projects = [ - { - "dir_name": project.key, - "display_name": project.display_name, - "session_count": project.session_count, - "source": project.source.value, - } - for project in list_projects(source=source) - ] - projects.sort(key=lambda x: x["session_count"], reverse=True) - return projects + cache_key = _cache_source_key(source) + with _PROJECTS_CACHE_LOCK: + cached = _PROJECTS_CACHE.get(cache_key) + if cached and _is_cache_fresh(cached.created_at): + return cached.projects + + files = collect_session_files(source=source) + files.sort(key=lambda path: str(path)) + signature = _session_files_signature(files) + cached = _PROJECTS_CACHE.get(cache_key) + if cached and cached.signature == signature: + _PROJECTS_CACHE[cache_key] = _ProjectsCacheEntry( + signature=cached.signature, + projects=cached.projects, + created_at=time.monotonic(), + ) + return cached.projects + + projects = [ + { + "dir_name": project.key, + "display_name": project.display_name, + "session_count": project.session_count, + "source": project.source.value, + } + for project in list_projects(source=source) + ] + projects.sort(key=lambda x: x["session_count"], reverse=True) + _PROJECTS_CACHE[cache_key] = _ProjectsCacheEntry( + signature=signature, + projects=projects, + created_at=time.monotonic(), + ) + return projects def _collect_session_files(project_dir_name=None, source: str | None = None): @@ -212,6 +293,40 @@ def _analyze_session_files( return all_stats +def _get_cached_analyzed_stats( + project_dir_name=None, + source: str | None = None, +) -> list[SessionStats]: + cache_key = (_cache_source_key(source), _cache_project_key(project_dir_name)) + with _ANALYZED_CACHE_LOCK: + cached = _ANALYZED_CACHE.get(cache_key) + if cached and _is_cache_fresh(cached.created_at): + return cached.stats + + files = _collect_session_files(project_dir_name, source=source) + if not files: + return [] + + files.sort(key=lambda f: f.stat().st_mtime) + signature = _session_files_signature(files) + cached = _ANALYZED_CACHE.get(cache_key) + if cached and cached.signature == signature: + _ANALYZED_CACHE[cache_key] = _AnalyzedCacheEntry( + signature=cached.signature, + stats=cached.stats, + created_at=time.monotonic(), + ) + return cached.stats + + all_stats = _analyze_session_files(files, project_dir_name=project_dir_name) + _ANALYZED_CACHE[cache_key] = _AnalyzedCacheEntry( + signature=signature, + stats=all_stats, + created_at=time.monotonic(), + ) + return all_stats + + def _merged_stats(all_stats: list[SessionStats]) -> SessionStats | None: if not all_stats: return None @@ -416,19 +531,10 @@ def _get_dashboard_payload( daily_days=30, source: str | None = None, ): - files = _collect_session_files(project_dir_name, source=source) - if not files: - return { - "stats": {"error": "No sessions found"}, - "daily_stats": [], - "skills": [], - } - - files.sort(key=lambda f: f.stat().st_mtime) - all_stats = _analyze_session_files(files, project_dir_name=project_dir_name) + all_stats = _get_cached_analyzed_stats(project_dir_name, source=source) if not all_stats: return { - "stats": {"error": "No valid sessions"}, + "stats": {"error": "No sessions found"}, "daily_stats": [], "skills": [], } @@ -547,13 +653,23 @@ class CcStatsHTTPServer(ThreadingHTTPServer): daemon_threads = True +def _warm_dashboard_cache() -> None: + try: + _get_cached_analyzed_stats() + _get_projects() + except Exception: + pass + + def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] -def start_server() -> tuple[CcStatsHTTPServer, int]: +def start_server(warm_cache: bool = True) -> tuple[CcStatsHTTPServer, int]: port = find_free_port() server = CcStatsHTTPServer(("127.0.0.1", port), ApiHandler) + if warm_cache: + threading.Thread(target=_warm_dashboard_cache, daemon=True).start() return server, port diff --git a/cc_stats_web/web/index.html b/cc_stats_web/web/index.html index 5cd1908..3554a8a 100644 --- a/cc_stats_web/web/index.html +++ b/cc_stats_web/web/index.html @@ -895,6 +895,10 @@

成本预测 let currentProject='',currentProjectSource='',currentDays=1,currentSource='',allProjects=[],loadSeq=0; let lastStats=null,lastDaily=[],lastUpdatedAt=null,trendMode='cost'; const COLORS=['#4fa3ff','#8a5cf6','#22c58d','#f4a218','#ff5f5f','#e879f9','#2dd4bf','#facc15']; +const PERIOD_DAYS=[1,7,30,0]; +const DASHBOARD_CACHE_TTL_MS=45000; +const dashboardCache=new Map(); +const dashboardInFlight=new Map(); function escapeHtml(value){ return String(value ?? '').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); @@ -963,6 +967,53 @@

成本预测 if(!res.ok)throw new Error(path+' failed with HTTP '+res.status); return res.json(); } +function dashboardParams(daysValue=currentDays){ + const days=daysValue>0?daysValue:null; + return { + project:currentProject||null, + days, + daily_days:daysValue>0?daysValue:30, + source:currentSource||currentProjectSource||null, + }; +} +function dashboardCacheKey(daysValue=currentDays){ + const p=dashboardParams(daysValue); + return [p.project||'',p.source||'',p.days||0,p.daily_days].join('|'); +} +function clearDashboardCache(){ + dashboardCache.clear(); + dashboardInFlight.clear(); +} +function cachedDashboardPayload(key){ + const entry=dashboardCache.get(key); + if(!entry)return null; + if(Date.now()-entry.at>DASHBOARD_CACHE_TTL_MS){ + dashboardCache.delete(key); + return null; + } + return entry.payload; +} +async function fetchDashboardPayload(daysValue=currentDays,force=false){ + const key=dashboardCacheKey(daysValue); + if(force){ + dashboardCache.delete(key); + dashboardInFlight.delete(key); + } + const cached=force?null:cachedDashboardPayload(key); + if(cached)return cached; + if(dashboardInFlight.has(key))return dashboardInFlight.get(key); + const request=api('/api/dashboard',dashboardParams(daysValue)).then(payload=>{ + dashboardCache.set(key,{payload,at:Date.now()}); + return payload; + }).finally(()=>dashboardInFlight.delete(key)); + dashboardInFlight.set(key,request); + return request; +} +function prefetchPeriodPayloads(seq=loadSeq){ + PERIOD_DAYS.filter(days=>days!==currentDays).forEach(days=>{ + fetchDashboardPayload(days).catch(()=>{}); + }); +} async function loadProjects(){ allProjects=await api('/api/projects',{source:currentSource||null}); @@ -983,30 +1034,40 @@

成本预测 sel.value=currentProject; } -async function loadStats(){ +function renderDashboardPayload(payload){ + const stats=payload.stats||{}; + if(stats.error){showError(stats.error);return false;} + const daily=Array.isArray(payload.daily_stats)?payload.daily_stats:[]; + lastStats=stats; + lastDaily=daily; + updateMetrics(stats); + updateTime(stats); + updateCode(stats); + updateTokens(stats); + updateCache(stats); + renderTrend(); + updateSkills(payload.skills||[]); + updateTools(stats); + updateForecast(lastDaily,stats); + markUpdated(); + return true; +} + +async function loadStats(options={}){ const seq=++loadSeq; - const days=currentDays>0?currentDays:null; - const source=currentSource||currentProjectSource||null; + const force=Boolean(options.force); + const key=dashboardCacheKey(currentDays); + const cached=force?null:cachedDashboardPayload(key); + if(cached){ + renderDashboardPayload(cached); + prefetchPeriodPayloads(seq); + return; + } setLoading(); - const dailyDays=currentDays>0?currentDays:30; try{ - const payload=await api('/api/dashboard',{project:currentProject||null,days,daily_days:dailyDays,source}); + const payload=await fetchDashboardPayload(currentDays,force); if(seq!==loadSeq)return; - const stats=payload.stats||{}; - if(stats.error){showError(stats.error);return;} - const daily=Array.isArray(payload.daily_stats)?payload.daily_stats:[]; - lastStats=stats; - lastDaily=daily; - updateMetrics(stats); - updateTime(stats); - updateCode(stats); - updateTokens(stats); - updateCache(stats); - renderTrend(); - updateSkills(payload.skills||[]); - updateTools(stats); - updateForecast(lastDaily,stats); - markUpdated(); + if(renderDashboardPayload(payload))prefetchPeriodPayloads(seq); }catch(e){ if(seq===loadSeq)showError(e.message||String(e)); } @@ -1198,12 +1259,14 @@

成本预测 document.getElementById('project-select').addEventListener('change',e=>{ currentProject=e.target.value; currentProjectSource=e.target.selectedOptions[0]?.dataset.source||''; + clearDashboardCache(); loadStats(); }); document.getElementById('source-select').addEventListener('change',async e=>{ currentSource=e.target.value; currentProject=''; currentProjectSource=''; + clearDashboardCache(); await loadProjects(); loadStats(); }); @@ -1219,8 +1282,8 @@

成本预测 trendMode=b.dataset.trend; renderTrend(); })); -document.getElementById('refresh-button').addEventListener('click',loadStats); -document.getElementById('reload-projects-button').addEventListener('click',async()=>{await loadProjects();loadStats()}); +document.getElementById('refresh-button').addEventListener('click',()=>{clearDashboardCache();loadStats({force:true})}); +document.getElementById('reload-projects-button').addEventListener('click',async()=>{clearDashboardCache();await loadProjects();loadStats({force:true})}); document.getElementById('settings-button').addEventListener('click',async()=>{await loadProjects();document.getElementById('project-select').focus()}); document.getElementById('export-button').addEventListener('click',exportSnapshot); diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 450536e..d4b3b3f 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -262,7 +262,7 @@ def test_get_projects_source_codex_includes_codex_project( def test_health_endpoint_returns_ok() -> None: - server, port = start_server() + server, port = start_server(warm_cache=False) thread = threading.Thread( target=server.serve_forever, kwargs={"poll_interval": 0.1}, @@ -287,7 +287,7 @@ def slow_stats(*args, **kwargs): return {"ok": True} monkeypatch.setattr("cc_stats_web.server._get_stats", slow_stats) - server, port = start_server() + server, port = start_server(warm_cache=False) thread = threading.Thread( target=server.serve_forever, kwargs={"poll_interval": 0.1}, @@ -408,6 +408,86 @@ def fake_analyze_session(session, *, include_git=True): assert payload["skills"] == [] +def test_get_dashboard_payload_reuses_analyzed_sessions_between_range_tabs( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + analyzed: list[str] = [] + + def fake_analyze_session(session, *, include_git=True): + analyzed.append(session.session_id) + stats = SessionStats(session_id=session.session_id, project_path=session.project_path) + stats.user_message_count = 1 + return stats + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + today = web_server._get_dashboard_payload(source="codex", since_days=1, daily_days=1) + week = web_server._get_dashboard_payload(source="codex", since_days=7, daily_days=7) + + assert analyzed == ["codex-a"] + assert today["stats"]["session_count"] == 1 + assert week["stats"]["session_count"] == 1 + + +def test_get_dashboard_payload_keeps_short_lived_cache_when_file_mtime_moves( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + session_file = _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + analyzed: list[str] = [] + + def fake_analyze_session(session, *, include_git=True): + analyzed.append(session.session_id) + return SessionStats(session_id=session.session_id, project_path=session.project_path) + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + web_server._get_dashboard_payload(source="codex", since_days=1, daily_days=1) + now = time.time() + 10 + os.utime(session_file, (now, now)) + web_server._get_dashboard_payload(source="codex", since_days=7, daily_days=7) + + assert analyzed == ["codex-a"] + + +def test_get_dashboard_payload_singleflights_concurrent_cache_fill( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + started = threading.Event() + analyzed: list[str] = [] + + def fake_analyze_session(session, *, include_git=True): + analyzed.append(session.session_id) + started.set() + time.sleep(0.05) + return SessionStats(session_id=session.session_id, project_path=session.project_path) + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + results: list[dict] = [] + first = threading.Thread( + target=lambda: results.append(web_server._get_dashboard_payload(source="codex")), + ) + first.start() + assert started.wait(timeout=2) + second = threading.Thread( + target=lambda: results.append(web_server._get_dashboard_payload(source="codex")), + ) + second.start() + first.join(timeout=5) + second.join(timeout=5) + + assert len(results) == 2 + assert analyzed == ["codex-a"] + + def test_get_stats_prefilters_old_mtime_before_parsing( tmp_path: Path, monkeypatch, @@ -586,3 +666,39 @@ def test_stats_to_dict_returns_na_when_no_cache_reads(): assert cache["hit_rate"] == 0.0 assert cache["savings_usd"] == 0.0 assert cache["by_model"] == {} + + +def test_stats_to_dict_omits_zero_token_model_rows(): + stats = SessionStats(session_id="s4", project_path="/tmp/demo") + stats.token_usage = TokenUsage(input_tokens=100, output_tokens=20) + stats.token_by_model = { + "gpt-5.5": TokenUsage(input_tokens=100, output_tokens=20), + "cursor-model-without-token-count": TokenUsage(), + } + + result = _stats_to_dict(stats) + + assert [row["model"] for row in result["token_by_model"]] == ["gpt-5.5"] + + +def test_dashboard_html_prefetches_period_payloads(): + html = (Path(web_server._web_dir) / "index.html").read_text(encoding="utf-8") + + assert "dashboardCache" in html + assert "prefetchPeriodPayloads" in html + assert "renderDashboardPayload" in html + + +def test_warm_dashboard_cache_primes_projects_and_analyzed_stats(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(web_server, "_get_projects", lambda source=None: calls.append("projects")) + monkeypatch.setattr( + web_server, + "_get_cached_analyzed_stats", + lambda project_dir_name=None, source=None: calls.append("stats"), + ) + + web_server._warm_dashboard_cache() + + assert calls == ["stats", "projects"] From fdbaddc093cb836ad0e2df00845c8a03957db1b2 Mon Sep 17 00:00:00 2001 From: efunyang Date: Thu, 11 Jun 2026 10:08:06 +0800 Subject: [PATCH 21/26] fix: serve dashboard root explicitly --- cc_stats_web/server.py | 18 +++++++++++++++++- tests/test_web_server.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index 891bddb..07186f1 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -592,7 +592,9 @@ def do_GET(self): source = params.get("source", [None])[0] try: - if path == "/api/health": + if path in {"", "/"}: + self._serve_index() + elif path == "/api/health": self._json({"status": "ok"}) elif path == "/api/projects": self._json(_get_projects(source=source)) @@ -645,6 +647,20 @@ def _json(self, data): self.end_headers() self.wfile.write(body) + def _serve_index(self): + index_path = Path(_web_dir) / "index.html" + try: + body = index_path.read_bytes() + except OSError: + self.send_error(404, "Dashboard index not found") + return + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, format, *args): pass diff --git a/tests/test_web_server.py b/tests/test_web_server.py index d4b3b3f..2acd5e6 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -281,6 +281,30 @@ def test_health_endpoint_returns_ok() -> None: assert payload == {"status": "ok"} +def test_dashboard_root_serves_index_html() -> None: + server, port = start_server(warm_cache=False) + thread = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.1}, + daemon=True, + ) + thread.start() + + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as resp: + status = resp.status + content_type = resp.headers.get("Content-Type") + body = resp.read().decode("utf-8") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert status == 200 + assert content_type == "text/html; charset=utf-8" + assert "CC Statistics" in body + + def test_health_endpoint_responds_while_stats_request_is_busy(monkeypatch) -> None: def slow_stats(*args, **kwargs): time.sleep(0.4) From 1a8451b31a011ba735834c5f6fc20d9d7826fd8b Mon Sep 17 00:00:00 2001 From: efunyang Date: Fri, 12 Jun 2026 10:27:38 +0800 Subject: [PATCH 22/26] fix: use calendar periods for dashboard stats --- cc_stats_web/server.py | 269 +++++++++++++++++++++++++++-- cc_stats_web/web/index.html | 31 ++-- tests/test_web_dashboard_markup.py | 8 +- tests/test_web_server.py | 173 +++++++++++++++++++ 4 files changed, 448 insertions(+), 33 deletions(-) diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index 07186f1..08f34c0 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -8,12 +8,14 @@ import threading import time from collections import defaultdict +from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse +from cc_stats.cli import _trim_stats_by_date_range from cc_stats.analyzer import ( SessionStats, TokenUsage, @@ -41,6 +43,14 @@ class _ProjectsCacheEntry: created_at: float +@dataclass(frozen=True) +class _DashboardPeriodRange: + since_dt: datetime | None + since_date: str | None + until_date: str | None + daily_days: int + + _CACHE_TTL_SECONDS = 45.0 _ANALYZED_CACHE_LOCK = threading.Lock() _PROJECTS_CACHE_LOCK = threading.Lock() @@ -79,6 +89,124 @@ def _is_cache_fresh(created_at: float) -> bool: return time.monotonic() - created_at <= _CACHE_TTL_SECONDS +def _now_local() -> datetime: + return datetime.now().astimezone() + + +def _dashboard_period_range( + period: str | None, + now: datetime | None = None, +) -> _DashboardPeriodRange | None: + if not period: + return None + + normalized = period.strip().lower() + local_now = now if now is not None else _now_local() + if local_now.tzinfo is None: + local_now = local_now.astimezone() + + if normalized == "all": + return _DashboardPeriodRange(None, None, None, 30) + if normalized == "today": + start = local_now.replace(hour=0, minute=0, second=0, microsecond=0) + elif normalized == "week": + start = (local_now - timedelta(days=local_now.weekday())).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + elif normalized == "month": + start = local_now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + else: + raise ValueError(f"Unsupported dashboard period: {period}") + + since_date = start.date().strftime("%Y-%m-%d") + until_date = local_now.date().strftime("%Y-%m-%d") + daily_days = max((local_now.date() - start.date()).days + 1, 1) + return _DashboardPeriodRange( + start.astimezone(timezone.utc), + since_date, + until_date, + daily_days, + ) + + +def _date_key_in_range( + date_key: str, + since_date: str | None, + until_date: str | None, +) -> bool: + if since_date and date_key < since_date: + return False + if until_date and date_key > until_date: + return False + return True + + +def _stats_matches_local_date_range( + stats: SessionStats, + since_date: str | None, + until_date: str | None, +) -> bool: + if not since_date and not until_date: + return True + if stats.token_by_date: + return any( + usage.total > 0 and _date_key_in_range(date_key, since_date, until_date) + for date_key, usage in stats.token_by_date.items() + ) + if stats.start_time: + return _date_key_in_range( + stats.start_time.astimezone().strftime("%Y-%m-%d"), + since_date, + until_date, + ) + if stats.end_time: + return _date_key_in_range( + stats.end_time.astimezone().strftime("%Y-%m-%d"), + since_date, + until_date, + ) + return False + + +def _scale_timedelta(value: timedelta, fraction: float) -> timedelta: + return timedelta(seconds=value.total_seconds() * fraction) + + +def _scale_stats_durations(stats: SessionStats, fraction: float) -> None: + fraction = max(0.0, min(fraction, 1.0)) + stats.total_duration = _scale_timedelta(stats.total_duration, fraction) + stats.ai_duration = _scale_timedelta(stats.ai_duration, fraction) + stats.user_duration = _scale_timedelta(stats.user_duration, fraction) + stats.active_duration = _scale_timedelta(stats.active_duration, fraction) + + +def _stats_for_local_date_range( + all_stats: list[SessionStats], + since_date: str | None, + until_date: str | None, +) -> list[SessionStats]: + if not since_date and not until_date: + return all_stats + + filtered = [] + for stats in all_stats: + if not _stats_matches_local_date_range(stats, since_date, until_date): + continue + original_token_total = stats.token_usage.total + stats_copy = deepcopy(stats) + _trim_stats_by_date_range(stats_copy, since_date, until_date) + if original_token_total > 0: + _scale_stats_durations( + stats_copy, + stats_copy.token_usage.total / original_token_total, + ) + filtered.append(stats_copy) + return filtered + + def _estimate_cost(tu: TokenUsage, model: str = "") -> float: p = match_model_pricing(model) cost = 0.0 @@ -422,35 +550,113 @@ def _get_daily_stats(project_dir_name=None, days=14, source: str | None = None): return result +def _add_token_usage(target: TokenUsage, source: TokenUsage) -> None: + target.input_tokens += source.input_tokens + target.output_tokens += source.output_tokens + target.cache_read_input_tokens += source.cache_read_input_tokens + target.cache_creation_input_tokens += source.cache_creation_input_tokens + + +def _daily_token_usage_and_cost( + stats_list: list[SessionStats], + day_key: str, + fallback_stats: SessionStats, +) -> tuple[TokenUsage, float]: + usage = TokenUsage() + cost = 0.0 + saw_token_dates = False + + for stats in stats_list: + day_usage = stats.token_by_date.get(day_key) + if day_usage is None: + continue + saw_token_dates = True + _add_token_usage(usage, day_usage) + + model_map = stats.token_by_model_by_date.get(day_key) + if model_map: + cost += sum( + _estimate_cost(model_usage, model) + for model, model_usage in model_map.items() + ) + elif stats.token_usage.total > 0: + stats_cost = sum( + _estimate_cost(model_usage, model) + for model, model_usage in stats.token_by_model.items() + ) + cost += stats_cost * day_usage.total / stats.token_usage.total + + if not saw_token_dates: + usage = fallback_stats.token_usage + cost = sum( + _estimate_cost(model_usage, model) + for model, model_usage in fallback_stats.token_by_model.items() + ) + + return usage, cost + + +def _daily_active_minutes( + stats_list: list[SessionStats], + day_key: str, + fallback_stats: SessionStats, +) -> float: + seconds = 0.0 + saw_token_dates = False + for stats in stats_list: + day_usage = stats.token_by_date.get(day_key) + if day_usage is None: + continue + saw_token_dates = True + if stats.token_usage.total > 0: + seconds += ( + stats.active_duration.total_seconds() + * day_usage.total + / stats.token_usage.total + ) + + if not saw_token_dates: + seconds = fallback_stats.active_duration.total_seconds() + + return round(seconds / 60, 1) + + def _daily_stats_from_analyzed( all_stats: list[SessionStats], since_dt: datetime, days: int, + now: datetime | None = None, ) -> list[dict]: + date_keys = _daily_date_keys(since_dt, days, now=now) + date_key_set = set(date_keys) daily: dict[str, list] = defaultdict(list) for stats in all_stats: - if stats.end_time and stats.end_time < since_dt: - continue - if not stats.start_time: + if stats.token_by_date: + for day_key, usage in stats.token_by_date.items(): + if usage.total > 0 and day_key in date_key_set: + daily[day_key].append(stats) continue - day_key = stats.start_time.astimezone().strftime("%Y-%m-%d") - daily[day_key].append(stats) + if stats.start_time: + day_key = stats.start_time.astimezone().strftime("%Y-%m-%d") + if day_key in date_key_set: + daily[day_key].append(stats) result = [] - for day_key in _daily_date_keys(since_dt, days): + for day_key in date_keys: day_stats = daily.get(day_key, []) if day_stats: merged = merge_stats(day_stats) if len(day_stats) > 1 else day_stats[0] - cost = sum(_estimate_cost(u, m) for m, u in merged.token_by_model.items()) + usage, cost = _daily_token_usage_and_cost(day_stats, day_key, merged) + active_minutes = _daily_active_minutes(day_stats, day_key, merged) result.append({ "date": day_key, "sessions": len(day_stats), "messages": merged.user_message_count, "tool_calls": merged.tool_call_total, - "active_minutes": round(merged.active_duration.total_seconds() / 60, 1), + "active_minutes": active_minutes, "lines_added": merged.total_added, "lines_removed": merged.total_removed, - "tokens": merged.token_usage.total, + "tokens": usage.total, "cost": round(cost, 2), }) else: @@ -530,6 +736,7 @@ def _get_dashboard_payload( since_days=None, daily_days=30, source: str | None = None, + period: str | None = None, ): all_stats = _get_cached_analyzed_stats(project_dir_name, source=source) if not all_stats: @@ -540,15 +747,37 @@ def _get_dashboard_payload( } since_dt = None - if since_days: + period_range = _dashboard_period_range(period) + if period_range is not None: + since_dt = period_range.since_dt + stats_for_range = _stats_for_local_date_range( + all_stats, + period_range.since_date, + period_range.until_date, + ) + daily_days = period_range.daily_days + daily_source_stats = stats_for_range + elif since_days: since_dt = datetime.now(tz=timezone.utc) - timedelta(days=since_days) - stats_for_range = [ - stats for stats in all_stats - if not since_dt or not stats.end_time or stats.end_time >= since_dt - ] + stats_for_range = [ + stats for stats in all_stats + if not since_dt or not stats.end_time or stats.end_time >= since_dt + ] + daily_source_stats = all_stats + else: + stats_for_range = all_stats + daily_source_stats = all_stats + merged = _merged_stats(stats_for_range) if merged is None: - stats_payload = {"error": "No valid sessions"} + if period_range is not None: + stats_payload = _stats_to_dict( + SessionStats(session_id="", project_path=str(project_dir_name or "")), + session_count=0, + git_scan_skipped=True, + ) + else: + stats_payload = {"error": "No valid sessions"} else: stats_payload = _stats_to_dict( merged, @@ -556,10 +785,10 @@ def _get_dashboard_payload( git_scan_skipped=True, ) - daily_since = datetime.now(tz=timezone.utc) - timedelta(days=daily_days) + daily_since = since_dt or datetime.now(tz=timezone.utc) - timedelta(days=daily_days) return { "stats": stats_payload, - "daily_stats": _daily_stats_from_analyzed(all_stats, daily_since, daily_days), + "daily_stats": _daily_stats_from_analyzed(daily_source_stats, daily_since, daily_days), "skills": _skill_stats_from_analyzed(all_stats), } @@ -610,11 +839,15 @@ def do_GET(self): project = params.get("project", [None])[0] days = params.get("days", [None])[0] daily_days = params.get("daily_days", ["30"])[0] + period = params.get("period", [None])[0] self._json(_get_dashboard_payload( project_dir_name=project or None, - since_days=int(days) if days and days != "0" else None, + since_days=( + int(days) if not period and days and days != "0" else None + ), daily_days=int(daily_days), source=source, + period=period, )) elif path == "/api/daily_stats": project = params.get("project", [None])[0] diff --git a/cc_stats_web/web/index.html b/cc_stats_web/web/index.html index 3554a8a..a4043f3 100644 --- a/cc_stats_web/web/index.html +++ b/cc_stats_web/web/index.html @@ -799,10 +799,10 @@ @@ -892,10 +892,12 @@

成本预测