From 8a101b4c5e18646323fbde6b48aab3a367aebb47 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sat, 28 Feb 2026 18:36:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E8=AE=BE=E7=BD=AE=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=94=AF=E6=8C=81=E5=90=8E=E7=AB=AF=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E5=88=87=E6=8D=A2=EF=BC=88=E6=A1=8C=E9=9D=A2/=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/package.json | 2 +- desktop/src/main.cjs | 209 ++++++- desktop/src/preload.cjs | 3 + frontend/app.vue | 7 +- frontend/components/SettingsDialog.vue | 616 ++++++++++++++++++++ frontend/components/SidebarRail.vue | 13 +- frontend/composables/useApiBase.js | 14 + frontend/composables/useSettingsDialog.js | 17 + frontend/nuxt.config.ts | 7 +- frontend/pages/settings.vue | 282 --------- frontend/utils/api-settings.js | 35 ++ main.py | 11 +- src/wechat_decrypt_tool/api.py | 6 +- src/wechat_decrypt_tool/backend_entry.py | 3 +- src/wechat_decrypt_tool/routers/admin.py | 203 +++++++ src/wechat_decrypt_tool/runtime_settings.py | 175 ++++++ 16 files changed, 1286 insertions(+), 317 deletions(-) create mode 100644 frontend/components/SettingsDialog.vue create mode 100644 frontend/composables/useApiBase.js create mode 100644 frontend/composables/useSettingsDialog.js delete mode 100644 frontend/pages/settings.vue create mode 100644 frontend/utils/api-settings.js create mode 100644 src/wechat_decrypt_tool/routers/admin.py create mode 100644 src/wechat_decrypt_tool/runtime_settings.py diff --git a/desktop/package.json b/desktop/package.json index 118a7b0..7eb8288 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,7 +5,7 @@ "main": "src/main.cjs", "scripts": { "dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"", - "dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:8000 electron .", + "dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .", "build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs", "build:backend": "uv sync --extra build && node scripts/build-backend.cjs", "build:icon": "node scripts/build-icon.cjs", diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 66f283b..6a0ed2c 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -12,19 +12,19 @@ const { autoUpdater } = require("electron-updater"); const { spawn, spawnSync } = require("child_process"); const fs = require("fs"); const http = require("http"); +const net = require("net"); const path = require("path"); -const BACKEND_HOST = process.env.WECHAT_TOOL_HOST || "127.0.0.1"; -const BACKEND_PORT = Number(process.env.WECHAT_TOOL_PORT || "8000"); -const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`; +const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1"; +const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392; let backendProc = null; -let backendStdioStream = null; let resolvedDataDir = null; let mainWindow = null; let tray = null; let isQuitting = false; let desktopSettings = null; +let backendPortChangeInProgress = false; const gotSingleInstanceLock = app.requestSingleInstanceLock(); if (!gotSingleInstanceLock) { @@ -46,6 +46,77 @@ function nowIso() { return new Date().toISOString(); } +function parsePort(value) { + if (value == null) return null; + const raw = String(value).trim(); + if (!raw) return null; + const n = Number(raw); + if (!Number.isInteger(n)) return null; + if (n < 1 || n > 65535) return null; + return n; +} + +function formatHostForUrl(host) { + const h = String(host || "").trim(); + if (!h) return "127.0.0.1"; + // IPv6 literals must be wrapped in brackets in URLs. + if (h.includes(":") && !(h.startsWith("[") && h.endsWith("]"))) return `[${h}]`; + return h; +} + +function getBackendBindHost() { + return DEFAULT_BACKEND_HOST; +} + +function getBackendAccessHost() { + // 0.0.0.0 / :: are fine bind hosts, but not a reachable client destination. + const host = String(getBackendBindHost() || "").trim(); + if (host === "0.0.0.0" || host === "::") return "127.0.0.1"; + return host || "127.0.0.1"; +} + +function getBackendPort() { + const settingsPort = parsePort(loadDesktopSettings()?.backendPort); + return settingsPort ?? DEFAULT_BACKEND_PORT; +} + +function setBackendPortSetting(nextPort) { + const p = parsePort(nextPort); + if (p == null) throw new Error("端口无效,请输入 1-65535 的整数"); + loadDesktopSettings(); + desktopSettings.backendPort = p; + persistDesktopSettings(); + process.env.WECHAT_TOOL_PORT = String(p); + return p; +} + +function getBackendHealthUrl() { + const host = formatHostForUrl(getBackendAccessHost()); + const port = getBackendPort(); + return `http://${host}:${port}/api/health`; +} + +function getBackendUiUrl() { + const host = formatHostForUrl(getBackendAccessHost()); + const port = getBackendPort(); + return `http://${host}:${port}/`; +} + +function isPortAvailable(port, host) { + return new Promise((resolve) => { + try { + const srv = net.createServer(); + srv.unref(); + srv.once("error", () => resolve(false)); + srv.listen({ port, host }, () => { + srv.close(() => resolve(true)); + }); + } catch { + resolve(false); + } + }); +} + function resolveDataDir() { if (resolvedDataDir) return resolvedDataDir; @@ -146,6 +217,8 @@ function loadDesktopSettings() { closeBehavior: "tray", // When set, suppress the auto-update prompt for this exact version. ignoredUpdateVersion: "", + // Backend (FastAPI) listens on this port. Used in packaged builds. + backendPort: DEFAULT_BACKEND_PORT, }; const p = getDesktopSettingsPath(); @@ -162,6 +235,7 @@ function loadDesktopSettings() { const raw = fs.readFileSync(p, { encoding: "utf8" }); const parsed = JSON.parse(raw || "{}"); desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) }; + desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort; } catch (err) { desktopSettings = { ...defaults }; logMain(`[main] failed to load settings: ${err?.message || err}`); @@ -710,20 +784,20 @@ function attachBackendStdio(proc, logPath) { fs.mkdirSync(path.dirname(logPath), { recursive: true }); } catch {} + let stream = null; try { - backendStdioStream = fs.createWriteStream(logPath, { flags: "a" }); - backendStdioStream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`); + stream = fs.createWriteStream(logPath, { flags: "a" }); + stream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`); } catch { - backendStdioStream = null; return; } const write = (prefix, chunk) => { - if (!backendStdioStream) return; + if (!stream) return; try { const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - backendStdioStream.write(`[${nowIso()}] ${prefix} ${text}`); - if (!text.endsWith("\n")) backendStdioStream.write("\n"); + stream.write(`[${nowIso()}] ${prefix} ${text}`); + if (!text.endsWith("\n")) stream.write("\n"); } catch {} }; @@ -733,9 +807,9 @@ function attachBackendStdio(proc, logPath) { proc.on("close", (code, signal) => { write("[backend:close]", `code=${code} signal=${signal}`); try { - backendStdioStream?.end(); + stream?.end(); } catch {} - backendStdioStream = null; + stream = null; }); } @@ -754,8 +828,8 @@ function startBackend() { const env = { ...process.env, - WECHAT_TOOL_HOST: BACKEND_HOST, - WECHAT_TOOL_PORT: String(BACKEND_PORT), + WECHAT_TOOL_HOST: getBackendBindHost(), + WECHAT_TOOL_PORT: String(getBackendPort()), // Make sure Python prints UTF-8 to stdout/stderr. PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8", }; @@ -795,8 +869,9 @@ function startBackend() { }); } - backendProc.on("exit", (code, signal) => { - backendProc = null; + const proc = backendProc; + proc.on("exit", (code, signal) => { + if (backendProc === proc) backendProc = null; // eslint-disable-next-line no-console console.log(`[backend] exited code=${code} signal=${signal}`); logMain(`[backend] exited code=${code} signal=${signal}`); @@ -835,6 +910,42 @@ function stopBackend() { } catch {} } +async function stopBackendAndWait({ timeoutMs = 10_000 } = {}) { + if (!backendProc) return; + const proc = backendProc; + + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + + const timer = setTimeout(finish, timeoutMs); + + try { + proc.once("exit", () => { + clearTimeout(timer); + finish(); + }); + } catch {} + + try { + stopBackend(); + } catch { + clearTimeout(timer); + finish(); + } + }); +} + +async function restartBackend({ timeoutMs = 30_000 } = {}) { + await stopBackendAndWait({ timeoutMs: 10_000 }); + startBackend(); + await waitForBackend({ timeoutMs }); +} + function httpGet(url) { return new Promise((resolve, reject) => { const req = http.get(url, (res) => { @@ -849,17 +960,18 @@ function httpGet(url) { }); } -async function waitForBackend({ timeoutMs }) { +async function waitForBackend({ timeoutMs, healthUrl } = {}) { + const url = String(healthUrl || getBackendHealthUrl()).trim(); const startedAt = Date.now(); // eslint-disable-next-line no-constant-condition while (true) { try { - const code = await httpGet(BACKEND_HEALTH_URL); + const code = await httpGet(url); if (code >= 200 && code < 500) return; } catch {} if (Date.now() - startedAt > timeoutMs) { - throw new Error(`Backend did not become ready in ${timeoutMs}ms: ${BACKEND_HEALTH_URL}`); + throw new Error(`Backend did not become ready in ${timeoutMs}ms: ${url}`); } await new Promise((r) => setTimeout(r, 300)); @@ -1051,6 +1163,63 @@ function registerWindowIpc() { } }); + ipcMain.handle("backend:getPort", () => { + try { + return getBackendPort(); + } catch (err) { + logMain(`[main] backend:getPort failed: ${err?.message || err}`); + return DEFAULT_BACKEND_PORT; + } + }); + + ipcMain.handle("backend:setPort", async (_event, port) => { + if (backendPortChangeInProgress) throw new Error("端口切换中,请稍后重试"); + if (!app.isPackaged) { + throw new Error("开发模式不支持界面修改端口;请设置 WECHAT_TOOL_PORT 环境变量后重启"); + } + + const nextPort = parsePort(port); + if (nextPort == null) throw new Error("端口无效,请输入 1-65535 的整数"); + + const prevPort = getBackendPort(); + if (nextPort === prevPort) { + return { success: true, changed: false, port: prevPort, uiUrl: getBackendUiUrl() }; + } + + const bindHost = getBackendBindHost(); + const ok = await isPortAvailable(nextPort, bindHost); + if (!ok) throw new Error(`端口 ${nextPort} 已被占用,请换一个端口`); + + backendPortChangeInProgress = true; + try { + setBackendPortSetting(nextPort); + try { + await restartBackend({ timeoutMs: 30_000 }); + } catch (err) { + // Roll back to the previous port so the UI can keep working. + setBackendPortSetting(prevPort); + try { + await restartBackend({ timeoutMs: 30_000 }); + } catch {} + throw err; + } + + const uiUrl = getBackendUiUrl(); + setTimeout(() => { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + void loadWithRetry(mainWindow, uiUrl); + } catch (err) { + logMain(`[main] failed to reload UI after backend port change: ${err?.message || err}`); + } + }, 50); + + return { success: true, changed: true, port: nextPort, uiUrl }; + } finally { + backendPortChangeInProgress = false; + } + }); + ipcMain.handle("app:getVersion", () => { try { return app.getVersion(); @@ -1134,7 +1303,7 @@ async function main() { const startUrl = process.env.ELECTRON_START_URL || - (app.isPackaged ? `http://${BACKEND_HOST}:${BACKEND_PORT}/` : "http://localhost:3000"); + (app.isPackaged ? getBackendUiUrl() : "http://localhost:3000"); await loadWithRetry(win, startUrl); diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 77bf4d0..e83f477 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -14,6 +14,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", { getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"), setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")), + getBackendPort: () => ipcRenderer.invoke("backend:getPort"), + setBackendPort: (port) => ipcRenderer.invoke("backend:setPort", Number(port)), + chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options), // Auto update diff --git a/frontend/app.vue b/frontend/app.vue index f49ca81..ea71c78 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -3,12 +3,14 @@
- +
+ + : 'flex-1 overflow-auto min-h-0' ) +const showDesktopTitleBar = computed(() => isDesktop.value) + const showSidebar = computed(() => { const path = String(route.path || '') if (path === '/') return false diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue new file mode 100644 index 0000000..6bc7560 --- /dev/null +++ b/frontend/components/SettingsDialog.vue @@ -0,0 +1,616 @@ + + + + + diff --git a/frontend/components/SidebarRail.vue b/frontend/components/SidebarRail.vue index e7273b0..7612e7b 100644 --- a/frontend/components/SidebarRail.vue +++ b/frontend/components/SidebarRail.vue @@ -171,7 +171,7 @@ title="设置" >
- + { await chatAccounts.ensureLoaded() }) -const sidebarMediaBase = process.client ? 'http://localhost:8000' : '' +const apiBase = useApiBase() const selfAvatarUrl = computed(() => { const acc = String(selectedAccount.value || '').trim() if (!acc) return '' - return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}` + return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}` }) const isChatRoute = computed(() => route.path?.startsWith('/chat')) @@ -219,8 +220,6 @@ const isEditsRoute = computed(() => route.path?.startsWith('/edits')) const isSnsRoute = computed(() => route.path?.startsWith('/sns')) const isContactsRoute = computed(() => route.path?.startsWith('/contacts')) const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped')) -const isSettingsRoute = computed(() => route.path?.startsWith('/settings')) - const goChat = async () => { await navigateTo('/chat') } @@ -241,8 +240,8 @@ const goWrapped = async () => { await navigateTo('/wrapped') } -const goSettings = async () => { - await navigateTo('/settings') +const goSettings = () => { + openSettingsDialog() } const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value) diff --git a/frontend/composables/useApiBase.js b/frontend/composables/useApiBase.js new file mode 100644 index 0000000..91c2b21 --- /dev/null +++ b/frontend/composables/useApiBase.js @@ -0,0 +1,14 @@ +import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings' + +export const useApiBase = () => { + const config = useRuntimeConfig() + + // Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work. + // Override priority: + // 1) Local UI setting (web + desktop) + // 2) NUXT_PUBLIC_API_BASE env/runtime config + // 3) `/api` + const override = process.client ? readApiBaseOverride() : '' + const runtime = String(config?.public?.apiBase || '').trim() + return normalizeApiBase(override || runtime || '/api') +} diff --git a/frontend/composables/useSettingsDialog.js b/frontend/composables/useSettingsDialog.js new file mode 100644 index 0000000..6727c56 --- /dev/null +++ b/frontend/composables/useSettingsDialog.js @@ -0,0 +1,17 @@ +export const useSettingsDialog = () => { + const open = useState('settings-dialog-open', () => false) + + const openDialog = () => { + open.value = true + } + + const closeDialog = () => { + open.value = false + } + + return { + open, + openDialog, + closeDialog, + } +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 963fda3..7a03351 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -1,4 +1,7 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392' +const devProxyTarget = `http://127.0.0.1:${backendPort}/api` + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: false }, @@ -6,7 +9,7 @@ export default defineNuxtConfig({ runtimeConfig: { public: { // Full API base, including `/api` when needed. - // Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:8000/api` + // Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:10392/api` apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api', }, }, @@ -22,7 +25,7 @@ export default defineNuxtConfig({ '/api': { // `h3` strips the matched prefix (`/api`) before calling the middleware, // so the proxy target must include `/api` to preserve backend routes. - target: 'http://127.0.0.1:8000/api', + target: devProxyTarget, changeOrigin: true } } diff --git a/frontend/pages/settings.vue b/frontend/pages/settings.vue deleted file mode 100644 index d95c6ce..0000000 --- a/frontend/pages/settings.vue +++ /dev/null @@ -1,282 +0,0 @@ -
-
-
-
-
-
-
设置
-
桌面端相关行为与启动偏好
-
- -
-
- 当前为浏览器环境:“桌面行为”分组仅桌面端可用;“启动偏好”分组可正常使用。 -
- -
-
-
桌面行为
-
-
-
-
-
开机自启动
-
系统登录后自动启动桌面端
-
- -
-
- {{ desktopAutoLaunchError }} -
- -
-
-
关闭窗口行为
-
点击关闭按钮时:默认最小化到托盘
-
- -
-
- {{ desktopCloseBehaviorError }} -
-
-
- -
-
-
启动偏好
-
-
-
-
-
启动后自动开启实时获取
-
进入聊天页后自动打开“实时开关”(默认关闭)
-
- -
- -
-
-
有数据时默认进入聊天页
-
有已解密账号时,打开应用默认跳转到 /chat(默认关闭)
-
- -
-
-
- -
-
-
更新
-
-
-
-
-
当前版本
-
- {{ desktopVersionText }} -
-
- -
-
- {{ desktopUpdate.lastCheckMessage.value }} -
-
-
- -
-
-
朋友圈
-
-
-
-
-
朋友圈图片使用缓存
-
开启:下载解密失败时回退本地缓存(默认开启);关闭:每次都走下载+解密
-
- -
-
-
-
-
-
-
-
-
- - - diff --git a/frontend/utils/api-settings.js b/frontend/utils/api-settings.js new file mode 100644 index 0000000..a333d5d --- /dev/null +++ b/frontend/utils/api-settings.js @@ -0,0 +1,35 @@ +export const API_BASE_OVERRIDE_KEY = 'ui.apiBaseOverride' + +export const readApiBaseOverride = () => { + if (!process.client) return '' + try { + const raw = localStorage.getItem(API_BASE_OVERRIDE_KEY) + return String(raw || '').trim() + } catch { + return '' + } +} + +export const writeApiBaseOverride = (value) => { + if (!process.client) return + try { + const v = String(value || '').trim() + if (!v) localStorage.removeItem(API_BASE_OVERRIDE_KEY) + else localStorage.setItem(API_BASE_OVERRIDE_KEY, v) + } catch {} +} + +export const normalizeApiBase = (value) => { + const raw = String(value || '').trim() + if (!raw) return '/api' + + let v = raw.replace(/\/$/, '') + + // If a full origin is provided, auto-append `/api` when missing. + if (/^https?:\/\//i.test(v) && !/\/api$/i.test(v)) { + v = `${v}/api` + } + + return v.replace(/\/$/, '') +} + diff --git a/main.py b/main.py index 426d786..924dc94 100644 --- a/main.py +++ b/main.py @@ -5,23 +5,30 @@ 使用方法: uv run main.py -默认在8000端口启动API服务 +默认在10392端口启动API服务 """ import uvicorn import os from pathlib import Path +from wechat_decrypt_tool.runtime_settings import read_effective_backend_port def main(): """启动微信解密工具API服务""" host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") - port = int(os.environ.get("WECHAT_TOOL_PORT", "8000")) + port, port_source = read_effective_backend_port(default=10392) access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host print("=" * 60) print("微信解密工具 API 服务") print("=" * 60) print("正在启动服务...") + if port_source == "env": + print("端口来源: 环境变量 WECHAT_TOOL_PORT") + elif port_source == "settings": + print("端口来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)") + else: + print("端口来源: 默认值") print(f"API文档: http://{access_host}:{port}/docs") print(f"健康检查: http://{access_host}:{port}/api/health") print("按 Ctrl+C 停止服务") diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index a17d56a..cd6f600 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -20,6 +20,7 @@ from .routers.chat_export import router as _chat_export_router from .routers.chat_media import router as _chat_media_router from .routers.decrypt import router as _decrypt_router from .routers.health import router as _health_router +from .routers.admin import router as _admin_router from .routers.keys import router as _keys_router from .routers.media import router as _media_router from .routers.sns import router as _sns_router @@ -75,6 +76,7 @@ async def _add_sns_stage_timing_headers(request: Request, call_next): app.include_router(_health_router) +app.include_router(_admin_router) app.include_router(_wechat_detection_router) app.include_router(_decrypt_router) app.include_router(_keys_router) @@ -192,6 +194,8 @@ async def _shutdown_wcdb_realtime() -> None: if __name__ == "__main__": import uvicorn + from .runtime_settings import read_effective_backend_port + host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") - port = int(os.environ.get("WECHAT_TOOL_PORT", "8000")) + port, _ = read_effective_backend_port(default=10392) uvicorn.run(app, host=host, port=port) diff --git a/src/wechat_decrypt_tool/backend_entry.py b/src/wechat_decrypt_tool/backend_entry.py index bbfbf3b..4f0d5e3 100644 --- a/src/wechat_decrypt_tool/backend_entry.py +++ b/src/wechat_decrypt_tool/backend_entry.py @@ -9,11 +9,12 @@ import os import uvicorn from wechat_decrypt_tool.api import app +from wechat_decrypt_tool.runtime_settings import read_effective_backend_port def main() -> None: host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") - port = int(os.environ.get("WECHAT_TOOL_PORT", "8000")) + port, _ = read_effective_backend_port(default=10392) uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/src/wechat_decrypt_tool/routers/admin.py b/src/wechat_decrypt_tool/routers/admin.py new file mode 100644 index 0000000..f445774 --- /dev/null +++ b/src/wechat_decrypt_tool/routers/admin.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import httpx +from fastapi import APIRouter, BackgroundTasks, HTTPException +from starlette.requests import Request + +from ..path_fix import PathFixRoute +from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting + + +router = APIRouter(route_class=PathFixRoute) + +DEFAULT_BACKEND_PORT = 10392 +_PORT_CHANGE_IN_PROGRESS = False + + +def _format_host_for_url(host: str) -> str: + h = str(host or "").strip() or "127.0.0.1" + if ":" in h and not (h.startswith("[") and h.endswith("]")): + return f"[{h}]" + return h + + +def _get_backend_bind_host() -> str: + return str(os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") or "").strip() or "127.0.0.1" + + +def _get_backend_access_host() -> str: + host = _get_backend_bind_host() + if host in {"0.0.0.0", "::"}: + return "127.0.0.1" + return host + + +def _is_loopback_client(request: Request) -> bool: + client = request.client + host = str(getattr(client, "host", "") or "").strip() + if not host: + return False + try: + ip = ipaddress.ip_address(host) + if ip.is_loopback: + return True + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped and ip.ipv4_mapped.is_loopback: + return True + except ValueError: + if host.lower() == "localhost": + return True + return False + + +def _is_port_available(port: int, host: str) -> bool: + try: + addr = (host, int(port)) + family = socket.AF_INET6 if ":" in host else socket.AF_INET + with socket.socket(family, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) + s.bind(addr) + return True + except Exception: + return False + + +async def _wait_for_backend_ready(health_url: str, timeout_s: float = 30.0) -> bool: + started = time.time() + async with httpx.AsyncClient(timeout=1.0) as client: + while time.time() - started < timeout_s: + try: + resp = await client.get(health_url) + if resp.status_code < 500: + return True + except Exception: + pass + await asyncio.sleep(0.3) + return False + + +def _spawn_backend_process(next_port: int) -> subprocess.Popen: + env = os.environ.copy() + env["WECHAT_TOOL_PORT"] = str(int(next_port)) + env.setdefault("WECHAT_TOOL_HOST", _get_backend_bind_host()) + + # Keep the same working directory so output paths remain consistent. + # (When `WECHAT_TOOL_DATA_DIR` is not set, the app uses `Path.cwd()`.) + cwd = os.getcwd() + cwd_path = Path(cwd) + + # Ensure local imports work when running from source (repo root + src layout). + src_dir = cwd_path / "src" + try: + existing_pp = str(env.get("PYTHONPATH", "") or "").strip() + if src_dir.is_dir(): + env["PYTHONPATH"] = str(src_dir) if not existing_pp else f"{src_dir}{os.pathsep}{existing_pp}" + except Exception: + pass + + if getattr(sys, "frozen", False): + cmd = [sys.executable] + spawn_cwd = cwd + else: + main_py = cwd_path / "main.py" + if main_py.is_file(): + cmd = [sys.executable, str(main_py)] + spawn_cwd = cwd + else: + cmd = [sys.executable, "-m", "wechat_decrypt_tool.backend_entry"] + spawn_cwd = cwd + + return subprocess.Popen(cmd, cwd=spawn_cwd, env=env) + + +async def _exit_process_after(delay_s: float) -> None: + try: + await asyncio.sleep(max(0.0, float(delay_s))) + except Exception: + pass + os._exit(0) # noqa: S404 + + +@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)") +async def get_backend_port() -> dict: + port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT) + return {"port": port, "source": source, "default_port": DEFAULT_BACKEND_PORT} + + +@router.post("/api/admin/port", summary="修改后端端口并重启(仅允许本机访问)") +async def set_backend_port(payload: dict, request: Request, background_tasks: BackgroundTasks) -> dict: + if not _is_loopback_client(request): + raise HTTPException(status_code=403, detail="仅允许本机访问该接口") + + global _PORT_CHANGE_IN_PROGRESS + if _PORT_CHANGE_IN_PROGRESS: + raise HTTPException(status_code=409, detail="端口切换中,请稍后重试") + + raw = payload.get("port") if isinstance(payload, dict) else None + try: + next_port = int(raw) + except Exception: + raise HTTPException(status_code=400, detail="端口无效:请输入 1-65535 的整数") + if next_port < 1 or next_port > 65535: + raise HTTPException(status_code=400, detail="端口无效:请输入 1-65535 的整数") + + current_port, _ = read_effective_backend_port(default=DEFAULT_BACKEND_PORT) + if next_port == int(current_port): + write_backend_port_setting(next_port) + env_file = write_backend_port_env_file(next_port) + host = _format_host_for_url(_get_backend_access_host()) + return { + "success": True, + "changed": False, + "port": next_port, + "ui_url": f"http://{host}:{next_port}/", + "env_file": str(env_file) if env_file else None, + } + + bind_host = _get_backend_bind_host() + if not _is_port_available(next_port, bind_host): + raise HTTPException(status_code=409, detail=f"端口 {next_port} 已被占用,请换一个端口") + + proc = None + _PORT_CHANGE_IN_PROGRESS = True + try: + try: + proc = _spawn_backend_process(next_port) + except Exception as e: + raise HTTPException(status_code=500, detail=f"启动新后端进程失败:{e}") + + access_host = _get_backend_access_host() + health_url = f"http://{_format_host_for_url(access_host)}:{next_port}/api/health" + ok = await _wait_for_backend_ready(health_url, timeout_s=30.0) + if not ok: + try: + if proc and proc.poll() is None: + proc.terminate() + except Exception: + pass + raise HTTPException(status_code=500, detail=f"新端口启动超时:{health_url}") + + # Persist only after the new backend is confirmed ready. + write_backend_port_setting(next_port) + env_file = write_backend_port_env_file(next_port) + + background_tasks.add_task(_exit_process_after, 0.2) + + host = _format_host_for_url(access_host) + return { + "success": True, + "changed": True, + "port": next_port, + "ui_url": f"http://{host}:{next_port}/", + "env_file": str(env_file) if env_file else None, + } + finally: + _PORT_CHANGE_IN_PROGRESS = False diff --git a/src/wechat_decrypt_tool/runtime_settings.py b/src/wechat_decrypt_tool/runtime_settings.py new file mode 100644 index 0000000..d071d53 --- /dev/null +++ b/src/wechat_decrypt_tool/runtime_settings.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + + +RUNTIME_SETTINGS_FILENAME = "runtime_settings.json" +BACKEND_PORT_KEY = "backend_port" +ENV_PORT_KEY = "WECHAT_TOOL_PORT" +ENV_FILE_KEY = "WECHAT_TOOL_ENV_FILE" +DEFAULT_ENV_FILENAME = ".env" + + +def _parse_port(value: object) -> int | None: + if value is None: + return None + try: + raw = str(value).strip() + except Exception: + return None + if not raw: + return None + try: + port = int(raw, 10) + except Exception: + return None + if port < 1 or port > 65535: + return None + return port + + +def get_runtime_settings_path() -> Path: + from .app_paths import get_output_dir + + return get_output_dir() / RUNTIME_SETTINGS_FILENAME + + +def read_backend_port_setting() -> int | None: + path = get_runtime_settings_path() + try: + if not path.is_file(): + return None + data = json.loads(path.read_text(encoding="utf-8") or "{}") + if not isinstance(data, dict): + return None + return _parse_port(data.get(BACKEND_PORT_KEY)) + except Exception: + return None + + +def write_backend_port_setting(port: int | None) -> None: + path = get_runtime_settings_path() + safe_port = _parse_port(port) + try: + path.parent.mkdir(parents=True, exist_ok=True) + except Exception: + return + + try: + data: dict = {} + if path.is_file(): + try: + existing = json.loads(path.read_text(encoding="utf-8") or "{}") + if isinstance(existing, dict): + data = existing + except Exception: + data = {} + + if safe_port is None: + data.pop(BACKEND_PORT_KEY, None) + else: + data[BACKEND_PORT_KEY] = safe_port + + # Keep the file small and stable; remove if empty. + if not data: + try: + path.unlink(missing_ok=True) + except Exception: + pass + return + + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + return + + +def read_effective_backend_port(default: int) -> tuple[int, str]: + """Return (port, source) where source is one of: env | settings | default.""" + + env_raw = str(os.environ.get("WECHAT_TOOL_PORT", "") or "").strip() + env_port = _parse_port(env_raw) + if env_port is not None: + return env_port, "env" + + settings_port = read_backend_port_setting() + if settings_port is not None: + return settings_port, "settings" + + return int(default), "default" + + +def get_env_file_path() -> Path | None: + """Best-effort env file path for `uv run` (defaults to repo root `.env`).""" + + v = str(os.environ.get(ENV_FILE_KEY, "") or "").strip() + if v: + try: + return Path(v) + except Exception: + return None + + cwd = Path.cwd() + # Heuristic: only write `.env` in a project root (avoid polluting random dirs). + try: + if (cwd / "pyproject.toml").is_file(): + return cwd / DEFAULT_ENV_FILENAME + except Exception: + return None + + return None + + +def _set_env_var_in_file(env_file: Path, key: str, value: str | None) -> bool: + try: + env_file.parent.mkdir(parents=True, exist_ok=True) + except Exception: + return False + + pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(key)}\s*=") + try: + raw = env_file.read_text(encoding="utf-8") if env_file.is_file() else "" + except Exception: + raw = "" + + lines = raw.splitlines(keepends=True) if raw else [] + out: list[str] = [] + replaced = False + for line in lines: + if pattern.match(line): + if value is None: + continue + if not replaced: + out.append(f"{key}={value}\n") + replaced = True + continue + out.append(line) + + if value is not None and not replaced: + if out and not out[-1].endswith("\n"): + out[-1] = out[-1] + "\n" + out.append(f"{key}={value}\n") + + try: + env_file.write_text("".join(out), encoding="utf-8") + return True + except Exception: + return False + + +def write_backend_port_env_file(port: int | None) -> Path | None: + """Write `WECHAT_TOOL_PORT` into a `.env` file so `uv run main.py` picks it up on restart. + + Note: `uv` doesn't override already-set env vars; `.env` only applies when the variable is not + present in the current shell/session. + """ + + env_file = get_env_file_path() + if not env_file: + return None + + safe_port = _parse_port(port) + ok = _set_env_var_in_file(env_file, ENV_PORT_KEY, str(safe_port) if safe_port is not None else None) + return env_file if ok else None