feat(settings): 增加 MCP 局域网接入配置

在设置面板新增 MCP 接入区,支持开启手机局域网访问、复制 MCP Token、重置 Token、复制 AI 接入提示词和 Skill Markdown。

桌面端增加 MCP 局域网访问 IPC,并在切换监听地址后重启后端。

后端运行时设置支持保存 backend_host 与 mcp_token,并同步写入 .env 供开发模式重启使用。
This commit is contained in:
2977094657
2026-06-11 14:58:35 +08:00
Unverified
parent d74e8415f8
commit 1e928c6af7
7 changed files with 770 additions and 44 deletions
+95 -7
View File
@@ -30,7 +30,8 @@ const {
normalizeDirectoryPath,
} = require("./output-dir.cjs");
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
const DEFAULT_BACKEND_HOST = "127.0.0.1";
const LAN_BACKEND_HOST = "0.0.0.0";
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
let backendProc = null;
@@ -86,7 +87,11 @@ function formatHostForUrl(host) {
}
function getBackendBindHost() {
return DEFAULT_BACKEND_HOST;
const envHost = String(process.env.WECHAT_TOOL_HOST || "").trim();
if (envHost === LAN_BACKEND_HOST || envHost === "::") return LAN_BACKEND_HOST;
if (envHost === DEFAULT_BACKEND_HOST || envHost === "localhost" || envHost === "::1") return DEFAULT_BACKEND_HOST;
if (!app.isPackaged) return DEFAULT_BACKEND_HOST;
return loadDesktopSettings()?.mcpLanAccessEnabled ? LAN_BACKEND_HOST : DEFAULT_BACKEND_HOST;
}
function getBackendAccessHost() {
@@ -116,6 +121,18 @@ function setBackendPortSetting(nextPort) {
return p;
}
function getMcpLanAccessEnabled() {
return getBackendBindHost() === LAN_BACKEND_HOST;
}
function setMcpLanAccessSetting(enabled) {
loadDesktopSettings();
desktopSettings.mcpLanAccessEnabled = !!enabled;
persistDesktopSettings();
process.env.WECHAT_TOOL_HOST = desktopSettings.mcpLanAccessEnabled ? LAN_BACKEND_HOST : DEFAULT_BACKEND_HOST;
return desktopSettings.mcpLanAccessEnabled;
}
function getBackendHealthUrl() {
const host = formatHostForUrl(getBackendAccessHost());
const port = getBackendPort();
@@ -128,6 +145,12 @@ function getBackendUiUrl() {
return `http://${host}:${port}/`;
}
function getDesktopUiUrl() {
const explicit = String(process.env.ELECTRON_START_URL || "").trim();
if (explicit) return explicit;
return app.isPackaged ? getBackendUiUrl() : "http://localhost:3000";
}
function isPortAvailable(port, host) {
return new Promise((resolve) => {
try {
@@ -597,6 +620,8 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// When enabled, the backend binds to 0.0.0.0 so phone clients can reach /mcp.
mcpLanAccessEnabled: false,
// Custom output dir; empty string means use the default dataDir/output.
outputDir: "",
// Pending output dir written by the installer before the next app startup.
@@ -623,6 +648,7 @@ function loadDesktopSettings() {
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
desktopSettings.mcpLanAccessEnabled = !!desktopSettings.mcpLanAccessEnabled;
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
desktopSettings.pendingOutputDir =
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
@@ -2275,7 +2301,7 @@ function registerWindowIpc() {
const prevPort = getBackendPort();
if (nextPort === prevPort) {
return { success: true, changed: false, port: prevPort, uiUrl: getBackendUiUrl() };
return { success: true, changed: false, port: prevPort, uiUrl: getDesktopUiUrl() };
}
const bindHost = getBackendBindHost();
@@ -2296,7 +2322,7 @@ function registerWindowIpc() {
throw err;
}
const uiUrl = getBackendUiUrl();
const uiUrl = getDesktopUiUrl();
setTimeout(() => {
try {
if (!mainWindow || mainWindow.isDestroyed()) return;
@@ -2312,6 +2338,70 @@ function registerWindowIpc() {
}
});
ipcMain.handle("backend:getMcpLanAccess", () => {
try {
return {
enabled: getMcpLanAccessEnabled(),
host: getBackendBindHost(),
port: getBackendPort(),
uiUrl: getDesktopUiUrl(),
};
} catch (err) {
logMain(`[main] backend:getMcpLanAccess failed: ${err?.message || err}`);
return {
enabled: false,
host: DEFAULT_BACKEND_HOST,
port: DEFAULT_BACKEND_PORT,
uiUrl: getDesktopUiUrl(),
};
}
});
ipcMain.handle("backend:setMcpLanAccess", async (_event, enabled) => {
if (backendPortChangeInProgress) throw new Error("后端切换中,请稍后重试");
const nextEnabled = !!enabled;
const prevEnabled = getMcpLanAccessEnabled();
if (nextEnabled === prevEnabled) {
return {
success: true,
changed: false,
enabled: prevEnabled,
host: getBackendBindHost(),
port: getBackendPort(),
uiUrl: getDesktopUiUrl(),
};
}
backendPortChangeInProgress = true;
try {
setMcpLanAccessSetting(nextEnabled);
try {
await restartBackend({ timeoutMs: 30_000 });
} catch (err) {
setMcpLanAccessSetting(prevEnabled);
try {
await restartBackend({ timeoutMs: 30_000 });
} catch {}
throw err;
}
const uiUrl = getDesktopUiUrl();
logMain(`[main] MCP access changed enabled=${nextEnabled}; backend restarted without UI reload`);
return {
success: true,
changed: true,
enabled: nextEnabled,
host: getBackendBindHost(),
port: getBackendPort(),
uiUrl,
};
} finally {
backendPortChangeInProgress = false;
}
});
ipcMain.handle("app:getVersion", () => {
try {
return app.getVersion();
@@ -2510,9 +2600,7 @@ async function main() {
mainWindow = win;
ensureTrayForCloseBehavior();
const startUrl =
process.env.ELECTRON_START_URL ||
(app.isPackaged ? getBackendUiUrl() : "http://localhost:3000");
const startUrl = getDesktopUiUrl();
logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`);
await loadWithRetry(win, startUrl);
+2
View File
@@ -78,6 +78,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
getBackendPort: () => ipcRenderer.invoke("backend:getPort"),
setBackendPort: (port) => ipcRenderer.invoke("backend:setPort", Number(port)),
getMcpLanAccess: () => ipcRenderer.invoke("backend:getMcpLanAccess"),
setMcpLanAccess: (enabled) => ipcRenderer.invoke("backend:setMcpLanAccess", !!enabled),
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
+328
View File
@@ -261,6 +261,104 @@
</div>
</section>
<section ref="mcpSectionRef">
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">MCP 接入</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">允许手机局域网接入 MCP</div>
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">开启后后端监听 0.0.0.0手机可通过接入提示词中的地址接入</div>
<div v-if="mcpLanAccessMessage" class="mt-1 text-[11px] leading-relaxed text-[#1b6b43]">{{ mcpLanAccessMessage }}</div>
<div v-if="mcpLanAccessError" class="mt-1 text-[11px] leading-relaxed text-red-600">{{ mcpLanAccessError }}</div>
</div>
<button
type="button"
role="switch"
:aria-checked="mcpLanAccessEnabled"
class="settings-switch shrink-0"
:class="switchTrackClass(mcpLanAccessEnabled, mcpLanAccessLoading)"
:disabled="mcpLanAccessLoading"
@click="toggleMcpLanAccess"
>
<span class="settings-switch-thumb" :class="mcpLanAccessEnabled ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">MCP Token</div>
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">手机端请求 MCP 时使用 Bearer token</div>
<div v-if="mcpTokenError" class="mt-1 text-[11px] leading-relaxed text-red-600">{{ mcpTokenError }}</div>
</div>
<div class="flex shrink-0 gap-1.5">
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
:disabled="mcpTokenLoading || !mcpToken"
@click="copyMcpText('token', mcpToken)"
>
{{ mcpCopiedKey === 'token' ? '已复制' : (mcpTokenLoading ? '加载中...' : '复制 Token') }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
:disabled="mcpTokenLoading"
@click="resetMcpToken"
>
{{ mcpCopiedKey === 'token-reset' ? '已重置' : '重置' }}
</button>
</div>
</div>
<pre class="max-h-[92px] overflow-auto rounded-[6px] bg-[#f7f7f7] px-2.5 py-2 text-[11px] leading-relaxed text-[#333] scrollbar-custom whitespace-pre-wrap">{{ mcpTokenText }}</pre>
</div>
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">AI 接入提示词</div>
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">复制到手机端 AI 的系统提示词或连接说明里</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
@click="copyMcpText('ai-prompt', mcpAiPrompt)"
>
{{ mcpCopiedKey === 'ai-prompt' ? '已复制' : '复制提示词' }}
</button>
</div>
<pre class="max-h-[220px] overflow-auto rounded-[6px] bg-[#f7f7f7] px-2.5 py-2 text-[11px] leading-relaxed text-[#333] scrollbar-custom whitespace-pre-wrap">{{ mcpAiPrompt }}</pre>
</div>
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">Skill Markdown</div>
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">单独复制到手机端 AI skill 或知识配置</div>
<div v-if="mcpSkillBundleError" class="mt-1 text-[11px] leading-relaxed text-red-600">{{ mcpSkillBundleError }}</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
:disabled="mcpSkillBundleLoading"
@click="copyMcpText('skill', mcpSkillText)"
>
{{ mcpCopiedKey === 'skill' ? '已复制' : (mcpSkillBundleLoading ? '加载中...' : '复制 Skill') }}
</button>
</div>
<pre class="max-h-[420px] overflow-auto rounded-[6px] bg-[#f7f7f7] px-2.5 py-2 text-[11px] leading-relaxed text-[#333] scrollbar-custom whitespace-pre-wrap">{{ mcpSkillText }}</pre>
</div>
</div>
</div>
</section>
<section ref="startupSectionRef">
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
@@ -376,6 +474,7 @@ const emit = defineEmits(['close'])
const settingNavItems = [
{ key: 'desktop', label: '桌面行为', hint: '启动 / 关闭 / 端口' },
{ key: 'mcp', label: 'MCP 接入', hint: '手机 / Skill / 工具' },
{ key: 'startup', label: '启动偏好', hint: '自动实时 / 默认页面' },
{ key: 'updates', label: '更新', hint: '版本信息 / 检查更新' },
{ key: 'sns', label: '朋友圈', hint: '图片缓存策略' },
@@ -384,6 +483,7 @@ const settingNavItems = [
const activeSection = ref(settingNavItems[0].key)
const contentScrollRef = ref(null)
const desktopSectionRef = ref(null)
const mcpSectionRef = ref(null)
const startupSectionRef = ref(null)
const updatesSectionRef = ref(null)
const snsSectionRef = ref(null)
@@ -497,6 +597,65 @@ const desktopLogFileText = computed(() => {
return v || '—'
})
const mcpLanAccessEnabled = ref(false)
const mcpLanAccessLoading = ref(false)
const mcpLanAccessError = ref('')
const mcpLanAccessMessage = ref('')
const mcpToken = ref('')
const mcpTokenLoading = ref(false)
const mcpTokenError = ref('')
const mcpSkillBundleText = ref('')
const mcpSkillBundleLoading = ref(false)
const mcpSkillBundleError = ref('')
const mcpCopiedKey = ref('')
let mcpCopiedTimer = null
const mcpPortText = computed(() => {
const n = Number(String(desktopBackendPortInput.value || '').trim())
if (Number.isInteger(n) && n >= 1 && n <= 65535) return String(n)
return '10392'
})
const mcpEndpoint = computed(() => {
if (!process.client || typeof window === 'undefined') return `http://127.0.0.1:${mcpPortText.value}/mcp`
const apiBase = useApiBase()
if (/^https?:\/\//i.test(apiBase)) {
try {
const u = new URL(apiBase)
return `${u.origin}/mcp`
} catch {}
}
const protocol = window.location?.protocol === 'https:' ? 'https:' : 'http:'
const host = String(window.location?.hostname || '127.0.0.1').trim() || '127.0.0.1'
return `${protocol}//${host}:${mcpPortText.value}/mcp`
})
const mcpSkillFallback = [
'# WeChat MCP Copilot',
'',
'Use WeChatDataAnalysis MCP like an investigator: start broad, resolve fuzzy targets, then fetch only the context needed to answer.',
'',
'Core rules:',
'1. Start with initialize and tools/list.',
'2. Prefer compact mobile facade tools before low-level tools.',
'3. Keep limits small, page results, and expand only when needed.',
'4. Use returned URLs for media and exports instead of inlining binary content.',
].join('\n')
const mcpAiPrompt = computed(() => [
'你现在可以通过 WeChatDataAnalysis MCP 访问本机微信数据。',
`MCP endpoint: ${mcpEndpoint.value}`,
`Authorization: Bearer ${mcpToken.value || '<MCP_TOKEN>'}`,
'',
'接入要求:',
'1. 使用 JSON-RPC 2.0 POST 到 MCP endpointContent-Type 为 application/json,并带上 Authorization Bearer token。',
'2. 先调用 initialize,再用 tools/list 分页读取工具 schema。',
'3. 工具调用使用 tools/call,优先读取 result.structuredContent。',
'4. 不要一次性请求大结果;按下方 skill 的分页和上下文预算逐步扩展。',
'5. 媒体、导出和 SSE 进度按返回 URL 在 App 侧加载,不要让模型内联二进制内容。',
].join('\n'))
const mcpSkillText = computed(() => mcpSkillBundleText.value || mcpSkillFallback)
const mcpTokenText = computed(() => mcpToken.value || '加载中...')
const switchTrackClass = (enabled, disabled = false) => {
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
@@ -535,6 +694,7 @@ const refreshDesktopOutputDirProgress = async () => {
const sectionElements = computed(() => [
{ key: 'desktop', el: desktopSectionRef.value },
{ key: 'mcp', el: mcpSectionRef.value },
{ key: 'startup', el: startupSectionRef.value },
{ key: 'updates', el: updatesSectionRef.value },
{ key: 'sns', el: snsSectionRef.value },
@@ -591,6 +751,164 @@ const fetchAdminEndpoint = async (url, options = {}) => {
}
}
const waitForBackendHealth = async (timeoutMs = 30_000) => {
if (!process.client || typeof window === 'undefined') return
const apiBase = useApiBase()
const healthUrl = `${String(apiBase || '').replace(/\/api\/?$/, '')}/api/health`
const startedAt = Date.now()
while (true) {
try {
const r = await fetch(healthUrl, { method: 'GET' })
if (r && r.status < 500) return
} catch {}
if (Date.now() - startedAt > timeoutMs) throw new Error(`后端启动超时:${healthUrl}`)
await new Promise((resolve) => setTimeout(resolve, 400))
}
}
const copyMcpText = async (key, text) => {
if (!process.client || typeof window === 'undefined') return
const value = String(text || '').trim()
if (!value) return
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
const el = document.createElement('textarea')
el.value = value
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
mcpCopiedKey.value = key
if (mcpCopiedTimer) clearTimeout(mcpCopiedTimer)
mcpCopiedTimer = setTimeout(() => {
if (mcpCopiedKey.value === key) mcpCopiedKey.value = ''
}, 1600)
} catch {}
}
const refreshMcpLanAccess = async () => {
if (!process.client || typeof window === 'undefined') return
mcpLanAccessLoading.value = true
mcpLanAccessError.value = ''
try {
if (window.wechatDesktop?.getMcpLanAccess) {
const resp = await window.wechatDesktop.getMcpLanAccess()
mcpLanAccessEnabled.value = !!resp?.enabled
return
}
const resp = await fetchAdminEndpoint('/admin/mcp-access')
mcpLanAccessEnabled.value = !!resp?.enabled
} catch (e) {
mcpLanAccessError.value = e?.message || '读取 MCP 接入状态失败'
} finally {
mcpLanAccessLoading.value = false
}
}
const refreshMcpToken = async () => {
if (!process.client || typeof window === 'undefined') return
mcpTokenLoading.value = true
mcpTokenError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/mcp-token')
const token = String(resp?.token || '').trim()
if (!token) throw new Error('MCP token is empty')
mcpToken.value = token
} catch (e) {
mcpTokenError.value = e?.message || '读取 MCP Token 失败'
} finally {
mcpTokenLoading.value = false
}
}
const resetMcpToken = async () => {
if (!process.client || typeof window === 'undefined') return
mcpTokenLoading.value = true
mcpTokenError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/mcp-token/reset', { method: 'POST' })
const token = String(resp?.token || '').trim()
if (!token) throw new Error('MCP token is empty')
mcpToken.value = token
mcpCopiedKey.value = 'token-reset'
if (mcpCopiedTimer) clearTimeout(mcpCopiedTimer)
mcpCopiedTimer = setTimeout(() => {
if (mcpCopiedKey.value === 'token-reset') mcpCopiedKey.value = ''
}, 1600)
await refreshMcpSkillBundle()
} catch (e) {
mcpTokenError.value = e?.message || '重置 MCP Token 失败'
} finally {
mcpTokenLoading.value = false
}
}
const refreshMcpSkillBundle = async () => {
if (!process.client || typeof window === 'undefined') return
mcpSkillBundleLoading.value = true
mcpSkillBundleError.value = ''
try {
if (!mcpToken.value) await refreshMcpToken()
const resp = await $fetch('/mcp/skill/bundle', {
baseURL: mcpEndpoint.value.replace(/\/mcp$/, ''),
headers: mcpToken.value ? { Authorization: `Bearer ${mcpToken.value}` } : {},
})
const bundleText = String(resp?.bundleText || '').trim()
if (!bundleText) throw new Error('Skill bundle is empty')
mcpSkillBundleText.value = bundleText
} catch (e) {
mcpSkillBundleError.value = e?.message || '读取 skill 失败,当前显示内置精简版'
if (!mcpSkillBundleText.value) mcpSkillBundleText.value = ''
} finally {
mcpSkillBundleLoading.value = false
}
}
const setMcpLanAccess = async (enabled) => {
if (!process.client || typeof window === 'undefined') return
mcpLanAccessLoading.value = true
mcpLanAccessError.value = ''
mcpLanAccessMessage.value = ''
const previous = mcpLanAccessEnabled.value
mcpLanAccessEnabled.value = !!enabled
try {
if (window.wechatDesktop?.setMcpLanAccess) {
const resp = await window.wechatDesktop.setMcpLanAccess(!!enabled)
mcpLanAccessEnabled.value = !!resp?.enabled
mcpLanAccessMessage.value = resp?.changed ? 'MCP 局域网接入已更新,后端已重启。' : 'MCP 局域网接入状态未变化。'
return
}
const resp = await fetchAdminEndpoint('/admin/mcp-access', {
method: 'POST',
body: { enabled: !!enabled },
})
mcpLanAccessEnabled.value = !!resp?.enabled
mcpLanAccessMessage.value = resp?.changed ? 'MCP 局域网接入已更新,正在等待后端重启。' : 'MCP 局域网接入状态未变化。'
if (resp?.changed) {
await waitForBackendHealth(30_000)
mcpLanAccessMessage.value = 'MCP 局域网接入已更新,后端已恢复。'
}
} catch (e) {
mcpLanAccessEnabled.value = previous
mcpLanAccessError.value = e?.message || '设置 MCP 接入状态失败'
await refreshMcpLanAccess()
} finally {
mcpLanAccessLoading.value = false
}
}
const toggleMcpLanAccess = async () => {
if (mcpLanAccessLoading.value) return
await setMcpLanAccess(!mcpLanAccessEnabled.value)
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
@@ -945,6 +1263,9 @@ const onDesktopCheckUpdates = async () => {
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshMcpLanAccess()
await refreshMcpToken()
await refreshMcpSkillBundle()
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
@@ -969,6 +1290,9 @@ onMounted(async () => {
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
await refreshDesktopBackendPort()
await refreshMcpLanAccess()
await refreshMcpToken()
await refreshMcpSkillBundle()
if (isDesktopEnv.value) {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
@@ -984,6 +1308,10 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onEscKeydown)
if (mcpCopiedTimer) {
clearTimeout(mcpCopiedTimer)
mcpCopiedTimer = null
}
if (typeof removeDesktopOutputDirProgressListener === 'function') {
removeDesktopOutputDirProgressListener()
removeDesktopOutputDirProgressListener = null
+9 -2
View File
@@ -11,11 +11,11 @@
import uvicorn
import os
from pathlib import Path
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
from wechat_decrypt_tool.runtime_settings import read_effective_backend_host, read_effective_backend_port
def main():
"""启动微信解密工具API服务"""
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
host, host_source = read_effective_backend_host(default="127.0.0.1")
port, port_source = read_effective_backend_port(default=10392)
access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
@@ -29,6 +29,13 @@ def main():
print("端口来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)")
else:
print("端口来源: 默认值")
if host_source == "env":
print("监听地址来源: 环境变量 WECHAT_TOOL_HOST")
elif host_source == "settings":
print("监听地址来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)")
else:
print("监听地址来源: 默认值")
print(f"监听地址: {host}")
print(f"API文档: http://{access_host}:{port}/docs")
print(f"健康检查: http://{access_host}:{port}/api/health")
print("按 Ctrl+C 停止服务")
+2 -2
View File
@@ -9,11 +9,11 @@ import os
import uvicorn
from wechat_decrypt_tool.api import app
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
from wechat_decrypt_tool.runtime_settings import read_effective_backend_host, read_effective_backend_port
def main() -> None:
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
host, _ = read_effective_backend_host(default="127.0.0.1")
port, _ = read_effective_backend_port(default=10392)
uvicorn.run(app, host=host, port=port, log_level="info")
+154 -4
View File
@@ -15,7 +15,19 @@ from starlette.requests import Request
from ..logging_config import get_log_file_path, get_logger
from ..path_fix import PathFixRoute
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
from ..runtime_settings import (
LAN_BACKEND_HOST,
LOOPBACK_BACKEND_HOST,
read_effective_backend_host,
read_effective_mcp_token,
read_effective_backend_port,
reset_mcp_token,
write_backend_host_env_file,
write_backend_host_setting,
write_backend_port_env_file,
write_backend_port_setting,
write_mcp_token_env_file,
)
router = APIRouter(route_class=PathFixRoute)
@@ -33,7 +45,8 @@ def _format_host_for_url(host: str) -> str:
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"
host, _ = read_effective_backend_host(default=LOOPBACK_BACKEND_HOST)
return host
def _get_backend_access_host() -> str:
@@ -116,10 +129,10 @@ async def _wait_for_backend_ready(health_url: str, timeout_s: float = 30.0) -> b
return False
def _spawn_backend_process(next_port: int) -> subprocess.Popen:
def _spawn_backend_process(next_port: int, next_host: str | None = None) -> subprocess.Popen:
env = os.environ.copy()
env["WECHAT_TOOL_PORT"] = str(int(next_port))
env.setdefault("WECHAT_TOOL_HOST", _get_backend_bind_host())
env["WECHAT_TOOL_HOST"] = str(next_host or _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()`.)
@@ -150,6 +163,40 @@ def _spawn_backend_process(next_port: int) -> subprocess.Popen:
return subprocess.Popen(cmd, cwd=spawn_cwd, env=env)
def _spawn_backend_process_after_delay(next_port: int, next_host: str, delay_s: float = 0.8) -> subprocess.Popen:
env = os.environ.copy()
env["WECHAT_TOOL_PORT"] = str(int(next_port))
env["WECHAT_TOOL_HOST"] = str(next_host or _get_backend_bind_host())
cwd = os.getcwd()
cwd_path = Path(cwd)
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):
target = [sys.executable]
else:
main_py = cwd_path / "main.py"
if main_py.is_file():
target = [sys.executable, str(main_py)]
else:
target = [sys.executable, "-m", "wechat_decrypt_tool.backend_entry"]
# Keep the launcher independent from this process; it starts the backend after
# the current process has released its listening socket.
launcher_code = (
"import os,subprocess,sys,time;"
f"time.sleep({max(0.0, float(delay_s))!r});"
"subprocess.Popen(sys.argv[1:], cwd=os.getcwd(), env=os.environ)"
)
return subprocess.Popen([sys.executable, "-c", launcher_code, *target], cwd=cwd, env=env)
async def _exit_process_after(delay_s: float) -> None:
try:
await asyncio.sleep(max(0.0, float(delay_s)))
@@ -212,6 +259,58 @@ async def get_backend_port() -> dict:
return {"port": port, "source": source, "default_port": DEFAULT_BACKEND_PORT}
@router.get("/api/admin/mcp-access", summary="获取 MCP 局域网接入状态")
async def get_mcp_access() -> dict:
host, source = read_effective_backend_host(default=LOOPBACK_BACKEND_HOST)
port, port_source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
return {
"enabled": host == LAN_BACKEND_HOST,
"host": host,
"source": source,
"port": port,
"port_source": port_source,
"default_host": LOOPBACK_BACKEND_HOST,
"lan_host": LAN_BACKEND_HOST,
"restart_required": False,
}
@router.get("/api/admin/mcp-token", summary="获取 MCP token(仅允许本机访问)")
async def get_mcp_token(request: Request) -> dict:
if not _is_loopback_client(request):
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
from ..runtime_settings import ensure_mcp_token
token, source = ensure_mcp_token()
env_file = write_mcp_token_env_file(token)
return {
"success": True,
"token": token,
"source": source,
"env_file": str(env_file) if env_file else None,
}
@router.post("/api/admin/mcp-token/reset", summary="重置 MCP token(仅允许本机访问)")
async def reset_mcp_token_endpoint(request: Request) -> dict:
if not _is_loopback_client(request):
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
previous, previous_source = read_effective_mcp_token()
token = reset_mcp_token()
os.environ["WECHAT_TOOL_MCP_TOKEN"] = token
env_file = write_mcp_token_env_file(token)
return {
"success": True,
"changed": token != previous,
"token": token,
"previous_source": previous_source,
"source": "reset",
"env_file": str(env_file) if env_file else None,
}
@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):
@@ -281,3 +380,54 @@ async def set_backend_port(payload: dict, request: Request, background_tasks: Ba
}
finally:
_PORT_CHANGE_IN_PROGRESS = False
@router.post("/api/admin/mcp-access", summary="开启或关闭 MCP 局域网接入并重启后端(仅允许本机访问)")
async def set_mcp_access(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="后端切换中,请稍后重试")
enabled = bool(payload.get("enabled")) if isinstance(payload, dict) else False
next_host = LAN_BACKEND_HOST if enabled else LOOPBACK_BACKEND_HOST
current_host = _get_backend_bind_host()
current_port, _ = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
if next_host == current_host:
write_backend_host_setting(next_host)
env_file = write_backend_host_env_file(next_host)
return {
"success": True,
"changed": False,
"enabled": enabled,
"host": next_host,
"port": int(current_port),
"ui_url": f"http://{_format_host_for_url(_get_backend_access_host())}:{int(current_port)}/",
"env_file": str(env_file) if env_file else None,
}
_PORT_CHANGE_IN_PROGRESS = True
try:
write_backend_host_setting(next_host)
env_file = write_backend_host_env_file(next_host)
# Host changes keep the same port. The old socket must close before the
# new process can bind, so start a detached launcher and then exit.
background_tasks.add_task(_spawn_backend_process_after_delay, int(current_port), next_host, 0.8)
background_tasks.add_task(_exit_process_after, 0.2)
return {
"success": True,
"changed": True,
"enabled": enabled,
"host": next_host,
"port": int(current_port),
"ui_url": f"http://{_format_host_for_url(LOOPBACK_BACKEND_HOST)}:{int(current_port)}/",
"env_file": str(env_file) if env_file else None,
"restart_scheduled": True,
}
finally:
_PORT_CHANGE_IN_PROGRESS = False
+180 -29
View File
@@ -3,14 +3,21 @@ from __future__ import annotations
import json
import os
import re
import secrets
from pathlib import Path
RUNTIME_SETTINGS_FILENAME = "runtime_settings.json"
BACKEND_PORT_KEY = "backend_port"
BACKEND_HOST_KEY = "backend_host"
MCP_TOKEN_KEY = "mcp_token"
ENV_PORT_KEY = "WECHAT_TOOL_PORT"
ENV_HOST_KEY = "WECHAT_TOOL_HOST"
ENV_MCP_TOKEN_KEY = "WECHAT_TOOL_MCP_TOKEN"
ENV_FILE_KEY = "WECHAT_TOOL_ENV_FILE"
DEFAULT_ENV_FILENAME = ".env"
LOOPBACK_BACKEND_HOST = "127.0.0.1"
LAN_BACKEND_HOST = "0.0.0.0"
def _parse_port(value: object) -> int | None:
@@ -31,6 +38,66 @@ def _parse_port(value: object) -> int | None:
return port
def _normalize_host(value: object) -> str | None:
try:
raw = str(value or "").strip()
except Exception:
return None
if raw in {LOOPBACK_BACKEND_HOST, "localhost", "::1"}:
return LOOPBACK_BACKEND_HOST
if raw in {LAN_BACKEND_HOST, "::"}:
return LAN_BACKEND_HOST
return None
def _normalize_mcp_token(value: object) -> str | None:
try:
raw = str(value or "").strip()
except Exception:
return None
if len(raw) < 16 or len(raw) > 512:
return None
if any(ch.isspace() for ch in raw):
return None
return raw
def generate_mcp_token() -> str:
return secrets.token_urlsafe(32)
def _read_runtime_settings() -> dict:
path = get_runtime_settings_path()
try:
if not path.is_file():
return {}
data = json.loads(path.read_text(encoding="utf-8") or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _write_runtime_settings(data: dict) -> None:
path = get_runtime_settings_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
return
try:
cleaned = data if isinstance(data, dict) else {}
if not cleaned:
try:
path.unlink(missing_ok=True)
except Exception:
pass
return
path.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
return
def get_runtime_settings_path() -> Path:
from .app_paths import get_output_dir
@@ -38,50 +105,24 @@ def get_runtime_settings_path() -> Path:
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
data = _read_runtime_settings()
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 = {}
data = _read_runtime_settings()
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")
_write_runtime_settings(data)
except Exception:
return
@@ -101,6 +142,92 @@ def read_effective_backend_port(default: int) -> tuple[int, str]:
return int(default), "default"
def read_backend_host_setting() -> str | None:
try:
data = _read_runtime_settings()
return _normalize_host(data.get(BACKEND_HOST_KEY))
except Exception:
return None
def write_backend_host_setting(host: str | None) -> None:
safe_host = _normalize_host(host)
try:
data = _read_runtime_settings()
if safe_host is None:
data.pop(BACKEND_HOST_KEY, None)
else:
data[BACKEND_HOST_KEY] = safe_host
_write_runtime_settings(data)
except Exception:
return
def read_effective_backend_host(default: str = LOOPBACK_BACKEND_HOST) -> tuple[str, str]:
"""Return (host, source) where source is one of: env | settings | default."""
env_host = _normalize_host(os.environ.get(ENV_HOST_KEY, ""))
if env_host is not None:
return env_host, "env"
settings_host = read_backend_host_setting()
if settings_host is not None:
return settings_host, "settings"
return _normalize_host(default) or LOOPBACK_BACKEND_HOST, "default"
def read_mcp_token_setting() -> str | None:
try:
data = _read_runtime_settings()
return _normalize_mcp_token(data.get(MCP_TOKEN_KEY))
except Exception:
return None
def write_mcp_token_setting(token: str | None) -> None:
safe_token = _normalize_mcp_token(token)
try:
data = _read_runtime_settings()
if safe_token is None:
data.pop(MCP_TOKEN_KEY, None)
else:
data[MCP_TOKEN_KEY] = safe_token
_write_runtime_settings(data)
except Exception:
return
def read_effective_mcp_token() -> tuple[str | None, str]:
"""Return (token, source) where source is one of: env | settings | missing."""
env_token = _normalize_mcp_token(os.environ.get(ENV_MCP_TOKEN_KEY, ""))
if env_token is not None:
return env_token, "env"
settings_token = read_mcp_token_setting()
if settings_token is not None:
return settings_token, "settings"
return None, "missing"
def ensure_mcp_token() -> tuple[str, str]:
token, source = read_effective_mcp_token()
if token:
return token, source
token = generate_mcp_token()
write_mcp_token_setting(token)
return token, "generated"
def reset_mcp_token() -> str:
token = generate_mcp_token()
write_mcp_token_setting(token)
return token
def get_env_file_path() -> Path | None:
"""Best-effort env file path for `uv run` (defaults to repo root `.env`)."""
@@ -173,3 +300,27 @@ def write_backend_port_env_file(port: int | None) -> Path | 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
def write_backend_host_env_file(host: str | None) -> Path | None:
"""Write `WECHAT_TOOL_HOST` into a `.env` file so `uv run main.py` picks it up on restart."""
env_file = get_env_file_path()
if not env_file:
return None
safe_host = _normalize_host(host)
ok = _set_env_var_in_file(env_file, ENV_HOST_KEY, safe_host)
return env_file if ok else None
def write_mcp_token_env_file(token: str | None) -> Path | None:
"""Write `WECHAT_TOOL_MCP_TOKEN` into a `.env` file so `uv run main.py` picks it up."""
env_file = get_env_file_path()
if not env_file:
return None
safe_token = _normalize_mcp_token(token)
ok = _set_env_var_in_file(env_file, ENV_MCP_TOKEN_KEY, safe_token)
return env_file if ok else None