diff --git a/main.py b/main.py index 732d404..426d786 100644 --- a/main.py +++ b/main.py @@ -14,12 +14,16 @@ from pathlib import Path def main(): """启动微信解密工具API服务""" + host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") + port = int(os.environ.get("WECHAT_TOOL_PORT", "8000")) + access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host + print("=" * 60) print("微信解密工具 API 服务") print("=" * 60) print("正在启动服务...") - print("API文档: http://localhost:8000/docs") - print("健康检查: http://localhost:8000/api/health") + print(f"API文档: http://{access_host}:{port}/docs") + print(f"健康检查: http://{access_host}:{port}/api/health") print("按 Ctrl+C 停止服务") print("=" * 60) @@ -29,8 +33,8 @@ def main(): # 启动API服务 uvicorn.run( "wechat_decrypt_tool.api:app", - host="0.0.0.0", - port=8000, + host=host, + port=port, reload=enable_reload, reload_dirs=[str(repo_root / "src")] if enable_reload else None, reload_excludes=[ diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index 4054ff0..df3773f 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -1,7 +1,13 @@ """微信解密工具的FastAPI Web服务器""" +import os +from pathlib import Path + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import FileResponse +from starlette.staticfiles import StaticFiles from .logging_config import setup_logging, get_logger from .path_fix import PathFixRoute @@ -47,6 +53,70 @@ app.include_router(_chat_export_router) app.include_router(_chat_media_router) +class _SPAStaticFiles(StaticFiles): + """StaticFiles with a SPA fallback (Nuxt generate output).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._fallback_200 = Path(str(self.directory)) / "200.html" + self._fallback_index = Path(str(self.directory)) / "index.html" + + async def get_response(self, path: str, scope): # type: ignore[override] + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code != 404: + raise + + # For client-side routes (no file extension), return Nuxt's SPA fallback. + name = Path(path).name + if "." in name: + raise + + if self._fallback_200.exists(): + return FileResponse(str(self._fallback_200)) + return FileResponse(str(self._fallback_index)) + + +def _maybe_mount_frontend() -> None: + """Serve the generated Nuxt static site at `/` if present. + + This keeps web + desktop UI identical when the desktop shell (Electron) loads + http://127.0.0.1:/ from the same backend that serves `/api/*`. + """ + + ui_dir_env = os.environ.get("WECHAT_TOOL_UI_DIR", "").strip() + + candidates: list[Path] = [] + if ui_dir_env: + candidates.append(Path(ui_dir_env)) + + # Repo default: `frontend/.output/public` after `npm --prefix frontend run generate`. + repo_root = Path(__file__).resolve().parents[2] + candidates.append(repo_root / "frontend" / ".output" / "public") + + ui_dir: Path | None = None + for p in candidates: + try: + if (p / "index.html").is_file(): + ui_dir = p + break + except Exception: + continue + + if not ui_dir: + return + + try: + app.mount("/", _SPAStaticFiles(directory=str(ui_dir), html=True), name="ui") + logger.info("Serving frontend UI from: %s", ui_dir) + except Exception: + logger.exception("Failed to mount frontend UI from: %s", ui_dir) + + +_maybe_mount_frontend() + + @app.on_event("shutdown") async def _shutdown_wcdb_realtime() -> None: try: @@ -62,4 +132,6 @@ async def _shutdown_wcdb_realtime() -> None: if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") + port = int(os.environ.get("WECHAT_TOOL_PORT", "8000")) + uvicorn.run(app, host=host, port=port) diff --git a/src/wechat_decrypt_tool/app_paths.py b/src/wechat_decrypt_tool/app_paths.py new file mode 100644 index 0000000..caab0a3 --- /dev/null +++ b/src/wechat_decrypt_tool/app_paths.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def get_data_dir() -> Path: + """Base writable directory for all runtime output (logs, databases, key store). + + - Desktop (Electron) should set `WECHAT_TOOL_DATA_DIR` to a per-user directory + (e.g. `%APPDATA%/WeChatDataAnalysis`). + - Dev defaults to the current working directory (repo root). + """ + + v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip() + if v: + return Path(v) + return Path.cwd() + + +def get_output_dir() -> Path: + return get_data_dir() / "output" + + +def get_output_databases_dir() -> Path: + return get_output_dir() / "databases" + + +def get_account_keys_path() -> Path: + return get_output_dir() / "account_keys.json" + diff --git a/src/wechat_decrypt_tool/backend_entry.py b/src/wechat_decrypt_tool/backend_entry.py new file mode 100644 index 0000000..bbfbf3b --- /dev/null +++ b/src/wechat_decrypt_tool/backend_entry.py @@ -0,0 +1,21 @@ +"""Entry point for bundling the FastAPI backend into a standalone executable. + +This avoids dynamic import strings like "pkg.module:app" which some bundlers +cannot detect reliably. +""" + +import os + +import uvicorn + +from wechat_decrypt_tool.api import app + + +def main() -> None: + host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") + port = int(os.environ.get("WECHAT_TOOL_PORT", "8000")) + uvicorn.run(app, host=host, port=port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/src/wechat_decrypt_tool/chat_helpers.py b/src/wechat_decrypt_tool/chat_helpers.py index 2322d3b..fa64347 100644 --- a/src/wechat_decrypt_tool/chat_helpers.py +++ b/src/wechat_decrypt_tool/chat_helpers.py @@ -12,6 +12,7 @@ from urllib.parse import quote from fastapi import HTTPException +from .app_paths import get_output_databases_dir from .logging_config import get_logger try: @@ -21,8 +22,7 @@ except Exception: logger = get_logger(__name__) -_REPO_ROOT = Path(__file__).resolve().parents[2] -_OUTPUT_DATABASES_DIR = _REPO_ROOT / "output" / "databases" +_OUTPUT_DATABASES_DIR = get_output_databases_dir() _DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1" diff --git a/src/wechat_decrypt_tool/key_store.py b/src/wechat_decrypt_tool/key_store.py index f06addd..570b080 100644 --- a/src/wechat_decrypt_tool/key_store.py +++ b/src/wechat_decrypt_tool/key_store.py @@ -3,8 +3,9 @@ import json from pathlib import Path from typing import Any, Optional -_REPO_ROOT = Path(__file__).resolve().parents[2] -_KEY_STORE_PATH = _REPO_ROOT / "output" / "account_keys.json" +from .app_paths import get_account_keys_path + +_KEY_STORE_PATH = get_account_keys_path() def _atomic_write_json(path: Path, payload: Any) -> None: @@ -66,4 +67,3 @@ def upsert_account_keys_in_store( pass return item - diff --git a/src/wechat_decrypt_tool/media_helpers.py b/src/wechat_decrypt_tool/media_helpers.py index 47a9e3c..59ed9c8 100644 --- a/src/wechat_decrypt_tool/media_helpers.py +++ b/src/wechat_decrypt_tool/media_helpers.py @@ -16,14 +16,14 @@ from urllib.parse import urlparse from fastapi import HTTPException +from .app_paths import get_output_databases_dir from .logging_config import get_logger logger = get_logger(__name__) -# 仓库根目录(用于定位 output/databases) -_REPO_ROOT = Path(__file__).resolve().parents[2] -_OUTPUT_DATABASES_DIR = _REPO_ROOT / "output" / "databases" +# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录) +_OUTPUT_DATABASES_DIR = get_output_databases_dir() _PACKAGE_ROOT = Path(__file__).resolve().parent diff --git a/src/wechat_decrypt_tool/routers/health.py b/src/wechat_decrypt_tool/routers/health.py index 5e8caf9..1a91ce2 100644 --- a/src/wechat_decrypt_tool/routers/health.py +++ b/src/wechat_decrypt_tool/routers/health.py @@ -8,10 +8,10 @@ logger = get_logger(__name__) router = APIRouter(route_class=PathFixRoute) -@router.get("/", summary="根端点") -async def root(): - """根端点""" - logger.info("访问根端点") +@router.get("/api", summary="API 根端点") +async def api_root(): + """API 根端点""" + logger.info("访问 API 根端点") return {"message": "微信数据库解密工具 API"} diff --git a/src/wechat_decrypt_tool/wechat_decrypt.py b/src/wechat_decrypt_tool/wechat_decrypt.py index a0d5b5c..b6aa38a 100644 --- a/src/wechat_decrypt_tool/wechat_decrypt.py +++ b/src/wechat_decrypt_tool/wechat_decrypt.py @@ -20,6 +20,8 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from .app_paths import get_output_databases_dir + # 注意:不再支持默认密钥,所有密钥必须通过参数传入 # SQLite文件头 @@ -251,7 +253,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di logger.info("=" * 60) # 创建基础输出目录 - base_output_dir = Path("output/databases") + base_output_dir = get_output_databases_dir() base_output_dir.mkdir(parents=True, exist_ok=True) logger.info(f"基础输出目录: {base_output_dir.absolute()}")