diff --git a/README.md b/README.md index 389ee87..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 @@ -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 @@ -244,6 +261,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..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 步搞定 @@ -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 参考 @@ -244,6 +261,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/analyzer.py b/cc_stats/analyzer.py index 349b6d5..c015165 100644 --- a/cc_stats/analyzer.py +++ b/cc_stats/analyzer.py @@ -10,7 +10,7 @@ from pathlib import Path from .parser import Message, Session, ToolCall -from .pricing import is_claude_model, match_model_pricing +from .pricing import match_model_pricing # 文件扩展名 → 语言映射 EXT_TO_LANG: dict[str, str] = { @@ -291,10 +291,8 @@ def compute_cache_stats( # savings = cache_read_tokens * (input_price - cache_read_price) / 1M savings_usd = 0.0 for model, usage in token_by_model.items(): - if not is_claude_model(model): - continue pricing = match_model_pricing(model) - savings_per_million = pricing["input"] - pricing["cache_read"] + savings_per_million = max(pricing["input"] - pricing["cache_read"], 0.0) savings_usd += usage.cache_read_input_tokens * savings_per_million / 1_000_000 # 按模型拆分命中率 @@ -368,6 +366,8 @@ def _collect_git_stats( cwd=project_path, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=10, ) if result.returncode != 0: @@ -467,7 +467,7 @@ def classify_work_mode(user_message_count: int, total_added: int, total_removed: return "Building" -def analyze_session(session: Session) -> SessionStats: +def analyze_session(session: Session, *, include_git: bool = True) -> SessionStats: """分析单个会话,返回统计结果""" stats = SessionStats( session_id=session.session_id, @@ -721,7 +721,7 @@ def analyze_session(session: Session) -> SessionStats: stats.lines_by_lang = dict(lang_stats) # -------- 4b. Git 变更统计 -------- - if stats.start_time and stats.end_time and session.project_path: + if include_git and stats.start_time and stats.end_time and session.project_path: git = _collect_git_stats( session.project_path, stats.start_time, stats.end_time ) 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..a709861 100644 --- a/cc_stats/parser.py +++ b/cc_stats/parser.py @@ -3,9 +3,12 @@ from __future__ import annotations 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 @@ -164,7 +167,22 @@ 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 _home_dir() -> Path: + home = os.environ.get("HOME") + if home: + return Path(home).expanduser() + return Path.home() def _is_subagent_file(path: Path) -> bool: @@ -203,12 +221,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 _home_dir() / ".claude" / "projects" if not claude_projects.exists(): return [] @@ -226,11 +248,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 _home_dir() / ".claude" / "projects" if not claude_projects.exists(): return [] @@ -683,9 +709,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 _home_dir() / ".codex" + base = codex_home / "sessions" if not base.exists(): return [] @@ -693,10 +724,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 +732,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 @@ -845,6 +874,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): @@ -858,9 +991,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 _home_dir() / ".gemini" + gemini_dir = gemini_home / "tmp" if not gemini_dir.exists(): return [] @@ -868,15 +1005,19 @@ def find_gemini_sessions() -> list[Path]: 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 -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 [] @@ -885,25 +1026,338 @@ def find_gemini_sessions_by_keyword(keyword: str) -> list[Path]: 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 + + +# 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): + return parse_gemini_jsonl(path) if _looks_like_codex_jsonl(path): return parse_codex_jsonl(path) return parse_jsonl(path) diff --git a/cc_stats/pricing.py b/cc_stats/pricing.py index 37b137b..9dc3aed 100644 --- a/cc_stats/pricing.py +++ b/cc_stats/pricing.py @@ -14,19 +14,25 @@ class Pricing(TypedDict): cache_create: float -# 价格来源(2026-04-16 校准): +# 价格来源(2026-06-10 校准): # - OpenAI: https://developers.openai.com/api/docs/pricing # - Anthropic: https://platform.claude.com/docs/en/about-claude/pricing # - Gemini: https://ai.google.dev/gemini-api/docs/pricing # # 注: -# - Gemini 2.5 Pro/Flash 按 <=200k context 档位计算(日志中无法精确区分每次请求是否 >200k)。 +# - 默认按 Standard / Paid / short context 或 <=200k 档位计算。 +# - Batch/Flex/Priority/Fast mode/Data residency/长上下文等需要请求模式或上下文字段,当前日志无法稳定区分。 # - OpenAI 暂无“cache write”单独价格字段,cache_create 退化为 input 单价。 MODEL_PRICING: dict[str, Pricing] = { # Claude + "claude-fable-5": {"input": 10.0, "output": 50.0, "cache_read": 1.00, "cache_create": 12.50}, + "claude-mythos-5": {"input": 10.0, "output": 50.0, "cache_read": 1.00, "cache_create": 12.50}, + "claude-opus-4.8": {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_create": 6.25}, + "claude-opus-4.7": {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_create": 6.25}, "claude-opus-4.6": {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_create": 6.25}, "claude-opus-4.5": {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_create": 6.25}, "claude-opus-4.1": {"input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_create": 18.75}, + "claude-opus-4": {"input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_create": 18.75}, "claude-sonnet-4.6": {"input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_create": 3.75}, "claude-sonnet-4.5": {"input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_create": 3.75}, "claude-sonnet-4": {"input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_create": 3.75}, @@ -34,11 +40,15 @@ class Pricing(TypedDict): # 兼容旧会话(历史模型) "claude-haiku-legacy": {"input": 0.8, "output": 4.0, "cache_read": 0.08, "cache_create": 1.0}, # OpenAI (GPT/Codex) + "gpt-5.5": {"input": 5.00, "output": 30.00, "cache_read": 0.50, "cache_create": 5.00}, + "gpt-5.5-pro": {"input": 30.00, "output": 180.00, "cache_read": 30.00, "cache_create": 30.00}, "gpt-5.4": {"input": 2.50, "output": 15.00, "cache_read": 0.25, "cache_create": 2.50}, "gpt-5.4-mini": {"input": 0.75, "output": 4.50, "cache_read": 0.075, "cache_create": 0.75}, "gpt-5.4-nano": {"input": 0.20, "output": 1.25, "cache_read": 0.020, "cache_create": 0.20}, + "gpt-5.4-pro": {"input": 30.00, "output": 180.00, "cache_read": 30.00, "cache_create": 30.00}, + "chat-latest": {"input": 5.00, "output": 30.00, "cache_read": 0.50, "cache_create": 5.00}, "gpt-5.3-codex": {"input": 1.75, "output": 14.00, "cache_read": 0.175, "cache_create": 1.75}, - "gpt-5.3-chat-latest": {"input": 1.75, "output": 14.00, "cache_read": 0.175, "cache_create": 1.75}, + "gpt-5.3-chat-latest": {"input": 5.00, "output": 30.00, "cache_read": 0.50, "cache_create": 5.00}, # 兼容旧会话(历史模型) "gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 1.25, "cache_create": 2.50}, "gpt-4o-mini": {"input": 0.15, "output": 0.60, "cache_read": 0.075, "cache_create": 0.15}, @@ -47,6 +57,10 @@ class Pricing(TypedDict): "o3-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.55, "cache_create": 1.10}, "o4-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.55, "cache_create": 1.10}, # Gemini + "gemini-3.5-flash": {"input": 1.50, "output": 9.00, "cache_read": 0.15, "cache_create": 1.50}, + "gemini-3.1-pro": {"input": 2.00, "output": 12.00, "cache_read": 0.20, "cache_create": 2.00}, + "gemini-3.1-flash-lite": {"input": 0.25, "output": 1.50, "cache_read": 0.025, "cache_create": 0.25}, + "gemini-3-flash": {"input": 0.50, "output": 3.00, "cache_read": 0.05, "cache_create": 0.50}, "gemini-2.5-pro": {"input": 1.25, "output": 10.00, "cache_read": 0.125, "cache_create": 1.25}, "gemini-2.5-flash": {"input": 0.30, "output": 2.50, "cache_read": 0.03, "cache_create": 0.30}, "gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40, "cache_read": 0.01, "cache_create": 0.10}, @@ -60,18 +74,28 @@ def match_model_pricing(model: str) -> Pricing: lower = model.lower() # OpenAI / Codex + if "gpt-5.5-pro" in lower: + return MODEL_PRICING["gpt-5.5-pro"] + if "gpt-5.5" in lower: + return MODEL_PRICING["gpt-5.5"] + if "gpt-5.4-pro" in lower: + return MODEL_PRICING["gpt-5.4-pro"] if "gpt-5.4-mini" in lower: return MODEL_PRICING["gpt-5.4-mini"] if "gpt-5.4-nano" in lower: return MODEL_PRICING["gpt-5.4-nano"] if "gpt-5.4" in lower: return MODEL_PRICING["gpt-5.4"] + if "chat-latest" in lower: + return MODEL_PRICING["chat-latest"] if "gpt-5.3-chat-latest" in lower: return MODEL_PRICING["gpt-5.3-chat-latest"] if "gpt-5.3-codex" in lower: return MODEL_PRICING["gpt-5.3-codex"] if "gpt-5" in lower and "codex" in lower: return MODEL_PRICING["gpt-5.3-codex"] + if "gpt-5" in lower: + return MODEL_PRICING["gpt-5.5"] if "gpt-4o-mini" in lower: return MODEL_PRICING["gpt-4o-mini"] if "gpt-4o" in lower: @@ -86,6 +110,16 @@ def match_model_pricing(model: str) -> Pricing: return MODEL_PRICING["o1"] # Gemini + if "gemini-3.5-flash" in lower: + return MODEL_PRICING["gemini-3.5-flash"] + if "gemini-3.1-pro" in lower: + return MODEL_PRICING["gemini-3.1-pro"] + if "gemini-3.1-flash-lite" in lower: + return MODEL_PRICING["gemini-3.1-flash-lite"] + if "gemini-3-flash" in lower: + return MODEL_PRICING["gemini-3-flash"] + if "gemini-3" in lower: + return MODEL_PRICING["gemini-3.5-flash"] if "gemini-2.5-pro" in lower: return MODEL_PRICING["gemini-2.5-pro"] if "gemini-2.5-flash-lite" in lower: @@ -98,12 +132,24 @@ def match_model_pricing(model: str) -> Pricing: return MODEL_PRICING["gemini-2.5-flash"] # Claude + if "fable" in lower: + return MODEL_PRICING["claude-fable-5"] + if "mythos" in lower: + return MODEL_PRICING["claude-mythos-5"] if "opus" in lower: + if "4.8" in lower or "4-8" in lower: + return MODEL_PRICING["claude-opus-4.8"] + if "4.7" in lower or "4-7" in lower: + return MODEL_PRICING["claude-opus-4.7"] if "4.6" in lower or "4-6" in lower: return MODEL_PRICING["claude-opus-4.6"] if "4.5" in lower or "4-5" in lower: return MODEL_PRICING["claude-opus-4.5"] - return MODEL_PRICING["claude-opus-4.1"] + if "4.1" in lower or "4-1" in lower: + return MODEL_PRICING["claude-opus-4.1"] + if "4" in lower: + return MODEL_PRICING["claude-opus-4"] + return MODEL_PRICING["claude-opus-4.8"] if "haiku" in lower: if "4.5" in lower or "4-5" in lower: return MODEL_PRICING["claude-haiku-4.5"] @@ -117,15 +163,22 @@ def match_model_pricing(model: str) -> Pricing: # 厂商回退(防止历史脏数据导致费用完全丢失) if "gpt" in lower or lower.startswith("o"): - return MODEL_PRICING["gpt-5.3-codex"] + return MODEL_PRICING["gpt-5.5"] if "gemini" in lower: - return MODEL_PRICING["gemini-2.5-flash"] + return MODEL_PRICING["gemini-3.5-flash"] return MODEL_PRICING["claude-sonnet-4.6"] def is_claude_model(model: str) -> bool: lower = model.lower() - return "claude" in lower or "sonnet" in lower or "opus" in lower or "haiku" in lower + return ( + "claude" in lower + or "sonnet" in lower + or "opus" in lower + or "haiku" in lower + or "fable" in lower + or "mythos" in lower + ) def estimate_cost_from_token_by_model(token_by_model: dict[str, Any]) -> float: 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..2e80b8a --- /dev/null +++ b/cc_stats/sources.py @@ -0,0 +1,259 @@ +"""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_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, +) + + +class SourceKind(str, Enum): + ALL = "all" + CLAUDE = "claude" + CODEX = "codex" + GEMINI = "gemini" + CURSOR = "cursor" + + +@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 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 + + +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, SourceKind.CURSOR) + 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, + )) + 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)) + + +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())) + elif kind == SourceKind.CURSOR: + files.extend(_find_cursor_sessions_by_keyword(keyword)) + 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: + sessions = parse_sessions(path) + except (OSError, ValueError): + continue + 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( + 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) + + +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 + 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: + sessions = parse_sessions(path) + except (OSError, ValueError): + continue + 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 + 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/__main__.py b/cc_stats_web/__main__.py index 28ae7df..aa28e8b 100644 --- a/cc_stats_web/__main__.py +++ b/cc_stats_web/__main__.py @@ -1,19 +1,54 @@ -"""cc-stats-web: start local web dashboard and open browser""" +"""cc-stats-web: start local web dashboard and optionally open browser.""" +import argparse +import json import threading import webbrowser from .server import start_server -def main(): +def _build_startup_payload(host: str, port: int) -> dict: + url = f"http://{host}:{port}/" + return { + "event": "cc_stats_web_started", + "host": host, + "port": port, + "url": url, + } + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="cc-stats-web", + description="Start the local CC Statistics web dashboard.", + ) + parser.add_argument( + "--no-browser", + action="store_true", + help="Start the server without opening the default browser.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print a structured startup JSON line for desktop shells.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None): + args = _parse_args(argv) server, port = start_server() - url = f"http://127.0.0.1:{port}/" - print(f"CC Stats Web Dashboard: {url}") - print("Press Ctrl+C to stop.") - - # Open browser after short delay - threading.Timer(0.5, lambda: webbrowser.open(url)).start() + payload = _build_startup_payload("127.0.0.1", port) + url = payload["url"] + if args.json: + print(json.dumps(payload, ensure_ascii=False), flush=True) + else: + print(f"CC Stats Web Dashboard: {url}") + print("Press Ctrl+C to stop.") + + if not args.no_browser: + threading.Timer(0.5, lambda: webbrowser.open(url)).start() try: server.serve_forever() diff --git a/cc_stats_web/server.py b/cc_stats_web/server.py index aa85f78..89f56d4 100644 --- a/cc_stats_web/server.py +++ b/cc_stats_web/server.py @@ -5,11 +5,17 @@ import json import os import socket +import threading +import time from collections import defaultdict +from copy import deepcopy +from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from http.server import HTTPServer, SimpleHTTPRequestHandler +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, @@ -17,45 +23,192 @@ 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, parse_sessions _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"] + +@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 + + +@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() +_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 _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_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,23 +217,11 @@ 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 _stats_to_dict( + stats: SessionStats, + session_count: int = 1, + git_scan_skipped: bool = False, +) -> dict: def _td_seconds(td): return td.total_seconds() @@ -115,6 +256,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({ @@ -146,6 +289,7 @@ def _token_dict(tu): "git_total_added": stats.git_total_added, "git_total_removed": stats.git_total_removed, "git_commit_count": stats.git_commit_count, + "git_scan_skipped": git_scan_skipped, "token_usage": _token_dict(stats.token_usage), "token_by_model": model_tokens, "estimated_cost": round(total_cost, 2), @@ -161,138 +305,228 @@ 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", - }) +def _get_projects(source: str | None = None): + 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 - # 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", - }) - 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: + sessions = _parse_sessions_from_file(f) + except Exception: + continue + if any(session.project_path == project_dir_name for session in sessions): + filtered.append(f) + continue + if ( + any(session.source == "claude" for session in sessions) + 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) - if not files: - return {"error": "No sessions found"} +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)] - files.sort(key=lambda f: f.stat().st_mtime) - since_dt = None - if since_days: - since_dt = datetime.now(tz=timezone.utc) - timedelta(days=since_days) +def _filter_files_by_mtime(files: list, since_dt: datetime | None): + if since_dt is None: + return files + + threshold = since_dt.timestamp() + filtered = [] + for f in files: + try: + if f.stat().st_mtime >= threshold: + filtered.append(f) + except OSError: + filtered.append(f) + 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: - session = _parse_session_file(f) - stats = analyze_session(session) - if since_dt and stats.end_time and stats.end_time < since_dt: - continue - all_stats.append(stats) + 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 _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 + return all_stats[0] if len(all_stats) == 1 else merge_stats(all_stats) + + +def _daily_date_keys( + since_dt: datetime, + days: int, + now: datetime | None = None, +) -> list[str]: + now_dt = now or datetime.now(tz=timezone.utc) + if days <= 1: + start_date = since_dt.astimezone().date() + end_date = now_dt.astimezone().date() + if start_date > end_date: + start_date = end_date + span = (end_date - start_date).days + return [ + (start_date + timedelta(days=i)).strftime("%Y-%m-%d") + for i in range(span + 1) + ] + + today = now_dt.astimezone().date() + return [ + (today - timedelta(days=i)).strftime("%Y-%m-%d") + for i in range(days - 1, -1, -1) + ] + + +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"} + + since_dt = None + if since_days: + since_dt = datetime.now(tz=timezone.utc) - timedelta(days=since_days) + + files = _filter_files_by_mtime(files, since_dt) + files.sort(key=lambda f: f.stat().st_mtime) + + 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) - return _stats_to_dict(result, session_count=len(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), + git_scan_skipped=True, + ) -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) + 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) - 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 = [] - today = datetime.now().date() - for i in range(days - 1, -1, -1): - d = today - timedelta(days=i) - day_key = d.strftime("%Y-%m-%d") + 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] @@ -316,31 +550,143 @@ def _get_daily_stats(project_dir_name=None, days=14): return result -def _get_skill_stats(project_dir_name=None, since_days=None): +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.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 + 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 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] + 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": active_minutes, + "lines_added": merged.total_added, + "lines_removed": merged.total_removed, + "tokens": 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 _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 [] 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) - 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( @@ -361,8 +707,96 @@ def _get_skill_stats(project_dir_name=None, since_days=None): 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( + 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 _get_dashboard_payload( + project_dir_name=None, + 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: + return { + "stats": {"error": "No sessions found"}, + "daily_stats": [], + "skills": [], + } + + since_dt = None + 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 + ] + 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: + 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, + session_count=len(stats_for_range), + git_scan_skipped=True, + ) + + daily_since = since_dt or datetime.now(tz=timezone.utc) - timedelta(days=daily_days) + return { + "stats": stats_payload, + "daily_stats": _daily_stats_from_analyzed(daily_source_stats, daily_since, daily_days), + "skills": _skill_stats_from_analyzed(all_stats), + } + + def _get_version_update(): """检查版本更新(供 Web API 使用)""" + if os.environ.get("CC_STATS_DESKTOP_SHELL") == "1": + return {"has_update": False} try: from cc_stats.version_checker import check_for_update result = check_for_update() @@ -386,34 +820,59 @@ 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 in {"", "/"}: + self._serve_index() + elif 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] + 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/dashboard": + 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 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] + 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") @@ -423,17 +882,45 @@ 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 +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[HTTPServer, int]: +def start_server(warm_cache: bool = True) -> tuple[CcStatsHTTPServer, int]: port = find_free_port() - server = HTTPServer(("127.0.0.1", port), ApiHandler) + 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 03986fe..a4043f3 100644 --- a/cc_stats_web/web/index.html +++ b/cc_stats_web/web/index.html @@ -1,327 +1,1321 @@ - + CC Statistics -
+
- +
-
-

