From 945d36eb313ca5690fa4a53eee7e1f449018d8d2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 8 Sep 2026 10:38:18 +0800 Subject: [PATCH] feat: add tray settings and local console access --- docs/WebUI-MCP-使用说明.md | 25 ++- docs/WebUI-MCP-功能矩阵.md | 6 +- docs/WebUI-MCP-开发计划.md | 12 +- docs/validation/WebUI-MCP-2026-09-07.md | 4 +- docs/webui-mcp-config.example.json | 2 - installer/WxAgent.iss | 2 + src/WxAgent.Service/ServiceOptions.cs | 9 +- src/WxAgent.Service/ServiceSecurity.cs | 30 ++- src/WxAgent.Service/wwwroot/app.js | 15 +- src/WxAgent.Service/wwwroot/index.html | 4 +- src/WxAgent.Tray/Program.cs | 176 +++++++++++++++++- src/WxAgent.Tray/WxAgent.Tray.csproj | 4 + src/WxAgent.Tray/wxagent.ico | Bin 0 -> 10502 bytes .../ServiceBoundaryTests.cs | 16 ++ tests/WxAgent.Service.Tests/ServiceTests.cs | 4 +- 15 files changed, 254 insertions(+), 55 deletions(-) create mode 100644 src/WxAgent.Tray/wxagent.ico diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index ee8ac7e..58d2c7f 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -2,8 +2,15 @@ ## 启动 -1. 在已登录、未锁定的 Windows 用户会话中准备目录和凭据文件;不要通过 HTTP 生成 Token。 -2. `credentials.json` 只保存 SHA-256 大写十六进制摘要,例如: +1. 在已登录、未锁定的 Windows 用户会话中运行发布包中的 `WxAgent-Setup.exe`。安装器会创建桌面/开始菜单快捷方式和可选的登录启动项,不需要 PowerShell 或管理员权限。 +2. 启动托盘程序后,首次运行会生成远程访问 Token,并在一次性窗口中提供复制按钮。后续通过托盘菜单“访问凭据...”新建或撤销 Token,通过“服务设置...”配置监听和服务参数;不需要手工编辑配置文件。 +3. 如需无托盘运行,仍可复制 `docs/webui-mcp-config.example.json` 后使用: + +```powershell +WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json +``` + +`credentials.json` 只保存 SHA-256 大写十六进制摘要,例如: ```json [{"PrincipalId":"local-read","TokenSha256":"<64-hex-sha256>","Permissions":["read"],"AccountIds":[]}] @@ -11,17 +18,9 @@ `AccountIds: []` 不授予任何显式数据库账号范围;需要联系人/群成员等账号范围调用时,必须填入已验证的 account fingerprint。凭据文件应使用当前用户 ACL,禁止提交仓库。 -3. 复制 `docs/webui-mcp-config.example.json`,修改本机路径后启动: +服务运行后使用托盘菜单中的“服务设置...”配置监听地址、端口、远程访问、队列和监听事件;使用“访问凭据...”新建或撤销 Token,不需要手工编辑 `service.json`/`credentials.json`。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口。Host/Origin(CORS)限制不再校验;HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 -```powershell -WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json -``` - -需要正常安装、登录后自动启动、创建桌面/开始菜单快捷方式并常驻托盘时,运行发布包中的 `WxAgent-Setup.exe`。安装器会创建当前用户的登录启动项,不需要 PowerShell 或管理员权限;首次启动托盘程序时生成本地管理 Token,并在一次性窗口中提供复制按钮。 - -服务运行后可在托盘菜单中打开控制台、打开 `service.json`、停止/启动服务或重新加载配置。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 - -浏览器访问 `/`,输入 Token 登录。HTTP/MCP 客户端使用: +远程浏览器访问 `/` 后输入 Token 登录。HTTP/MCP 客户端使用: ```text Authorization: Bearer @@ -31,7 +30,7 @@ MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ## 凭据更换与撤销 -原子替换凭据文件并保留相同 `PrincipalId` 可保留幂等记录;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。 +在托盘“访问凭据...”中创建新 Token 或撤销旧 Token。明文 Token 只在创建时显示一次;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。 ## 多账号显式绑定 diff --git a/docs/WebUI-MCP-功能矩阵.md b/docs/WebUI-MCP-功能矩阵.md index 4cd1169..19c6d24 100644 --- a/docs/WebUI-MCP-功能矩阵.md +++ b/docs/WebUI-MCP-功能矩阵.md @@ -19,7 +19,7 @@ ## 运行边界 -- 通过 `WxAgent.Host serve --config ` 启动;默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。 -- HTTP API、Streamable HTTP MCP (`/mcp`) 和浏览器登录共用 Bearer Token;Token 只放 `Authorization`,不放 URL。 -- 浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。 +- 可通过 `WxAgent.Host serve --config ` 启动;安装版使用托盘程序,服务设置和 Token 管理均通过系统 UI 完成。默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。 +- 本机回环访问 Web UI、HTTP API 和 Streamable HTTP MCP (`/mcp`) 不需要 Token;远程访问仍使用 Bearer Token,Token 只放 `Authorization`,不放 URL。 +- 远程浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。 - 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。 diff --git a/docs/WebUI-MCP-开发计划.md b/docs/WebUI-MCP-开发计划.md index bc551f1..32474a0 100644 --- a/docs/WebUI-MCP-开发计划.md +++ b/docs/WebUI-MCP-开发计划.md @@ -164,10 +164,10 @@ Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed ### 5.1 本机与外部入口鉴权 -1. 默认绑定 `127.0.0.1`/`::1`;允许显式配置非回环 IP、`0.0.0.0`/`::`、端口和允许的访问域名。外部 HTTP 监听必须先配置有效 Token,否则启动失败;不自动开放防火墙。不要求 HTTPS,不开发证书管理及反向代理适配。 -2. HTTP API 与 MCP 共用高熵 Token、principalId、权限和撤销机制,同一 Token 可以调用两种协议。直接客户端使用 `Authorization: Bearer `,不得将 Token 放入 URL、查询参数、日志或 MCP session ID。Token 首次生成/配置及授权在 Windows 主机本地完成,文件由当前用户 ACL 保护;不通过匿名网络接口生成或提权。 -3. 本机和外部 Web UI 统一输入 Token 登录,不实现配对码。只匿名提供无业务数据的登录页及静态资源;浏览器通过 POST 换取短期 `HttpOnly`、`SameSite=Strict` 会话 Cookie,随后清空输入,不把 Token 写入 localStorage/sessionStorage 或持久缓存。明文 HTTP 下 Cookie 不设置 Secure,因此不具备传输保密性。会话继承 Token 身份和权限;SSE 使用该 Cookie,写请求另校验 CSRF。API/MCP 无 Token 或有效派生会话时拒绝,错误/状态接口不得匿名泄露环境信息。 -4. Token 登录失败限流;校验配置的 Host 与精确 Origin 白名单,不设置通配 CORS。原生 MCP/HTTP 客户端可无 Origin,但仍须 Bearer 认证;有 Origin 则必须通过检查。本机专用入口仅接受直接回环连接,不信任客户端自报 Host、Origin 或 X-Forwarded-For 作为来源证明。 +1. 默认绑定 `127.0.0.1`/`::1`;通过托盘“服务设置...”配置非回环 IP、端口和远程访问,不自动开放防火墙。不要求 HTTPS,不开发证书管理及反向代理适配。 +2. HTTP API 与 MCP 共用高熵 Token、principalId、权限和撤销机制,同一 Token 可以调用两种协议。直接客户端使用 `Authorization: Bearer `,不得将 Token 放入 URL、查询参数、日志或 MCP session ID。Token 的生成、撤销和服务设置均通过 Windows 托盘系统 UI 完成;凭据文件由当前用户 ACL 保护。 +3. 本机回环 Web UI/API/MCP 不需要 Token,服务仅信任操作系统提供的直接回环地址;远程 Web UI 仍输入 Token 登录。远程浏览器通过 POST 换取短期 `HttpOnly`、`SameSite=Strict` 会话 Cookie,随后清空输入,不把 Token 写入 localStorage/sessionStorage 或持久缓存。明文 HTTP 下 Cookie 不设置 Secure,因此不具备传输保密性。会话继承 Token 身份和权限;SSE 使用该 Cookie,写请求另校验 CSRF。 +4. Token 登录失败限流;不校验 Host/Origin,客户端可不提供 Origin。远程请求仍必须通过 Bearer 或有效派生会话;本机专用入口仅接受直接回环连接,不信任客户端自报 X-Forwarded-For 作为来源证明。 5. 首版权限区分只读、内容读取、普通写、管理写和本机诊断管理;凭据默认只读。共享 Token 即共享身份,不能隔离共享者;需要隔离时配置不同身份的 Token。密钥扫描、保存等本机专用动作不能因外部 Token 有管理权限而开放。 6. 更换或撤销 Token 立即使旧 Token、派生 Cookie 和 MCP 会话授权失效,关闭相关 SSE/事件流,不实现新旧 Token 重叠窗口;稳定身份和幂等记录保留。每次资源访问及每个事件输出批次检查授权;执行及实际提交副作用前重新检查。未执行任务失败且不执行;已提交任务保留真实/不确定结果,不承诺撤销微信动作。 @@ -229,7 +229,7 @@ Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed 1. 增加 serve 分支和最小 HTTP Host,CLI 行为保持兼容。 2. 提取必要共享处理逻辑,使 REST、MCP 不复制参数校验和微信操作实现。 3. 接入有界队列、现有门禁、超时取消和任务记录,核查库 API 未受门禁保护的路径。 -4. 实现默认回环及显式外部 HTTP 监听、HTTP/MCP 共用 Token、统一浏览器 Token 登录、权限、CSRF、Host/Origin 检查和脱敏日志。 +4. 实现默认回环及显式外部 HTTP 监听、HTTP/MCP 共用 Token、本机免 Token、远程 Token 登录、权限、CSRF 和脱敏日志。 5. 先接 status、capabilities、operations 查询和取消。 验收: @@ -340,7 +340,7 @@ Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed 2. Linux restore/test/build/publish 后上传 Windows,同一已登录用户会话启动 serve。 3. 执行 doctor/inspect-ui/smoke 及本期实际接入功能回归,记录 Windows/微信/浏览器/MCP 客户端版本。 4. 验证关闭、崩溃重启、凭据撤销、端口冲突、锁屏、微信退出及版本不支持路径。 -5. 从另一主机验证外部 HTTP Web UI、HTTP API 和 MCP:同一 Token 可访问,缺失/错误/撤销 Token 被拒绝,本机专用入口不可访问;验证 Host/Origin、限流和防火墙部署要求,不以 localhost 自测代替。 +5. 从另一主机验证外部 HTTP Web UI、HTTP API 和 MCP:同一 Token 可访问,缺失/错误/撤销 Token 被拒绝,本机专用入口不可访问;验证限流和防火墙部署要求,不以 localhost 自测代替。 6. 输出使用说明、HTTP/MCP 共用 Token 配置样例(只用占位值)、显式监听配置、明文风险提示、凭据更换、回滚步骤及本轮验收报告。 验收: diff --git a/docs/validation/WebUI-MCP-2026-09-07.md b/docs/validation/WebUI-MCP-2026-09-07.md index 99b8d10..dda4bef 100644 --- a/docs/validation/WebUI-MCP-2026-09-07.md +++ b/docs/validation/WebUI-MCP-2026-09-07.md @@ -27,8 +27,8 @@ ## 安全/失败路径 -- 无 Bearer Token:401。 -- 非 allowlist Host/Origin:403。 +- 回环本机无 Bearer Token:允许直接访问;远程无 Bearer Token:401。 +- Host/Origin 不再作为认证条件;远程仍必须通过 Bearer Token,URL 查询中的 token 仍拒绝。 - URL 查询中出现 token 参数:400,拒绝凭据泄漏路径。 - 旧 Token 轮换后:旧 Bearer 与浏览器 Cookie 均 401;身份与幂等记录保留。 - 写能力:保持 disabled;当前服务不模拟成功、不执行微信写入。 diff --git a/docs/webui-mcp-config.example.json b/docs/webui-mcp-config.example.json index 88aae31..66ec4be 100644 --- a/docs/webui-mcp-config.example.json +++ b/docs/webui-mcp-config.example.json @@ -1,8 +1,6 @@ { "ListenUrl": "http://127.0.0.1:5088", "AllowExternal": false, - "AllowedHosts": ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"], - "AllowedOrigins": ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"], "CredentialFile": "C:/Users/USERNAME/wx-agent/credentials.json", "DataDirectory": "C:/Users/USERNAME/wx-agent/data", "QueueCapacity": 100, diff --git a/installer/WxAgent.iss b/installer/WxAgent.iss index 878c806..7ce7381 100644 --- a/installer/WxAgent.iss +++ b/installer/WxAgent.iss @@ -6,6 +6,7 @@ AppId={{8B2EAF78-2B11-4E55-9C39-7A6E4E7A4B7D} AppName=WxAgent AppVersion={#AppVersion} AppPublisher=WxAgent +SetupIconFile={#PublishRoot}\wxagent.ico DefaultDirName={localappdata}\WxAgent DefaultGroupName=WxAgent OutputDir=..\artifacts\setup @@ -25,6 +26,7 @@ Uninstallable=yes Source: "{#PublishRoot}\WxAgent.Tray.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PublishRoot}\WxAgent.Host.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PublishRoot}\wwwroot\*"; DestDir: "{app}\wwwroot"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#PublishRoot}\wxagent.ico"; DestDir: "{app}"; Flags: ignoreversion [Tasks] Name: "autostart"; Description: "登录 Windows 后自动启动 WxAgent"; GroupDescription: "启动选项:"; Flags: checkedonce diff --git a/src/WxAgent.Service/ServiceOptions.cs b/src/WxAgent.Service/ServiceOptions.cs index 296ae01..4f55ff8 100644 --- a/src/WxAgent.Service/ServiceOptions.cs +++ b/src/WxAgent.Service/ServiceOptions.cs @@ -26,11 +26,6 @@ public sealed class ServiceOptions throw new ArgumentException("ListenUrl must be an explicit HTTP IP address and port."); if (!IPAddress.IsLoopback(address) && !AllowExternal) throw new ArgumentException("External HTTP requires AllowExternal; use a trusted isolated network."); - if (AllowedHosts.Length == 0 || AllowedOrigins.Length == 0 || - AllowedHosts.Any(h => h.Contains('*') || h.Contains('/')) || - AllowedOrigins.Any(o => !Uri.TryCreate(o, UriKind.Absolute, out var origin) || - origin.Scheme != "http" || origin.GetLeftPart(UriPartial.Authority) != o)) - throw new ArgumentException("Explicit Host and exact Origin allowlists are required."); if (QueueCapacity is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(QueueCapacity)); if (string.IsNullOrWhiteSpace(ListenerSession) || ListenerSession.Length > 200) throw new ArgumentException("ListenerSession must be a bounded nonempty name."); _ = ReadCredentials(); @@ -55,10 +50,10 @@ public sealed class ServiceOptions public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); } -public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds) +public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds, bool LocalOnly = false) { public bool Allows(string permission) => Permissions.Contains(permission, StringComparer.Ordinal); - public bool AllowsAccount(string accountId) => AccountIds.Contains(accountId, StringComparer.Ordinal); + public bool AllowsAccount(string accountId) => LocalOnly || AccountIds.Contains(accountId, StringComparer.Ordinal); } public sealed class ServiceException(string code, int statusCode, string message) : Exception(message) diff --git a/src/WxAgent.Service/ServiceSecurity.cs b/src/WxAgent.Service/ServiceSecurity.cs index 246fbed..e7f49a5 100644 --- a/src/WxAgent.Service/ServiceSecurity.cs +++ b/src/WxAgent.Service/ServiceSecurity.cs @@ -7,6 +7,7 @@ namespace WxAgent.Service; public sealed class ServiceSecurity(ServiceOptions options) { + private static readonly ServiceIdentity LocalIdentity = new("local", "local", ["read", "content", "manage", "local-admin"], [], true); private sealed record BrowserSession(ServiceIdentity Identity, string Csrf, DateTimeOffset Expires); private readonly ConcurrentDictionary sessions = new(); private readonly object loginGate = new(); @@ -24,6 +25,14 @@ public sealed class ServiceSecurity(ServiceOptions options) public ServiceIdentity RequireCurrent(ServiceIdentity original, string? permission = null, string? accountId = null) { + if (original.LocalOnly) + { + if (permission is not null && !original.Allows(permission)) + throw new ServiceException("Forbidden", 403, "Permission required."); + if (accountId is not null && !original.AllowsAccount(accountId)) + throw new ServiceException("Forbidden", 403, "Account access denied."); + return original; + } var credential = ReadCredentialsSafely().SingleOrDefault(c => c.PrincipalId == original.PrincipalId && EqualHash(c.TokenSha256, original.CredentialHash)); if (credential is null) throw new ServiceException("AuthorizationRevoked", 401, "Credential expired or revoked."); @@ -47,11 +56,7 @@ public sealed class ServiceSecurity(ServiceOptions options) public void ValidateSource(HttpContext context) { - if (!options.AllowedHosts.Contains(context.Request.Host.Value, StringComparer.OrdinalIgnoreCase)) - throw new ServiceException("InvalidHost", 403, "Host not allowed."); - var origin = context.Request.Headers.Origin; - if (origin.Count != 0 && (origin.Count != 1 || !options.AllowedOrigins.Contains(origin[0], StringComparer.Ordinal))) - throw new ServiceException("InvalidOrigin", 403, "Origin not allowed."); + // Host/Origin filtering is intentionally disabled; authentication and the optional loopback-only mode are the boundary. } public static void RequireLocal(HttpContext context) @@ -70,7 +75,12 @@ public sealed class ServiceSecurity(ServiceOptions options) throw new ServiceException("Unauthorized", 401, "Valid Bearer credential required."); } if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session)) + { + if (IsLoopback(context)) return LocalIdentity; throw new ServiceException("Unauthorized", 401, "Login required."); + } + if (session.Identity.LocalOnly && !IsLoopback(context)) + throw new ServiceException("Unauthorized", 401, "Local session cannot be used remotely."); if (session.Expires <= DateTimeOffset.UtcNow) { sessions.TryRemove(id, out _); @@ -78,9 +88,8 @@ public sealed class ServiceSecurity(ServiceOptions options) } var current = RequireCurrent(session.Identity); if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) && - (context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf || - !options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal))) - throw new ServiceException("CsrfRejected", 403, "CSRF token and same-origin request required."); + context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf) + throw new ServiceException("CsrfRejected", 403, "CSRF token required."); return current; } @@ -93,8 +102,6 @@ public sealed class ServiceSecurity(ServiceOptions options) { loginWindow = DateTimeOffset.UtcNow; loginAttempts = 0; } if (++loginAttempts > 10) throw new ServiceException("RateLimited", 429, "Wait before attempting login again."); } - if (!options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal)) - throw new ServiceException("InvalidOrigin", 403, "Login requires an allowed Origin."); var identity = AuthenticateToken(token) ?? throw new ServiceException("Unauthorized", 401, "Invalid credential."); foreach (var entry in sessions.Where(s => s.Value.Expires <= DateTimeOffset.UtcNow)) sessions.TryRemove(entry.Key, out _); if (sessions.Count >= 100) throw new ServiceException("RateLimited", 429, "Browser session limit reached."); @@ -107,6 +114,9 @@ public sealed class ServiceSecurity(ServiceOptions options) return new { identity.PrincipalId, identity.Permissions, identity.AccountIds, csrfToken = csrf, expires }; } + private static bool IsLoopback(HttpContext context) => + context.Connection.RemoteIpAddress is { } address && IPAddress.IsLoopback(address); + public void Logout(HttpContext context) { if (context.Request.Cookies.TryGetValue(CookieName, out var id)) sessions.TryRemove(id, out _); diff --git a/src/WxAgent.Service/wwwroot/app.js b/src/WxAgent.Service/wwwroot/app.js index e50b6ad..1441118 100644 --- a/src/WxAgent.Service/wwwroot/app.js +++ b/src/WxAgent.Service/wwwroot/app.js @@ -38,7 +38,7 @@ async function get(path) { async function mutate(path, body) { return api(path, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, Origin: location.origin }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, body: JSON.stringify(body) }); } @@ -226,7 +226,7 @@ $('loginForm').addEventListener('submit', async event => { button.textContent = '登录中…'; clearError(); try { - const response = await api('/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: location.origin }, body: JSON.stringify({ token: $('token').value }) }); + const response = await api('/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: $('token').value }) }); csrf = response.csrfToken; $('token').value = ''; $('login').hidden = true; @@ -248,6 +248,15 @@ $('lookup').onclick = async () => { catch (error) { showError(error); } }; $('logout').onclick = async () => { - try { await api('/api/v1/logout', { method: 'POST', headers: { 'X-CSRF-Token': csrf, Origin: location.origin } }); } + try { await api('/api/v1/logout', { method: 'POST', headers: { 'X-CSRF-Token': csrf } }); } finally { if (refreshTimer) window.clearInterval(refreshTimer); $('app').hidden = true; $('login').hidden = false; $('connectionBadge').textContent = '未连接'; $('connectionBadge').className = 'badge neutral'; } }; + +(async () => { + const local = await get('/api/v1/status'); + if (local.ok) { + $('login').hidden = true; + $('app').hidden = false; + await refresh(); + } +})(); diff --git a/src/WxAgent.Service/wwwroot/index.html b/src/WxAgent.Service/wwwroot/index.html index 1bb38d4..d14e235 100644 --- a/src/WxAgent.Service/wwwroot/index.html +++ b/src/WxAgent.Service/wwwroot/index.html @@ -15,8 +15,8 @@

HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。