CC Statistics

-
- - -
- - - - + +
+
+
+ + +
+
+ +
-
-
-
Instructions
-
Tool Calls
-
Active Time
-
Est. Cost
-
-
Daily Trend
-
-
Dev Time
-
Code Changes
-
Token Usage
-
Tool Calls
-
-
Cache Grade
- -
+ + + +
+
+
+
会话i
+
...
+
+
+
+
指令i
+
...
+
+
+
+
时长i
+
...
+
+
+
+
预计费用i
+
...
+
+
+
+ +
+
+

TOKEN 用量i

+
+
+
+
+
+
+ +
+
+

每日趋势

+
+ + + + +
+
+
+
+ +
+
+

成本预测i

+
+
+
+
+
+ +
+
+

开发时长

+
+
+
+

代码变化

+
+
+
+

工具调用

+
+
+
+

Skill 使用

+
+
+
+
+ +
+ + +
+ 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/package-lock.json b/desktop/cc-stats-tauri/package-lock.json new file mode 100644 index 0000000..257fabb --- /dev/null +++ b/desktop/cc-stats-tauri/package-lock.json @@ -0,0 +1,1351 @@ +{ + "name": "cc-stats-tauri", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cc-stats-tauri", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "2.11.0" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.2", + "typescript": "^5.9.3", + "vite": "^7.2.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/desktop/cc-stats-tauri/package.json b/desktop/cc-stats-tauri/package.json new file mode 100644 index 0000000..39201df --- /dev/null +++ b/desktop/cc-stats-tauri/package.json @@ -0,0 +1,21 @@ +{ + "name": "cc-stats-tauri", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tauri dev", + "build": "tauri build", + "dev:web": "vite --host 127.0.0.1", + "build:web": "vite build", + "test": "node --test tests/*.test.mjs" + }, + "dependencies": { + "@tauri-apps/api": "2.11.0" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.2", + "typescript": "^5.9.3", + "vite": "^7.2.7" + } +} 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..e798821 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.lock @@ -0,0 +1,5238 @@ +# 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", + "tauri-plugin-single-instance", + "windows-sys 0.61.2", +] + +[[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-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" +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.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" +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 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" +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_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" +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_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" +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_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" +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_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" +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_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" +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_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" +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 = "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" +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..9d5225f --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/Cargo.toml @@ -0,0 +1,20 @@ +[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" +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/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.ico b/desktop/cc-stats-tauri/src-tauri/icons/icon.ico new file mode 100644 index 0000000..393291d Binary files /dev/null and b/desktop/cc-stats-tauri/src-tauri/icons/icon.ico differ 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 0000000..b60851d Binary files /dev/null and b/desktop/cc-stats-tauri/src-tauri/icons/icon.png differ 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..c688217 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/api_process.rs @@ -0,0 +1,635 @@ +use std::{ + env, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::mpsc, + thread, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +use serde::{Deserialize, Serialize}; + +use crate::health::{is_api_healthy, ApiState}; + +const HEALTH_FAILURE_THRESHOLD: u8 = 3; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[derive(Clone, Debug, Serialize)] +pub struct ApiStatus { + pub state: ApiState, + pub url: Option, + pub error: Option, +} + +pub struct ApiProcessManager { + child: Option, + health_failures: u8, + python_source_dir: Option, + status: ApiStatus, +} + +impl ApiProcessManager { + 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(manager.python_source_dir.as_deref()) { + Ok((child, url)) => { + manager.child = Some(child); + manager.status = ApiStatus { + state: ApiState::Running, + url: Some(url), + error: None, + }; + manager + } + Err(error) => Self::failed_with_python_source(error, manager.python_source_dir.clone()), + } + } + + 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, + error: Some(error), + }, + } + } + + pub fn status(&mut self) -> ApiStatus { + 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_with_python_source(self.python_source_dir.clone()); + self.child = next.child.take(); + self.health_failures = next.health_failures; + 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.health_failures = 0; + self.status.state = ApiState::Stopped; + } + + fn refresh_child_status(&mut self) { + if !matches!( + self.status.state, + ApiState::Running | ApiState::Failed | ApiState::Starting + ) { + return; + } + + if let Some(child) = self.child.as_mut() { + 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; + } + } + } + + 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()); + } + } + + #[cfg(test)] + fn running_for_test(url: &str) -> Self { + 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()), + error: None, + }, + } + } +} + +impl Drop for ApiProcessManager { + fn drop(&mut self) { + self.stop(); + } +} + +pub fn candidate_python_commands() -> Vec> { + let configured_python = env::var("CC_STATS_PYTHON") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + candidate_python_commands_for_platform(cfg!(windows), configured_python) +} + +pub fn candidate_python_commands_for_platform( + is_windows: bool, + configured_python: Option, +) -> Vec> { + let mut commands = Vec::new(); + if let Some(python) = configured_python { + push_python_command(&mut commands, vec![python]); + } + + if is_windows { + push_python_command(&mut commands, vec!["pythonw".to_string()]); + push_python_command(&mut commands, vec!["python".to_string()]); + push_python_command(&mut commands, vec!["py".to_string(), "-3".to_string()]); + push_python_command(&mut commands, vec!["python3".to_string()]); + } else { + push_python_command(&mut commands, vec!["/opt/homebrew/bin/python3".to_string()]); + push_python_command(&mut commands, vec!["/usr/local/bin/python3".to_string()]); + push_python_command(&mut commands, vec!["python3".to_string()]); + push_python_command(&mut commands, vec!["python".to_string()]); + } + commands +} + +fn push_python_command(commands: &mut Vec>, command: Vec) { + if !commands.contains(&command) { + commands.push(command); + } +} + +#[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"]); + command.env("CC_STATS_DESKTOP_SHELL", "1"); + 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() { + 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(python_source_dir: Option<&Path>) -> Result<(Child, String), String> { + let commands = candidate_python_commands(); + let mut errors = Vec::new(); + for python in &commands { + match spawn_with_python(python, python_source_dir) { + Ok(started) => return Ok(started), + Err(error) => errors.push(format!("{}: {error}", python.join(" "))), + } + } + + let attempted = commands + .iter() + .map(|command| command.join(" ")) + .collect::>() + .join(", "); + if errors.is_empty() { + Err(format!("Unable to start cc_stats_web with {attempted}")) + } else { + Err(format!( + "Unable to start cc_stats_web with {attempted}; attempts: {}", + errors.join(" | ") + )) + } +} + +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() + .map_err(|err| format!("failed to spawn {}: {err}", python.join(" ")))?; + let stderr_handle = capture_stderr(&mut child); + + 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); + let mut sent_url = false; + for line in reader.lines().map_while(Result::ok) { + if !sent_url { + if let Some(url) = parse_startup_url(&line) { + let _ = tx.send(url); + sent_url = true; + } + } + } + }); + + match rx.recv_timeout(Duration::from_secs(8)) { + Ok(url) => match wait_for_api_health(&mut child, &url) { + Ok(()) => Ok((child, url)), + Err(error) => Err(fail_child_with_stderr(child, stderr_handle, error)), + }, + Err(err) => Err(fail_child_with_stderr( + child, + stderr_handle, + format!("cc_stats_web did not report a startup URL: {err}"), + )), + } +} + +fn capture_stderr(child: &mut Child) -> Option> { + let stderr = child.stderr.take()?; + Some(thread::spawn(move || { + let reader = BufReader::new(stderr); + reader + .lines() + .map_while(Result::ok) + .collect::>() + .join("\n") + })) +} + +fn fail_child_with_stderr( + mut child: Child, + stderr_handle: Option>, + message: String, +) -> String { + let _ = child.kill(); + let _ = child.wait(); + let stderr = stderr_handle + .and_then(|handle| handle.join().ok()) + .unwrap_or_default(); + let stderr = stderr.trim(); + if stderr.is_empty() { + message + } else { + format!("{message}; stderr: {stderr}") + } +} + +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, + url: String, +} + +#[cfg(test)] +mod tests { + use super::{ + build_api_command, build_api_command_with_python_source, candidate_python_commands, + candidate_python_commands_for_platform, capture_stderr, parse_startup_url, + spawn_with_python, ApiProcessManager, ApiStatus, + }; + use crate::health::ApiState; + use std::{ + path::PathBuf, + process::{Child, Command, Stdio}, + }; + + #[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 api_command_sets_pythonpath_to_bundled_source_dir() { + let python = vec!["python3".to_string()]; + let source_dir = if cfg!(windows) { + PathBuf::from(r"C:\cc-statistics\resources\python") + } else { + PathBuf::from("/tmp/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 api_command_marks_python_dashboard_as_desktop_shell() { + let python = vec!["python3".to_string()]; + let command = build_api_command(&python); + + assert!(command.get_envs().any(|(key, value)| { + key == "CC_STATS_DESKTOP_SHELL" && value == Some(std::ffi::OsStr::new("1")) + })); + } + + #[test] + fn capture_stderr_takes_child_pipe_to_prevent_blocking_api() { + let mut command = if cfg!(windows) { + let mut command = Command::new("cmd"); + command.args(["/C", "echo noisy>&2"]); + command + } else { + let mut command = Command::new("sh"); + command.args(["-c", "echo noisy >&2"]); + command + }; + command.stderr(Stdio::piped()); + let mut child = command.spawn().unwrap(); + + let handle = capture_stderr(&mut child); + + assert!(child.stderr.is_none()); + let _ = child.wait(); + assert_eq!( + handle.and_then(|handle| handle.join().ok()).as_deref(), + Some("noisy") + ); + } + + #[test] + fn candidate_python_commands_are_not_empty() { + assert!(!candidate_python_commands().is_empty()); + } + + #[test] + fn windows_prefers_pythonw_to_avoid_console_window() { + let commands = candidate_python_commands_for_platform(true, None); + + assert_eq!(commands[0], vec!["pythonw".to_string()]); + } + + #[test] + fn candidate_python_commands_prefers_configured_python() { + let commands = + candidate_python_commands_for_platform(false, Some("/custom/bin/python3".to_string())); + + assert_eq!(commands[0], vec!["/custom/bin/python3".to_string()]); + } + + #[test] + fn candidate_python_commands_include_homebrew_paths_on_macos() { + let commands = candidate_python_commands_for_platform(false, None); + + assert!(commands.contains(&vec!["/opt/homebrew/bin/python3".to_string()])); + assert!(commands.contains(&vec!["/usr/local/bin/python3".to_string()])); + } + + #[test] + fn startup_failure_includes_stderr_output() { + let command = if cfg!(windows) { + vec![ + "cmd".to_string(), + "/C".to_string(), + "echo module missing 1>&2 & exit /B 42".to_string(), + ] + } else { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo 'module missing' >&2; exit 42".to_string(), + ] + }; + let err = spawn_with_python(&command, None).unwrap_err(); + + assert!(err.contains("module missing"), "{err}"); + } + + #[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::Failed); + assert_eq!(status.url.as_deref(), Some("http://localhost:61234/")); + 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 child = sleep_child(); + let mut manager = + ApiProcessManager::running_with_child_for_test("http://localhost:61234/", child); + + 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/")); + assert!(status.error.unwrap().contains("health check failed")); + } + + #[test] + 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()), + error: Some("cc_stats_web health check failed".to_string()), + }, + }; + + let status = manager.apply_health_probe(&url, true); + + assert_eq!(status.state, ApiState::Failed); + assert_eq!(status.url.as_deref(), Some(url.as_str())); + 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/external_browser.rs b/desktop/cc-stats-tauri/src-tauri/src/external_browser.rs new file mode 100644 index 0000000..2f629ef --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/external_browser.rs @@ -0,0 +1,122 @@ +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/health.rs b/desktop/cc-stats-tauri/src-tauri/src/health.rs new file mode 100644 index 0000000..3c4b5f4 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/health.rs @@ -0,0 +1,150 @@ +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + time::Duration, +}; + +use serde::Serialize; + +const HEALTH_TIMEOUT: Duration = Duration::from_millis(800); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ApiState { + Starting, + Running, + Failed, + 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, HEALTH_TIMEOUT) else { + return false; + }; + let _ = stream.set_read_timeout(Some(HEALTH_TIMEOUT)); + let _ = stream.set_write_timeout(Some(HEALTH_TIMEOUT)); + + let request = + 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 = String::new(); + if stream.read_to_string(&mut response).is_err() { + return false; + } + 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 { + 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\"" + ); + } + + #[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.0")); + 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_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_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 new file mode 100644 index 0000000..cbab6db --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/main.rs @@ -0,0 +1,145 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::{path::PathBuf, sync::Mutex, thread, time::Duration}; + +use api_process::{ApiProcessManager, ApiStatus}; +use health::{is_api_healthy, ApiState}; +use tauri::{AppHandle, Manager, State}; + +mod api_process; +mod external_browser; +mod health; +mod notifications; +mod tray; +mod window; + +struct AppState { + api: Mutex, +} + +#[tauri::command] +fn api_status(state: State<'_, AppState>) -> ApiStatus { + probe_api_status(&state.api) +} + +#[tauri::command] +fn restart_api(app: AppHandle, state: State<'_, AppState>) -> Result { + 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) +} + +#[tauri::command] +fn open_dashboard(state: State<'_, AppState>) -> Result<(), String> { + 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 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() { + notifications::api_start_failed(&app, error); + } + } + last_state = status.state; + } + }); +} + +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( + 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); + } + app.manage(AppState { + api: Mutex::new(api), + }); + start_api_health_monitor(app.handle().clone(), initial_status.state); + 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/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-tauri/src/tray.rs b/desktop/cc-stats-tauri/src-tauri/src/tray.rs new file mode 100644 index 0000000..8b7a32c --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/src/tray.rs @@ -0,0 +1,49 @@ +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, +}; + +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>)?; + 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])?; + + let mut builder = TrayIconBuilder::new() + .menu(&menu) + .tooltip("CC Statistics") + .show_menu_on_left_click(false); + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + + builder + .on_menu_event(|app, event| match event.id.as_ref() { + "open_dashboard" => { + let _ = open_dashboard_for_app(app); + } + "restart_api" => { + let _ = restart_api(app.clone(), app.state()); + } + "quit" => { + quit_app(app); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let _ = open_dashboard_for_app(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..3bda8b9 --- /dev/null +++ b/desktop/cc-stats-tauri/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "$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, + "icon": [ + "icons/icon.ico" + ], + "resources": { + "../../../cc_stats": "python/cc_stats", + "../../../cc_stats_web": "python/cc_stats_web" + }, + "targets": "all" + } +} diff --git a/desktop/cc-stats-tauri/src/apiClient.js b/desktop/cc-stats-tauri/src/apiClient.js new file mode 100644 index 0000000..48a0e8c --- /dev/null +++ b/desktop/cc-stats-tauri/src/apiClient.js @@ -0,0 +1,13 @@ +import { invoke } from "@tauri-apps/api/core"; + +export async function apiStatus() { + return invoke("api_status"); +} + +export async function restartApi() { + return invoke("restart_api"); +} + +export async function openDashboard() { + return invoke("open_dashboard"); +} diff --git a/desktop/cc-stats-tauri/src/dashboard.js b/desktop/cc-stats-tauri/src/dashboard.js new file mode 100644 index 0000000..605dbd9 --- /dev/null +++ b/desktop/cc-stats-tauri/src/dashboard.js @@ -0,0 +1,58 @@ +export const STATUS_POLL_INTERVAL_MS = 3000; + +export function normalizeApiBaseUrl(url) { + return String(url || "").replace(/\/+$/, ""); +} + +export function dashboardUrl(apiBaseUrl) { + const base = normalizeApiBaseUrl(apiBaseUrl); + return base ? `${base}/` : ""; +} + +export function frameUrlForStatus(status) { + if (status?.state !== "running") { + return ""; + } + return dashboardUrl(status.url); +} + +export function updateFrameForStatus(frameEl, status) { + if (!frameEl) { + return false; + } + const nextUrl = frameUrlForStatus(status); + const currentUrl = + typeof frameEl.getAttribute === "function" + ? frameEl.getAttribute("src") || "" + : frameEl.src || ""; + if (currentUrl === nextUrl) { + return false; + } + if (nextUrl) { + if (typeof frameEl.setAttribute === "function") { + frameEl.setAttribute("src", nextUrl); + } else { + frameEl.src = nextUrl; + } + } else if (typeof frameEl.removeAttribute === "function") { + frameEl.removeAttribute("src"); + } else { + frameEl.src = ""; + } + return true; +} + +export function statusLabel(status, error = "") { + switch (status) { + case "starting": + return "Starting API..."; + case "running": + return "API running"; + case "failed": + return error ? `API failed: ${error}` : "API failed"; + case "stopped": + return "API stopped"; + default: + return "Unknown status"; + } +} diff --git a/desktop/cc-stats-tauri/src/index.html b/desktop/cc-stats-tauri/src/index.html new file mode 100644 index 0000000..e9091e6 --- /dev/null +++ b/desktop/cc-stats-tauri/src/index.html @@ -0,0 +1,100 @@ + + + + + + CC Statistics + + + +
+
+
+

CC Statistics

+
Starting API...
+
+
+ + +
+
+ +
+ + + diff --git a/desktop/cc-stats-tauri/src/main.js b/desktop/cc-stats-tauri/src/main.js new file mode 100644 index 0000000..565d1f8 --- /dev/null +++ b/desktop/cc-stats-tauri/src/main.js @@ -0,0 +1,36 @@ +import { apiStatus, openDashboard, restartApi } from "./apiClient.js"; +import { STATUS_POLL_INTERVAL_MS, statusLabel, updateFrameForStatus } 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, status.error); + updateFrameForStatus(frameEl, status); +} + +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}`; +}); + +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 new file mode 100644 index 0000000..e9d0010 --- /dev/null +++ b/desktop/cc-stats-tauri/tests/frontend.test.mjs @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + dashboardUrl, + frameUrlForStatus, + normalizeApiBaseUrl, + STATUS_POLL_INTERVAL_MS, + statusLabel, + updateFrameForStatus, +} 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"); +}); + +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/", + ); +}); + +test("status polling interval is responsive without hammering the api", () => { + assert.equal(STATUS_POLL_INTERVAL_MS, 3000); +}); + +test("updateFrameForStatus does not reload the same dashboard url", () => { + let writes = 0; + const attrs = new Map([["src", "http://127.0.0.1:61234/"]]); + const frame = { + getAttribute(name) { + return attrs.get(name) || ""; + }, + setAttribute(name, value) { + writes += 1; + attrs.set(name, value); + }, + removeAttribute(name) { + writes += 1; + attrs.delete(name); + }, + }; + + const changed = updateFrameForStatus(frame, { + state: "running", + url: "http://127.0.0.1:61234/", + }); + + assert.equal(changed, false); + assert.equal(writes, 0); +}); 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, + }, +}); diff --git a/docs/superpowers/plans/2026-06-09-cross-platform-core-api-foundation.md b/docs/superpowers/plans/2026-06-09-cross-platform-core-api-foundation.md new file mode 100644 index 0000000..2558c99 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-cross-platform-core-api-foundation.md @@ -0,0 +1,589 @@ +# Cross-Platform Core API Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the cross-platform Python core and local API foundation that a Windows tray app can consume without duplicating parser/analyzer logic. + +**Architecture:** Introduce one source/provider registry for Claude, Codex, and Gemini session discovery, then refactor CLI and Web API to use it. This keeps platform path rules, source filtering, and parser selection in one place so macOS Swift, future Windows tray, and Web UI can converge on the same local API. + +**Tech Stack:** Python 3.10+ standard library, existing `cc_stats.parser` and `cc_stats.analyzer`, pytest, current static HTML/JS dashboard. + +--- + +## Scope + +This plan delivers the core/API foundation for Windows tray support: + +- One Python source registry for Claude, Codex, and Gemini. +- Environment-variable path overrides for cross-platform testing and future Windows paths. +- CLI list/default/all flows using the registry. +- Web dashboard API using the registry, including Codex support and real source filtering. +- Tests that prove Codex appears in Web/API output and filtering works. + +The Windows tray shell itself should be implemented in a follow-up plan after this foundation lands. That follow-up should choose Tauri or Electron and consume the local API created here. + +## File Structure + +- Create `cc_stats/sources.py` + - Defines `SourceKind`, `SourceProject`, source discovery helpers, and registry helpers. + - Owns environment overrides and session file discovery for all file-backed sources. + +- Modify `cc_stats/parser.py` + - Adds optional home/path parameters for source discovery helpers while preserving existing public behavior. + - Keeps parsing logic unchanged. + +- Modify `cc_stats/cli.py` + - Replaces scattered source discovery for list/default/all/report-adjacent flows with `cc_stats.sources`. + - Keeps user-visible commands stable. + +- Modify `cc_stats_web/server.py` + - Replaces local Claude/Gemini-only discovery with the source registry. + - Adds `source` query support for `/api/projects`, `/api/stats`, `/api/daily_stats`, and `/api/skills`. + - Uses `cc_stats.pricing` instead of private duplicate pricing logic when possible. + +- Modify `cc_stats_web/web/index.html` + - Sends selected source to API calls, not only to client-side project filtering. + - Shows Codex project tags consistently. + +- Create `tests/test_sources.py` + - Covers provider discovery, project grouping, source filtering, and env path overrides. + +- Modify `tests/test_web_server.py` + - Adds Codex-backed API tests and source-filter tests. + +--- + +### Task 1: Add Source Registry Contract + +**Files:** +- Create: `cc_stats/sources.py` +- Create: `tests/test_sources.py` + +- [ ] **Step 1: Write failing source registry tests** + +Create tests proving: + +- `collect_session_files(source=SourceKind.ALL)` includes Codex sessions. +- `list_projects(source=SourceKind.CODEX)` groups Codex sessions by `cwd`. +- `collect_session_files(source=SourceKind.CODEX)` excludes Gemini and Claude sessions. +- Env path overrides do not require real home-directory data. + +Use synthetic `tmp_path` fixtures and monkeypatch: + +- `CC_STATS_CLAUDE_PROJECTS_DIR` +- `CC_STATS_CODEX_HOME` +- `CC_STATS_GEMINI_HOME` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +python -m pytest tests/test_sources.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'cc_stats.sources'`. + +- [ ] **Step 3: Implement `cc_stats/sources.py`** + +Create a unified source registry with: + +```python +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 +``` + +Implement: + +- `normalize_source(source)` +- `active_sources(source)` +- `collect_session_files(source=None, project_dir=None)` +- `list_projects(source=None)` +- `parse_file(path)` + +Provider behavior: + +- Claude uses `find_sessions(project_dir, projects_dir=claude_projects_dir())`. +- Codex uses `find_codex_sessions(project_dir, codex_home_dir=codex_home())`. +- Gemini uses `find_gemini_sessions(gemini_home_dir=gemini_home())`. +- Project listing groups Codex/Gemini by parsed `session.project_path`. +- Claude project listing preserves existing project directory behavior but resolves display name from session data where available. + +- [ ] **Step 4: Run tests and observe the next failure** + +```bash +python -m pytest tests/test_sources.py -q +``` + +Expected: FAIL because parser discovery helpers do not yet accept the new keyword arguments. + +--- + +### Task 2: Add Path Overrides to Existing Parsers + +**Files:** +- Modify: `cc_stats/parser.py` +- Test: `tests/test_sources.py` + +- [ ] **Step 1: Update parser discovery signatures** + +Add keyword-only override parameters while preserving default behavior: + +```python +def find_sessions( + project_dir: Path | None = None, + *, + projects_dir: Path | None = None, +) -> list[Path]: +``` + +```python +def find_sessions_by_keyword( + keyword: str, + *, + projects_dir: Path | None = None, +) -> list[Path]: +``` + +```python +def find_codex_sessions( + project_dir: Path | None = None, + *, + codex_home_dir: Path | None = None, +) -> list[Path]: +``` + +```python +def find_codex_sessions_by_keyword( + keyword: str, + *, + codex_home_dir: Path | None = None, +) -> list[Path]: +``` + +```python +def find_gemini_sessions( + *, + gemini_home_dir: Path | None = None, +) -> list[Path]: +``` + +```python +def find_gemini_sessions_by_keyword( + keyword: str, + *, + gemini_home_dir: Path | None = None, +) -> list[Path]: +``` + +- [ ] **Step 2: Run source tests** + +```bash +python -m pytest tests/test_sources.py -q +``` + +Expected: PASS. + +- [ ] **Step 3: Run parser regression tests** + +```bash +python -m pytest tests/test_codex_parser.py tests/test_subagent_sessions.py -q +``` + +Expected: PASS. + +--- + +### Task 3: Refactor CLI to Use Source Registry + +**Files:** +- Modify: `cc_stats/cli.py` +- Test: `tests/test_sources.py` + +- [ ] **Step 1: Add CLI source-list regression test** + +Add a test documenting the CLI-facing project shape: + +```python +assert [(p.source.value, Path(p.display_name).name, p.session_count) for p in projects] == [ + ("codex", "demo", 1) +] +``` + +- [ ] **Step 2: Replace CLI imports** + +Import from the registry: + +```python +from .sources import SourceKind, collect_session_files, list_projects, parse_file +``` + +Keep keyword-search parser imports until they are folded into the registry. + +- [ ] **Step 3: Replace `_parse_session`** + +```python +def _parse_session(path: Path): + return parse_file(path) +``` + +- [ ] **Step 4: Replace `_list_projects`** + +Use `list_projects()` and group display by: + +- Claude Code +- Codex +- Gemini CLI + +- [ ] **Step 5: Replace main session collection paths** + +Use: + +```python +session_files = collect_session_files() +session_files = collect_session_files(project_dir=Path.cwd()) +session_files = collect_session_files(project_dir=p) +``` + +depending on the existing branch. + +- [ ] **Step 6: Replace rate-limit and git collection** + +Use: + +```python +session_files: list[Path] = collect_session_files() +``` + +- [ ] **Step 7: Run CLI-related tests** + +```bash +python -m pytest tests/test_sources.py tests/test_cli_version.py tests/test_codex_parser.py -q +``` + +Expected: PASS. + +- [ ] **Step 8: Manual smoke test** + +```bash +python -m cc_stats.cli --list +python -m cc_stats.cli --all --since 1d +``` + +Expected: `--list` still groups sources; `--all` still returns a stats report. + +--- + +### Task 4: Refactor Web API to Use Source Registry and Add Codex + +**Files:** +- Modify: `cc_stats_web/server.py` +- Test: `tests/test_web_server.py` + +- [ ] **Step 1: Add failing Web API tests** + +Add tests proving: + +- `_get_projects(source="codex")` returns Codex projects. +- `_collect_session_files(source="codex")` returns Codex files. +- `_get_stats(source="codex")` parses Codex token usage. +- Non-Codex source filters exclude Codex sessions. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +python -m pytest tests/test_web_server.py::test_web_projects_include_codex tests/test_web_server.py::test_web_stats_source_filter_uses_codex -q +``` + +Expected: FAIL because Web server does not accept `source` and does not include Codex. + +- [ ] **Step 3: Replace Web parser imports and pricing** + +Use: + +```python +from cc_stats.parser import parse_session_file +from cc_stats.sources import collect_session_files, list_projects +``` + +If pricing can be cleanly shared, route cost calculation through `cc_stats.pricing`; otherwise leave pricing unchanged and avoid scope creep. + +- [ ] **Step 4: Replace `_get_projects`** + +```python +def _get_projects(source: str | None = None): + return [ + { + "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) + ] +``` + +- [ ] **Step 5: Replace `_collect_session_files`** + +```python +def _collect_session_files(project_dir_name=None, source: str | None = None): + files = collect_session_files(source=source) + if not project_dir_name: + return files + + filtered = [] + for path in files: + try: + session = parse_session_file(path) + except Exception: + continue + if session.project_path == project_dir_name: + filtered.append(path) + return filtered +``` + +- [ ] **Step 6: Replace `_parse_session_file`** + +```python +def _parse_session_file(f): + return parse_session_file(f) +``` + +- [ ] **Step 7: Add `source` parameter to stats helpers** + +Add `source: str | None = None` to: + +- `_get_stats` +- `_get_daily_stats` +- `_get_skill_stats` + +Pass it into `_collect_session_files`. + +- [ ] **Step 8: Wire `source` through HTTP endpoints** + +Read: + +```python +source = params.get("source", [None])[0] +``` + +Pass it through `/api/projects`, `/api/stats`, `/api/daily_stats`, and `/api/skills`. + +- [ ] **Step 9: Run Web tests** + +```bash +python -m pytest tests/test_web_server.py -q +``` + +Expected: PASS. + +--- + +### Task 5: Send Source Filter from Web UI + +**Files:** +- Modify: `cc_stats_web/web/index.html` + +- [ ] **Step 1: Modify `loadProjects` to request source** + +```javascript +allProjects = await api('/api/projects', { source: currentSource || null }); +``` + +- [ ] **Step 2: Modify `loadStats` API calls** + +Include `source: currentSource || null` in calls to: + +- `/api/stats` +- `/api/daily_stats` +- `/api/skills` + +- [ ] **Step 3: Simplify project select rendering** + +Because the backend now filters sources, remove duplicate client-side filtering. + +- [ ] **Step 4: Render project source tags** + +Use: + +```javascript +const tag = p.source === 'gemini' + ? ' [G]' + : p.source === 'codex' + ? ' [C]' + : p.source === 'claude' + ? ' [Claude]' + : ''; +``` + +- [ ] **Step 5: Reload projects when source changes** + +Make the source-select listener async: + +```javascript +document.getElementById('source-select').addEventListener('change', async e => { + currentSource = e.target.value; + currentProject = ''; + await loadProjects(); + loadStats(); +}); +``` + +- [ ] **Step 6: Manual Web smoke test** + +```bash +python -m cc_stats_web +``` + +Expected: + +- Selecting `Codex` shows Codex projects. +- `All Sources` includes Codex sessions in aggregate metrics. +- Selecting `Gemini CLI` excludes Codex sessions. + +--- + +### Task 6: Add Platform Path Documentation and Guardrails + +**Files:** +- Modify: `README_CN.md` +- Modify: `README.md` +- Test: `tests/test_sources.py` + +- [ ] **Step 1: Add env override test** + +```python +def test_env_overrides_do_not_require_real_home(tmp_path, monkeypatch): + monkeypatch.setenv("CC_STATS_CLAUDE_PROJECTS_DIR", str(tmp_path / "claude-projects")) + monkeypatch.setenv("CC_STATS_CODEX_HOME", str(tmp_path / "codex-home")) + monkeypatch.setenv("CC_STATS_GEMINI_HOME", str(tmp_path / "gemini-home")) + + assert collect_session_files() == [] + assert list_projects() == [] +``` + +- [ ] **Step 2: Update README path override sections** + +In `README_CN.md`, add: + +```markdown +### 路径覆盖(跨平台 / 测试) + +默认路径来自当前用户 home 目录。需要在 Windows、WSL、便携安装或测试环境中指定数据目录时,可以设置: + +| 环境变量 | 作用 | +|----------|------| +| `CC_STATS_CLAUDE_PROJECTS_DIR` | 覆盖 Claude Code 项目日志目录 | +| `CC_STATS_CODEX_HOME` | 覆盖 Codex home,工具会读取其中的 `sessions/` | +| `CC_STATS_GEMINI_HOME` | 覆盖 Gemini home,工具会读取其中的 `tmp/*/chats/` | +``` + +In `README.md`, add: + +```markdown +### Path Overrides (Cross-Platform / Testing) + +Default paths are resolved from the current user's home directory. For Windows, WSL, portable installs, or tests, set: + +| Environment Variable | Purpose | +|----------------------|---------| +| `CC_STATS_CLAUDE_PROJECTS_DIR` | Override the Claude Code project log directory | +| `CC_STATS_CODEX_HOME` | Override Codex home; `sessions/` is read below it | +| `CC_STATS_GEMINI_HOME` | Override Gemini home; `tmp/*/chats/` is read below it | +``` + +- [ ] **Step 3: Run source and web tests** + +```bash +python -m pytest tests/test_sources.py tests/test_web_server.py -q +``` + +Expected: PASS. + +--- + +### Task 7: Final Verification for Foundation + +**Files:** +- No source edits unless verification finds a failure. + +- [ ] **Step 1: Run focused Python tests** + +```bash +python -m pytest tests/test_sources.py tests/test_web_server.py tests/test_codex_parser.py tests/test_cli_version.py -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run CLI smoke tests** + +```bash +python -m cc_stats.cli --list +python -m cc_stats.cli --all --since 1d +``` + +Expected: + +- `--list` shows Claude/Codex/Gemini groups when data exists. +- `--all --since 1d` generates a report and includes current Codex sessions. + +- [ ] **Step 3: Run Web API smoke check** + +```bash +python -m cc_stats_web +``` + +Open the printed local URL and verify: + +- `All Sources` totals include Codex. +- `Codex` source filter shows Codex-only metrics. +- `Gemini CLI` source filter does not show Codex metrics. + +- [ ] **Step 4: Inspect for duplicate source discovery** + +```bash +rg -n "find_sessions\\(|find_codex_sessions\\(|find_gemini_sessions\\(" cc_stats cc_stats_web +``` + +Expected: + +- `cc_stats/parser.py` still defines discovery helpers. +- `cc_stats/sources.py` calls those helpers. +- CLI/Web should not manually combine all three sources outside `cc_stats/sources.py`. + +--- + +## Follow-Up Plan: Windows Tray Shell + +After this foundation passes, create a second plan for `cc-stats-desktop`: + +- Choose Tauri or Electron. +- Launch/monitor the Python local API. +- Implement Windows tray menu and icon states. +- Implement toast notifications through Windows APIs. +- Implement global hotkey. +- Implement launch-at-login. +- Consume `/api/projects`, `/api/stats`, `/api/daily_stats`, `/api/skills`, and bridge `/v1/*` endpoints. +- Package Windows artifacts in CI. + +The second plan should not duplicate parser/analyzer logic. It should treat Python core/API as the product kernel and the desktop shell as a thin platform adapter. + +--- + +## Self-Review + +**Spec coverage:** This plan covers the cross-platform abstraction foundation needed for Windows tray development: source discovery, platform path overrides, CLI consistency, Web API parity, and Codex support. The actual Windows tray shell is explicitly separated into a follow-up plan because it is an independent subsystem. + +**Placeholder scan:** No `TBD`, `TODO`, or vague implementation steps remain. Each code-changing step includes concrete behavior, exact files, or replacement snippets. + +**Type consistency:** The plan uses `SourceKind`, `SourceProject`, `collect_session_files`, `list_projects`, and `parse_file` consistently across CLI and Web tasks. 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 new file mode 100644 index 0000000..d940137 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-windows-tray-mvp-implementation.md @@ -0,0 +1,70 @@ +# Windows Tray MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Windows Tauri tray MVP that launches the Python `cc_stats_web` API, opens the dashboard, and keeps all statistics logic in Python. + +**Architecture:** Add a Tauri app under `desktop/cc-stats-tauri/` as a platform shell. Add a structured Python web startup mode so the Tauri process manager can start the API without scraping localized human output. Keep macOS Swift code unchanged. + +**Tech Stack:** Python 3.10+, pytest, Tauri 2.11, Rust, Vite, TypeScript, Node test runner. + +--- + +## File Structure + +- Modify `cc_stats_web/__main__.py` + - Add `--no-browser` and `--json` flags. + - Print one structured startup JSON line for desktop shells. + +- Create `tests/test_web_entrypoint.py` + - Covers startup payload formatting and CLI argument parsing without starting a blocking server. + +- Create `desktop/cc-stats-tauri/` + - Tauri app shell with tray/window/process modules. + - Frontend dashboard wrapper that embeds the Python dashboard URL. + - Node tests for frontend URL/status helpers. + - Rust unit tests for command construction and startup URL parsing. + +- Modify `README.md` and `README_CN.md` + - Add a short Windows tray development note. + +## Task 1: Python Structured Web Startup + +- [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 + +- [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 + +- [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`. +- [x] Run `cargo test` if Rust is available. +- [x] Run `cargo check` if Rust is available. + +## Task 4: Docs and Verification + +- [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 + +**Spec coverage:** This plan covers the MVP design: Tauri shell, Python API process contract, tray/window basics, no macOS Swift edits, and no duplicated statistics logic. + +**Placeholder scan:** No implementation placeholders remain; each task names concrete files and commands. + +**Type consistency:** Python startup payload, frontend helpers, and Rust process parser all use the same `url`, `host`, and `port` fields. diff --git a/docs/superpowers/specs/2026-06-09-windows-tray-mvp-design.md b/docs/superpowers/specs/2026-06-09-windows-tray-mvp-design.md new file mode 100644 index 0000000..86c62ce --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-windows-tray-mvp-design.md @@ -0,0 +1,199 @@ +# Windows Tray MVP Design + +## Summary + +Build a Windows desktop tray MVP that provides the same class of statistics and tray experience as the existing macOS app, without rewriting the macOS Swift app and without duplicating parser or analyzer logic. + +The Windows app will be a Tauri shell that launches and monitors the Python local API, exposes a system tray entry point, opens the existing dashboard experience, and reserves clear extension points for Windows notifications, launch-at-login, and hotkeys. + +## Goals + +- Provide a Windows system tray app for `cc-statistics`. +- Reuse the Python statistics core and local API as the product kernel. +- Preserve the existing macOS Swift app unless a small shared abstraction change is required. +- Avoid duplicating parser, analyzer, pricing, source discovery, or source filtering logic in the Windows shell. +- Keep the first release small enough to test and ship incrementally. + +## Non-Goals + +- Do not rewrite the existing macOS Swift UI. +- Do not modify macOS Swift code except for narrowly required shared abstraction changes. +- Do not implement a complete Windows-native statistics panel in the first version. +- Do not duplicate the Swift parser/analyzer implementation in Rust, TypeScript, or JavaScript. +- Do not build a Windows version of the iOS bridge or approval flow in this phase. +- Do not implement signing, automatic updates, or installer polish in the MVP unless the packaging path requires minimal package metadata. + +## Recommended Approach + +Use Tauri for the Windows desktop shell. + +Tauri is the best fit because it gives the Windows side native tray/window/process integration with a smaller footprint than Electron, while still allowing the app to reuse the existing Web dashboard and Python local API. The desktop shell should be treated as a platform adapter, not as a statistics engine. + +## Architecture + +```mermaid +flowchart LR + Tray["Windows Tray (Tauri/Rust)"] --> Window["Tauri Dashboard Window"] + Tray --> Proc["Python API Process Manager"] + Window --> API["127.0.0.1 Local API"] + Proc --> API + API --> Core["Python Core: sources/parser/analyzer/pricing"] + Core --> Logs["Claude / Codex / Gemini Session Data"] +``` + +The Python core remains responsible for: + +- Source discovery and filtering. +- Session parsing. +- Token and cost analysis. +- Web/API response shapes. + +The Tauri shell is responsible for: + +- Tray icon and menu. +- Dashboard window lifecycle. +- Starting, monitoring, and stopping the Python local API. +- Displaying Windows toast notifications for shell-level status. +- Providing platform hooks for launch-at-login and hotkeys. + +## Directory Layout + +Create a new desktop app directory: + +```text +desktop/cc-stats-tauri/ + package.json + src/ + main.ts + apiClient.ts + dashboard.ts + src-tauri/ + Cargo.toml + tauri.conf.json + src/ + main.rs + api_process.rs + tray.rs + window.rs + health.rs +``` + +The exact Tauri scaffold may vary slightly depending on the Tauri version, but the responsibilities should remain stable: + +- `api_process.rs`: build and supervise the Python API process. +- `tray.rs`: tray menu and tray events. +- `window.rs`: dashboard window creation and focus. +- `health.rs`: local API health checks and retry state. +- `apiClient.ts`: small frontend helper for calling the local API. +- `dashboard.ts`: dashboard URL construction and UI glue. + +## Process Model + +On startup, the Tauri app should: + +1. Find a Python executable. +2. Start the local API using `python -m cc_stats_web` or a dedicated future API command if one is added. +3. Parse the printed local URL or use a structured startup mode introduced in Python. +4. Poll a health endpoint or a known API endpoint until the server is available. +5. Enable tray actions once the API is healthy. + +On shutdown, the Tauri app should: + +1. Stop the Python child process it started. +2. Close the dashboard window. +3. Leave no background API process behind. + +If the API fails: + +- The tray menu should expose `Restart API`. +- The dashboard window should show an error state or open only after restart. +- A Windows toast can report startup failure or recovery. + +## API Contract + +The Windows shell should consume the same API surface the Web dashboard already uses: + +- `GET /api/projects?source=...` +- `GET /api/stats?project=...&days=...&source=...` +- `GET /api/daily_stats?project=...&days=...&source=...` +- `GET /api/skills?project=...&days=...&source=...` +- `GET /api/version_check` + +The shell should not parse session files directly. + +For process startup, the MVP can start `python -m cc_stats_web` and parse the printed URL. If this becomes brittle during implementation, add a small Python helper that starts the server and prints one JSON startup line with the selected port. That helper belongs in Python, not in the Tauri app. + +## User Experience + +Tray menu MVP: + +- `Open Dashboard` +- `Restart API` +- `Quit` + +Dashboard MVP: + +- Opens the existing dashboard experience. +- Preserves source filtering for Claude, Codex, and Gemini. +- Supports Codex statistics on Windows through the Python source registry. + +Notification MVP: + +- Show a Windows toast when API startup fails. +- Show a Windows toast when an API restart succeeds after failure. + +Hotkeys and launch-at-login: + +- Keep implementation hooks in the design, but treat them as follow-up tasks unless Tauri support is straightforward and low-risk during MVP implementation. + +## macOS Constraint + +The existing macOS Swift app remains the macOS implementation. + +The Windows Tauri shell should not modify Swift UI, Swift parsers, or macOS-specific behavior. If a shared Python abstraction must change for Windows, make the change in Python and keep it compatible with the macOS app. macOS Swift code should be changed only when a narrowly scoped shared contract requires it. + +## Testing Strategy + +Python: + +- Continue running the full Python test suite. +- Add tests only when new Python startup helpers or API contracts are introduced. + +Rust/Tauri: + +- Unit test command construction, URL parsing, health state transitions, and process state where possible. +- Keep process-manager code small and testable without launching the real dashboard for every test. + +End-to-end smoke: + +- Start the Windows shell or its API process manager. +- Confirm the Python API starts on `127.0.0.1`. +- Request `/api/projects?source=codex`. +- Open the dashboard window URL. +- Quit the app and confirm the API child process stops. + +## Risks + +- Python executable discovery can vary across Windows environments. +- Packaging Python with Tauri may be more involved than launching an installed Python environment. +- Tauri tray, notifications, and launch-at-login behavior can differ across Windows versions. +- Reusing the static Web dashboard may need small URL/base-path adjustments. + +## Success Criteria + +- A Windows user can launch the desktop app and see a tray icon. +- `Open Dashboard` shows the statistics dashboard. +- The dashboard can show Codex projects and stats through the Python API. +- `Restart API` recovers from a stopped or failed API process. +- `Quit` shuts down the app and any API process it started. +- The full Python test suite still passes. +- No macOS Swift UI rewrite occurs. + +## Follow-Up Candidates + +- Launch-at-login. +- Global hotkey. +- Richer Windows notifications. +- Installer and code signing. +- Native Windows settings panel. +- Python bundling strategy for users without Python installed. diff --git a/tests/test_cache_stats.py b/tests/test_cache_stats.py index 07e3360..9d95e09 100644 --- a/tests/test_cache_stats.py +++ b/tests/test_cache_stats.py @@ -73,17 +73,24 @@ def test_savings_usd_calculation(self): # savings = 1_000_000 * (3.0 - 0.3) / 1_000_000 = $2.70 assert result.savings_usd == pytest.approx(2.70) - def test_gemini_model_no_savings(self): - """Gemini 模型不计算节省费用""" + def test_gemini_model_savings(self): + """Gemini savings use the configured cache-read discount.""" tu = TokenUsage(input_tokens=200, cache_read_input_tokens=800) result = compute_cache_stats(tu, {"gemini-2.5-pro": tu}) - assert result.savings_usd == 0.0 + assert result.savings_usd == pytest.approx(0.0009) # 但命中率仍然计算 assert result.hit_rate == 0.8 assert result.grade == "excellent" + def test_openai_model_savings(self): + """OpenAI cache-read tokens count toward estimated savings.""" + tu = TokenUsage(input_tokens=200_000, cache_read_input_tokens=1_000_000) + result = compute_cache_stats(tu, {"gpt-5.4": tu}) + + assert result.savings_usd == pytest.approx(2.25) + def test_mixed_models_savings(self): - """混合模型:只对 Claude 模型计算节省费用""" + """Mixed-model savings add each model's configured cache discount.""" claude_tu = TokenUsage(input_tokens=100, cache_read_input_tokens=500) gemini_tu = TokenUsage(input_tokens=100, cache_read_input_tokens=500) total_tu = TokenUsage(input_tokens=200, cache_read_input_tokens=1000) @@ -93,8 +100,10 @@ def test_mixed_models_savings(self): "gemini-2.5-pro": gemini_tu, } result = compute_cache_stats(total_tu, by_model) - # 只算 claude 的 500 tokens - expected = 500 * (3.0 - 0.3) / 1_000_000 + expected = ( + 500 * (3.0 - 0.3) / 1_000_000 + + 500 * (1.25 - 0.125) / 1_000_000 + ) assert result.savings_usd == pytest.approx(expected) def test_by_model_hit_rates(self): diff --git a/tests/test_git_integration.py b/tests/test_git_integration.py index 037329b..099fa75 100644 --- a/tests/test_git_integration.py +++ b/tests/test_git_integration.py @@ -18,6 +18,7 @@ analyze_git_integration, parse_git_log, ) +from cc_stats.analyzer import _collect_git_stats def _dt(offset_hours: int = 0) -> datetime: @@ -210,6 +211,20 @@ def test_binary_files_skipped(self, tmp_path): # ── analyze_git_integration ─────────────────────────────────── +class TestAnalyzerGitStats: + def test_collect_git_stats_decodes_git_output_as_utf8_with_replacement(self, tmp_path): + (tmp_path / ".git").mkdir() + + with patch("cc_stats.analyzer.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="") + + _collect_git_stats(str(tmp_path), _dt(0), _dt(1)) + + kwargs = mock_run.call_args.kwargs + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + + class TestAnalyzeGitIntegration: def _make_stats(self, start_h: int, end_h: int, inp=1000, out=500, cache=100): """Create a mock SessionStats-like object""" diff --git a/tests/test_pricing.py b/tests/test_pricing.py index c827702..d2b948e 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -1,6 +1,40 @@ from cc_stats.pricing import is_claude_model, match_model_pricing +def test_match_gpt_55(): + p = match_model_pricing("gpt-5.5") + assert p["input"] == 5.0 + assert p["output"] == 30.0 + assert p["cache_read"] == 0.5 + + +def test_match_gpt_55_pro(): + p = match_model_pricing("gpt-5.5-pro") + assert p["input"] == 30.0 + assert p["output"] == 180.0 + + +def test_match_chat_latest(): + p = match_model_pricing("gpt-5.3-chat-latest") + assert p["input"] == 5.0 + assert p["output"] == 30.0 + assert p["cache_read"] == 0.5 + + +def test_match_claude_opus_48(): + p = match_model_pricing("claude-opus-4-8-20260528") + assert p["input"] == 5.0 + assert p["output"] == 25.0 + assert p["cache_read"] == 0.5 + + +def test_match_claude_fable_5(): + p = match_model_pricing("claude-fable-5-20260601") + assert p["input"] == 10.0 + assert p["output"] == 50.0 + assert p["cache_read"] == 1.0 + + def test_match_gpt_53_codex_exact(): p = match_model_pricing("gpt-5.3-codex") assert p["input"] == 1.75 @@ -33,7 +67,22 @@ def test_match_gemini_25_flash(): assert p["cache_read"] == 0.03 +def test_match_gemini_35_flash(): + p = match_model_pricing("gemini-3.5-flash") + assert p["input"] == 1.5 + assert p["output"] == 9.0 + assert p["cache_read"] == 0.15 + + +def test_match_gemini_31_pro_preview(): + p = match_model_pricing("gemini-3.1-pro-preview") + assert p["input"] == 2.0 + assert p["output"] == 12.0 + assert p["cache_read"] == 0.2 + + def test_is_claude_model(): assert is_claude_model("claude-sonnet-4-6") assert is_claude_model("sonnet") + assert is_claude_model("fable") assert not is_claude_model("gpt-5.3-codex") diff --git a/tests/test_sources.py b/tests/test_sources.py new file mode 100644 index 0000000..22f03ad --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +from cc_stats.sources import ( + SourceKind, + collect_session_files, + collect_session_files_by_keyword, + list_projects, + parse_file, + parse_sessions, +) + + +def _write_jsonl(path: Path, records: list[dict]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record) + "\n") + return path + + +def _write_codex_session(codex_home: Path, name: str, cwd: Path, message: str = "hi") -> Path: + path = ( + codex_home + / "sessions" + / "2026" + / "06" + / "09" + / f"rollout-2026-06-09T00-00-00-{name}.jsonl" + ) + return _write_jsonl(path, [ + { + "timestamp": "2026-06-09T00:00:00Z", + "type": "session_meta", + "payload": {"id": name, "cwd": str(cwd)}, + }, + { + "timestamp": "2026-06-09T00:00:01Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": message}, + }, + ]) + + +def _write_gemini_session(gemini_home: Path, name: str, cwd: Path) -> Path: + path = gemini_home / "tmp" / "session-a" / "chats" / f"{name}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({ + "sessionId": name, + "directories": [str(cwd)], + "messages": [ + { + "type": "user", + "timestamp": "2026-06-09T00:00:00Z", + "content": "hello", + } + ], + }), + encoding="utf-8", + ) + 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, [ + { + "type": "user", + "timestamp": "2026-06-09T00:00:00Z", + "cwd": str(cwd), + "sessionId": f"{project_name}-session", + "message": {"content": "hello"}, + } + ]) + + +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, +) -> None: + _, codex_home, gemini_home = _set_source_homes(monkeypatch, tmp_path) + codex_file = _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + gemini_file = _write_gemini_session(gemini_home, "gemini-a", tmp_path / "gemini-demo") + + files = collect_session_files(source=SourceKind.ALL) + + assert codex_file in files + 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_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, +) -> 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" + _write_codex_session(codex_home, "codex-a", project_dir) + _write_codex_session(codex_home, "codex-b", project_dir) + + projects = list_projects(source=SourceKind.CODEX) + + assert len(projects) == 1 + assert projects[0].source == SourceKind.CODEX + assert projects[0].key == str(project_dir) + assert projects[0].display_name == str(project_dir) + assert projects[0].session_count == 2 + + +def test_list_projects_source_claude_preserves_project_directory_key( + tmp_path: Path, + monkeypatch, +) -> None: + claude_projects, _, _ = _set_source_homes(monkeypatch, tmp_path) + shared_cwd = tmp_path / "shared-project" + _write_claude_session(claude_projects, "-tmp-project-a", shared_cwd) + _write_claude_session(claude_projects, "-tmp-project-b", shared_cwd) + + projects = list_projects(source=SourceKind.CLAUDE) + + assert [p.key for p in projects] == ["-tmp-project-a", "-tmp-project-b"] + assert [p.display_name for p in projects] == [str(shared_cwd), str(shared_cwd)] + assert [p.session_count for p in projects] == [1, 1] + + +def test_source_filter_excludes_other_sources(tmp_path: Path, monkeypatch) -> None: + _, codex_home, gemini_home = _set_source_homes(monkeypatch, tmp_path) + codex_file = _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + gemini_file = _write_gemini_session(gemini_home, "gemini-a", tmp_path / "demo") + + files = collect_session_files(source=SourceKind.CODEX) + + assert files == [codex_file] + assert gemini_file not in files + + +def test_keyword_search_uses_env_overrides_for_codex(tmp_path: Path, monkeypatch) -> None: + _, codex_home, gemini_home = _set_source_homes(monkeypatch, tmp_path) + codex_file = _write_codex_session( + codex_home, + "codex-a", + tmp_path / "demo", + message="needle from override", + ) + _write_gemini_session(gemini_home, "gemini-a", tmp_path / "demo") + + files = collect_session_files_by_keyword("needle", source=SourceKind.CODEX) + + assert files == [codex_file] + + +def test_list_projects_display_shape_for_cli(tmp_path: Path, monkeypatch) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + + projects = list_projects(source="codex") + + assert [(p.source.value, Path(p.display_name).name, p.session_count) for p in projects] == [ + ("codex", "demo", 1), + ] + + +def test_env_overrides_do_not_require_real_home_data(tmp_path: Path, monkeypatch) -> None: + claude_projects, codex_home, gemini_home = _set_source_homes(monkeypatch, tmp_path) + assert not claude_projects.exists() + assert not codex_home.exists() + assert not gemini_home.exists() + + assert collect_session_files() == [] + assert list_projects() == [] 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 diff --git a/tests/test_web_dashboard_markup.py b/tests/test_web_dashboard_markup.py new file mode 100644 index 0000000..d6aa55b --- /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-period="today">今天', + 'data-period="week">本周', + 'data-period="month">本月', + 'data-period="all">全部', + '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 diff --git a/tests/test_web_entrypoint.py b/tests/test_web_entrypoint.py new file mode 100644 index 0000000..b06ba58 --- /dev/null +++ b/tests/test_web_entrypoint.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json + +from cc_stats_web.__main__ import _build_startup_payload, _parse_args + + +def test_build_startup_payload_returns_desktop_contract() -> None: + payload = _build_startup_payload(host="127.0.0.1", port=61234) + + assert payload == { + "event": "cc_stats_web_started", + "host": "127.0.0.1", + "port": 61234, + "url": "http://127.0.0.1:61234/", + } + json.dumps(payload) + + +def test_parse_args_supports_desktop_shell_flags() -> None: + args = _parse_args(["--no-browser", "--json"]) + + assert args.no_browser is True + assert args.json is True diff --git a/tests/test_web_server.py b/tests/test_web_server.py index ed96426..bb40c12 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -1,8 +1,812 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import threading +import time +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + from cc_stats.analyzer import SessionStats, TokenUsage -from cc_stats_web.server import _stats_to_dict +from cc_stats_web import server as web_server +from cc_stats_web.server import ( + _collect_session_files, + _daily_date_keys, + _get_projects, + _get_stats, + start_server, + _stats_to_dict, +) + + +def _write_jsonl(path: Path, records: list[dict]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record) + "\n") + return path + + +def _write_codex_session(codex_home: Path, name: str, cwd: Path) -> Path: + path = ( + codex_home + / "sessions" + / "2026" + / "06" + / "09" + / f"rollout-2026-06-09T00-00-00-{name}.jsonl" + ) + return _write_jsonl(path, [ + { + "timestamp": "2026-06-09T00:00:00Z", + "type": "session_meta", + "payload": { + "id": name, + "cwd": str(cwd), + "model": "gpt-5.3-codex", + }, + }, + { + "timestamp": "2026-06-09T00:00:01Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "hi"}, + }, + { + "timestamp": "2026-06-09T00:00:02Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "hello"}, + }, + { + "timestamp": "2026-06-09T00:00:03Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 100, + "cached_input_tokens": 40, + "output_tokens": 10, + } + }, + }, + }, + ]) + + +def _write_codex_session_at( + codex_home: Path, + name: str, + cwd: Path, + timestamp: datetime, +) -> Path: + path = ( + codex_home + / "sessions" + / timestamp.strftime("%Y") + / timestamp.strftime("%m") + / timestamp.strftime("%d") + / f"rollout-{timestamp.strftime('%Y-%m-%dT%H-%M-%S')}-{name}.jsonl" + ) + ts = timestamp.isoformat().replace("+00:00", "Z") + return _write_jsonl(path, [ + { + "timestamp": ts, + "type": "session_meta", + "payload": { + "id": name, + "cwd": str(cwd), + "model": "gpt-5.3-codex", + }, + }, + { + "timestamp": ts, + "type": "event_msg", + "payload": {"type": "user_message", "message": "hi"}, + }, + ]) + + +def _write_gemini_session(gemini_home: Path, name: str, cwd: Path) -> Path: + path = gemini_home / "tmp" / "session-a" / "chats" / f"{name}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({ + "sessionId": name, + "directories": [str(cwd)], + "messages": [ + { + "type": "user", + "timestamp": "2026-06-09T00:00:00Z", + "content": "hello", + } + ], + }), + encoding="utf-8", + ) + 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, [ + { + "type": "user", + "timestamp": "2026-06-09T00:00:00Z", + "cwd": str(cwd), + "sessionId": f"{project_name}-session", + "message": {"content": "hello"}, + } + ]) + + +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, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + project_dir = tmp_path / "demo" + _write_codex_session(codex_home, "codex-a", project_dir) + + projects = _get_projects(source="codex") + + assert projects == [{ + "dir_name": str(project_dir), + "display_name": str(project_dir), + "session_count": 1, + "source": "codex", + }] + + +def test_health_endpoint_returns_ok() -> 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}/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_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) + return {"ok": True} + + monkeypatch.setattr("cc_stats_web.server._get_stats", slow_stats) + server, port = start_server(warm_cache=False) + 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, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + codex_file = _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + + files = _collect_session_files(source="codex") + + assert files == [codex_file] + + +def test_get_stats_source_codex_parses_user_message_and_token_usage( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + + stats = _get_stats(source="codex") + + assert stats["session_count"] == 1 + assert stats["user_message_count"] == 1 + assert stats["token_usage"]["input_tokens"] == 60 + assert stats["token_usage"]["cache_read"] == 40 + assert stats["token_usage"]["output_tokens"] == 10 + 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_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_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_today_period_uses_local_calendar_day( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session_at( + codex_home, + "yesterday-local", + tmp_path / "demo", + datetime(2026, 6, 11, 15, 0, tzinfo=timezone.utc), + ) + _write_codex_session_at( + codex_home, + "today-local", + tmp_path / "demo", + datetime(2026, 6, 12, 1, 0, tzinfo=timezone.utc), + ) + fixed_now = datetime(2026, 6, 12, 10, 0, tzinfo=timezone(timedelta(hours=8))) + monkeypatch.setattr(web_server, "_now_local", lambda: fixed_now) + + def fake_analyze_session(session, *, include_git=True): + stats = SessionStats(session_id=session.session_id, project_path=session.project_path) + stats.user_message_count = 1 + stats.token_usage = TokenUsage(input_tokens=100) + stats.token_by_model = {"gpt-5.5": TokenUsage(input_tokens=100)} + if session.session_id == "today-local": + stats.start_time = datetime(2026, 6, 12, 1, 0, tzinfo=timezone.utc) + stats.end_time = datetime(2026, 6, 12, 1, 5, tzinfo=timezone.utc) + stats.active_duration = timedelta(minutes=5) + stats.token_by_date = {"2026-06-12": TokenUsage(input_tokens=100)} + stats.token_by_model_by_date = { + "2026-06-12": {"gpt-5.5": TokenUsage(input_tokens=100)} + } + else: + stats.start_time = datetime(2026, 6, 11, 15, 0, tzinfo=timezone.utc) + stats.end_time = datetime(2026, 6, 11, 15, 5, tzinfo=timezone.utc) + stats.active_duration = timedelta(hours=4) + stats.token_by_date = {"2026-06-11": TokenUsage(input_tokens=100)} + stats.token_by_model_by_date = { + "2026-06-11": {"gpt-5.5": TokenUsage(input_tokens=100)} + } + return stats + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + payload = web_server._get_dashboard_payload(source="codex", period="today") + + assert payload["stats"]["session_count"] == 1 + assert payload["stats"]["user_message_count"] == 1 + assert payload["stats"]["active_duration_fmt"] == "5m" + assert payload["stats"]["token_usage"]["input_tokens"] == 100 + + +def test_get_dashboard_payload_today_period_returns_zero_when_range_is_empty( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session_at( + codex_home, + "yesterday-local", + tmp_path / "demo", + datetime(2026, 6, 11, 15, 0, tzinfo=timezone.utc), + ) + fixed_now = datetime(2026, 6, 12, 10, 0, tzinfo=timezone(timedelta(hours=8))) + monkeypatch.setattr(web_server, "_now_local", lambda: fixed_now) + + def fake_analyze_session(session, *, include_git=True): + stats = SessionStats(session_id=session.session_id, project_path=session.project_path) + stats.start_time = datetime(2026, 6, 11, 15, 0, tzinfo=timezone.utc) + stats.end_time = datetime(2026, 6, 11, 15, 5, tzinfo=timezone.utc) + stats.active_duration = timedelta(hours=4) + stats.user_message_count = 1 + stats.token_usage = TokenUsage(input_tokens=100) + stats.token_by_model = {"gpt-5.5": TokenUsage(input_tokens=100)} + stats.token_by_date = {"2026-06-11": TokenUsage(input_tokens=100)} + stats.token_by_model_by_date = { + "2026-06-11": {"gpt-5.5": TokenUsage(input_tokens=100)} + } + return stats + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + payload = web_server._get_dashboard_payload(source="codex", period="today") + assert payload["stats"]["session_count"] == 0 + assert payload["stats"]["user_message_count"] == 0 + assert payload["stats"]["active_duration_fmt"] == "0s" + assert payload["stats"]["token_usage"]["total"] == 0 -def test_stats_to_dict_includes_cache_grade_and_claude_only_savings(): + +def test_get_dashboard_payload_today_period_scales_cross_day_duration_by_tokens( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session_at( + codex_home, + "cross-day", + tmp_path / "demo", + datetime(2026, 6, 11, 15, 0, tzinfo=timezone.utc), + ) + fixed_now = datetime(2026, 6, 12, 10, 0, tzinfo=timezone(timedelta(hours=8))) + monkeypatch.setattr(web_server, "_now_local", lambda: fixed_now) + + def fake_analyze_session(session, *, include_git=True): + stats = SessionStats(session_id=session.session_id, project_path=session.project_path) + stats.start_time = datetime(2026, 6, 11, 15, 0, tzinfo=timezone.utc) + stats.end_time = datetime(2026, 6, 12, 1, 0, tzinfo=timezone.utc) + stats.ai_duration = timedelta(hours=8) + stats.user_duration = timedelta(hours=2) + stats.active_duration = timedelta(hours=10) + stats.total_duration = timedelta(hours=10) + stats.token_usage = TokenUsage(input_tokens=1_000) + stats.token_by_model = {"gpt-5.5": TokenUsage(input_tokens=1_000)} + stats.token_by_date = { + "2026-06-11": TokenUsage(input_tokens=900), + "2026-06-12": TokenUsage(input_tokens=100), + } + stats.token_by_model_by_date = { + "2026-06-11": {"gpt-5.5": TokenUsage(input_tokens=900)}, + "2026-06-12": {"gpt-5.5": TokenUsage(input_tokens=100)}, + } + return stats + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + payload = web_server._get_dashboard_payload(source="codex", period="today") + + assert payload["stats"]["active_duration"] == 3600 + assert payload["stats"]["active_duration_fmt"] == "1h" + assert payload["stats"]["ai_duration_fmt"] == "48m" + assert payload["stats"]["user_duration_fmt"] == "12m" + + +def test_daily_stats_from_analyzed_uses_token_dates_for_cross_day_sessions() -> None: + stats = SessionStats(session_id="cross-day", project_path="/tmp/demo") + stats.start_time = datetime(2026, 6, 11, 23, 0, tzinfo=timezone.utc) + stats.end_time = datetime(2026, 6, 12, 1, 0, tzinfo=timezone.utc) + stats.active_duration = timedelta(minutes=15) + stats.user_message_count = 2 + stats.tool_call_total = 3 + stats.token_usage = TokenUsage(input_tokens=1_000) + stats.token_by_model = {"gpt-5.5": TokenUsage(input_tokens=1_000)} + stats.token_by_date = { + "2026-06-11": TokenUsage(input_tokens=900), + "2026-06-12": TokenUsage(input_tokens=100), + } + stats.token_by_model_by_date = { + "2026-06-11": {"gpt-5.5": TokenUsage(input_tokens=900)}, + "2026-06-12": {"gpt-5.5": TokenUsage(input_tokens=100)}, + } + + result = web_server._daily_stats_from_analyzed( + [stats], + datetime(2026, 6, 12, tzinfo=timezone.utc), + 1, + now=datetime(2026, 6, 12, 12, tzinfo=timezone.utc), + ) + + assert result == [{ + "date": "2026-06-12", + "sessions": 1, + "messages": 2, + "tool_calls": 3, + "active_minutes": 1.5, + "lines_added": 0, + "lines_removed": 0, + "tokens": 100, + "cost": 0.0, + }] + + +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, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + now = datetime.now(timezone.utc) + old_ts = now - timedelta(days=7) + recent_ts = now - timedelta(hours=1) + old_file = _write_codex_session_at(codex_home, "old", tmp_path / "old", old_ts) + recent_file = _write_codex_session_at( + codex_home, + "recent", + tmp_path / "recent", + recent_ts, + ) + old_epoch = old_ts.timestamp() + recent_epoch = recent_ts.timestamp() + + os.utime(old_file, (old_epoch, old_epoch)) + os.utime(recent_file, (recent_epoch, recent_epoch)) + + parsed: list[Path] = [] + original_parse = web_server._parse_session_file + + def tracking_parse(path: Path): + parsed.append(path) + return original_parse(path) + + monkeypatch.setattr(web_server, "_parse_session_file", tracking_parse) + + stats = web_server._get_stats(source="codex", since_days=1) + + assert stats["session_count"] == 1 + assert recent_file in parsed + assert old_file not in parsed + + +def test_get_stats_disables_git_collection_for_web_requests( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, _ = _set_source_homes(monkeypatch, tmp_path) + _write_codex_session(codex_home, "codex-a", tmp_path / "demo") + include_git_values: list[bool] = [] + + def fake_analyze_session(session, *, include_git=True): + include_git_values.append(include_git) + return SessionStats(session_id=session.session_id, project_path=session.project_path) + + monkeypatch.setattr(web_server, "analyze_session", fake_analyze_session) + + stats = web_server._get_stats(source="codex") + + assert stats["session_count"] == 1 + assert include_git_values == [False] + + +def test_daily_date_keys_for_today_cover_rolling_window_across_midnight() -> None: + since_dt = datetime(2026, 6, 9, 15, 30, tzinfo=timezone.utc) + now_dt = datetime(2026, 6, 9, 17, 0, tzinfo=timezone.utc) + + assert _daily_date_keys(since_dt, days=1, now=now_dt) == [ + "2026-06-09", + "2026-06-10", + ] + + +def test_source_filter_excludes_other_sources_for_web_helpers( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, gemini_home = _set_source_homes(monkeypatch, tmp_path) + project_dir = tmp_path / "demo" + codex_file = _write_codex_session(codex_home, "codex-a", project_dir) + gemini_file = _write_gemini_session(gemini_home, "gemini-a", project_dir) + + files = _collect_session_files(source="codex") + projects = _get_projects(source="codex") + + assert files == [codex_file] + assert gemini_file not in files + assert {project["source"] for project in projects} == {"codex"} + + +def test_project_filter_with_source_disambiguates_shared_cwd( + tmp_path: Path, + monkeypatch, +) -> None: + _, codex_home, gemini_home = _set_source_homes(monkeypatch, tmp_path) + project_dir = tmp_path / "shared" + codex_file = _write_codex_session(codex_home, "codex-a", project_dir) + gemini_file = _write_gemini_session(gemini_home, "gemini-a", project_dir) + + files = _collect_session_files(project_dir_name=str(project_dir), source="codex") + stats = _get_stats(project_dir_name=str(project_dir), source="codex") + + assert files == [codex_file] + assert gemini_file not in files + assert stats["session_count"] == 1 + assert stats["user_message_count"] == 1 + + +def test_collect_session_files_filters_claude_by_project_directory_key( + tmp_path: Path, + monkeypatch, +) -> None: + claude_projects, _, _ = _set_source_homes(monkeypatch, tmp_path) + claude_file = _write_claude_session( + claude_projects, + "-tmp-project-a", + tmp_path / "demo", + ) + + files = _collect_session_files(project_dir_name="-tmp-project-a", source="claude") + stats = _get_stats(project_dir_name="-tmp-project-a", source="claude") + + assert files == [claude_file] + assert stats["session_count"] == 1 + assert stats["user_message_count"] == 1 + + +def test_stats_to_dict_includes_cache_grade_and_model_savings(): stats = SessionStats(session_id="s1", project_path="/tmp/demo") stats.token_usage = TokenUsage( input_tokens=300_000, @@ -29,11 +833,19 @@ def test_stats_to_dict_includes_cache_grade_and_claude_only_savings(): assert cache["cache_read_tokens"] == 700_000 assert cache["total_input_tokens"] == 1_000_000 assert cache["hit_rate"] == 0.7 - assert cache["savings_usd"] == 1.62 + assert cache["savings_usd"] == 1.845 assert cache["by_model"]["claude-sonnet-4.5"] == 0.8571428571428571 assert cache["by_model"]["gpt-5.4"] == 0.3333333333333333 +def test_stats_to_dict_marks_skipped_git_scan(): + stats = SessionStats(session_id="s3", project_path="/tmp/demo") + + result = _stats_to_dict(stats, git_scan_skipped=True) + + assert result["git_scan_skipped"] is True + + def test_stats_to_dict_returns_na_when_no_cache_reads(): stats = SessionStats(session_id="s2", project_path="/tmp/demo") stats.token_usage = TokenUsage(input_tokens=120_000, output_tokens=30_000) @@ -51,3 +863,47 @@ 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_version_update_hidden_for_desktop_shell(monkeypatch): + monkeypatch.setenv("CC_STATS_DESKTOP_SHELL", "1") + + result = web_server._get_version_update() + + assert result == {"has_update": False} + + +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"] diff --git a/tests/test_windows_tauri_shell.py b/tests/test_windows_tauri_shell.py new file mode 100644 index 0000000..ec8ad25 --- /dev/null +++ b/tests/test_windows_tauri_shell.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +TAURI_DIR = ROOT / "desktop" / "cc-stats-tauri" / "src-tauri" + + +def _png_unique_rgba_colors(path: Path) -> set[tuple[int, int, int, int]]: + data = path.read_bytes() + assert data.startswith(b"\x89PNG\r\n\x1a\n") + + offset = 8 + width = height = color_type = None + compressed = bytearray() + while offset < len(data): + length = int.from_bytes(data[offset : offset + 4], "big") + chunk_type = data[offset + 4 : offset + 8] + chunk_data = data[offset + 8 : offset + 8 + length] + offset += 12 + length + + if chunk_type == b"IHDR": + width, height, bit_depth, color_type = struct.unpack(">IIBB", chunk_data[:10]) + assert bit_depth == 8 + assert color_type == 6 + elif chunk_type == b"IDAT": + compressed.extend(chunk_data) + elif chunk_type == b"IEND": + break + + assert width is not None + assert height is not None + assert color_type == 6 + + raw = zlib.decompress(bytes(compressed)) + row_stride = width * 4 + colors: set[tuple[int, int, int, int]] = set() + pos = 0 + previous = bytearray(row_stride) + for _ in range(height): + filter_type = raw[pos] + pos += 1 + row = bytearray(raw[pos : pos + row_stride]) + pos += row_stride + + if filter_type == 1: + for i, value in enumerate(row): + row[i] = (value + (row[i - 4] if i >= 4 else 0)) & 0xFF + elif filter_type == 2: + for i, value in enumerate(row): + row[i] = (value + previous[i]) & 0xFF + elif filter_type == 3: + for i, value in enumerate(row): + left = row[i - 4] if i >= 4 else 0 + row[i] = (value + ((left + previous[i]) // 2)) & 0xFF + elif filter_type == 4: + for i, value in enumerate(row): + left = row[i - 4] if i >= 4 else 0 + up = previous[i] + up_left = previous[i - 4] if i >= 4 else 0 + p = left + up - up_left + pa = abs(p - left) + pb = abs(p - up) + pc = abs(p - up_left) + predictor = left if pa <= pb and pa <= pc else up if pb <= pc else up_left + row[i] = (value + predictor) & 0xFF + else: + assert filter_type == 0 + + for i in range(0, row_stride, 4): + colors.add(tuple(row[i : i + 4])) + if len(colors) > 8: + return colors + previous = row + return colors + + +def test_windows_release_uses_gui_subsystem() -> None: + main_rs = (TAURI_DIR / "src" / "main.rs").read_text(encoding="utf-8") + + assert 'windows_subsystem = "windows"' in main_rs + + +def test_python_api_child_process_uses_no_window_flag() -> None: + api_process_rs = (TAURI_DIR / "src" / "api_process.rs").read_text(encoding="utf-8") + + assert "CREATE_NO_WINDOW" in api_process_rs + assert "creation_flags" in api_process_rs + + +def test_tray_uses_explicit_default_window_icon() -> None: + tray_rs = (TAURI_DIR / "src" / "tray.rs").read_text(encoding="utf-8") + + assert ".icon(" in tray_rs + 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_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") + + 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" + + assert len(_png_unique_rgba_colors(icon_png)) > 1 + + data = icon_ico.read_bytes() + reserved, kind, count = struct.unpack("= 4