diff --git a/WxAgent.sln b/WxAgent.sln
index d88af38..3571dbf 100644
--- a/WxAgent.sln
+++ b/WxAgent.sln
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Service", "src\WxAg
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Service.Tests", "tests\WxAgent.Service.Tests\WxAgent.Service.Tests.csproj", "{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Tray", "src\WxAgent.Tray\WxAgent.Tray.csproj", "{FCDBB182-E1EB-44C4-8DB8-20BF2913E929}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -52,6 +54,10 @@ Global
{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FCDBB182-E1EB-44C4-8DB8-20BF2913E929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FCDBB182-E1EB-44C4-8DB8-20BF2913E929}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FCDBB182-E1EB-44C4-8DB8-20BF2913E929}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FCDBB182-E1EB-44C4-8DB8-20BF2913E929}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{EDC68F82-2F67-40A5-B6CA-B4D26349E988} = {11E9B433-75E3-446D-B8FF-958A0F541BA5}
@@ -60,5 +66,6 @@ Global
{1F24FB68-6917-4984-89FC-5F9C64E2965D} = {CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9}
{8280C39E-328E-4321-8D96-238405B5F19D} = {11E9B433-75E3-446D-B8FF-958A0F541BA5}
{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB} = {CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9}
+ {FCDBB182-E1EB-44C4-8DB8-20BF2913E929} = {11E9B433-75E3-446D-B8FF-958A0F541BA5}
EndGlobalSection
EndGlobal
diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md
index f6552ab..ee8ac7e 100644
--- a/docs/WebUI-MCP-使用说明.md
+++ b/docs/WebUI-MCP-使用说明.md
@@ -17,14 +17,9 @@
WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json
```
-需要安装为当前用户登录后自动启动、创建桌面快捷方式并打开控制台时,使用发布目录中的脚本:
+需要正常安装、登录后自动启动、创建桌面/开始菜单快捷方式并常驻托盘时,运行发布包中的 `WxAgent-Setup.exe`。安装器会创建当前用户的登录启动项,不需要 PowerShell 或管理员权限;首次启动托盘程序时生成本地管理 Token,并在一次性窗口中提供复制按钮。
-```powershell
-Set-ExecutionPolicy -Scope Process Bypass
-.\scripts\windows\Install-WxAgent.ps1 -InstallRoot C:\Users\USERNAME\wx-agent -Start -OpenBrowser -PreventAutoLock
-```
-
-脚本不会覆盖已有 `credentials.json` 或 `service.json`;首次安装且没有凭据时生成一次本地管理 Token,仅在终端显示、不写入明文文件。`-PreventAutoLock` 会关闭当前电源计划的自动息屏/睡眠/休眠和屏保,并设置 Windows 锁屏策略;不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。
+服务运行后可在托盘菜单中打开控制台、打开 `service.json`、停止/启动服务或重新加载配置。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。
浏览器访问 `/`,输入 Token 登录。HTTP/MCP 客户端使用:
diff --git a/docs/WxAgent-CSharp-开发计划.md b/docs/WxAgent-CSharp-开发计划.md
index 7af327b..d971efd 100644
--- a/docs/WxAgent-CSharp-开发计划.md
+++ b/docs/WxAgent-CSharp-开发计划.md
@@ -55,7 +55,8 @@ WxAgent.sln
├── src/
│ ├── WxAgent.Core/ # net8.0,模型、解析、去重、状态机、业务用例
│ ├── WxAgent.Windows/ # net8.0-windows,FlaUI/UIA/Win32 实现
-│ └── WxAgent.Host/ # net8.0-windows,Desktop Agent 和 CLI
+│ ├── WxAgent.Host/ # net8.0-windows,Desktop Agent 和 CLI
+│ └── WxAgent.Tray/ # net8.0-windows,托盘常驻、配置和服务生命周期
└── tests/
└── WxAgent.Core.Tests/ # net8.0,可在 Linux 执行
```
@@ -127,6 +128,8 @@ dotnet publish src/WxAgent.Host \
```text
src/WxAgent.Host/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/
+src/WxAgent.Tray/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/
+installer/WxAgent.iss → WxAgent-Setup.exe
```
### 5.3 Windows 真机验证流程
diff --git a/installer/WxAgent.iss b/installer/WxAgent.iss
new file mode 100644
index 0000000..878c806
--- /dev/null
+++ b/installer/WxAgent.iss
@@ -0,0 +1,48 @@
+#define AppVersion "0.1.0"
+#define PublishRoot "..\artifacts\installer-publish"
+
+[Setup]
+AppId={{8B2EAF78-2B11-4E55-9C39-7A6E4E7A4B7D}
+AppName=WxAgent
+AppVersion={#AppVersion}
+AppPublisher=WxAgent
+DefaultDirName={localappdata}\WxAgent
+DefaultGroupName=WxAgent
+OutputDir=..\artifacts\setup
+OutputBaseFilename=WxAgent-Setup
+Compression=lzma2
+SolidCompression=yes
+WizardStyle=modern
+PrivilegesRequired=lowest
+ArchitecturesAllowed=x64compatible
+ArchitecturesInstallIn64BitMode=x64compatible
+DisableProgramGroupPage=yes
+CloseApplications=yes
+RestartApplications=no
+Uninstallable=yes
+
+[Files]
+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
+
+[Tasks]
+Name: "autostart"; Description: "登录 Windows 后自动启动 WxAgent"; GroupDescription: "启动选项:"; Flags: checkedonce
+Name: "preventautolock"; Description: "防止自动息屏、睡眠、休眠和锁屏"; GroupDescription: "会话选项:"; Flags: unchecked
+
+[Icons]
+Name: "{autoprograms}\WxAgent"; Filename: "{app}\WxAgent.Tray.exe"; Parameters: "--config ""{app}\service.json"""; WorkingDir: "{app}"
+Name: "{userdesktop}\WxAgent"; Filename: "{app}\WxAgent.Tray.exe"; Parameters: "--config ""{app}\service.json"""; WorkingDir: "{app}"
+Name: "{userstartup}\WxAgent"; Filename: "{app}\WxAgent.Tray.exe"; Parameters: "--config ""{app}\service.json"""; WorkingDir: "{app}"; Tasks: autostart
+
+[Run]
+Filename: "{sys}\schtasks.exe"; Parameters: "/delete /tn ""WxAgent"" /f"; Flags: runhidden waituntilterminated
+Filename: "{sys}\taskkill.exe"; Parameters: "/IM WxAgent.Host.exe /F"; Flags: runhidden waituntilterminated
+Filename: "{sys}\taskkill.exe"; Parameters: "/IM WxAgent.Tray.exe /F"; Flags: runhidden waituntilterminated
+Filename: "{app}\WxAgent.Tray.exe"; Parameters: "--prevent-auto-lock --exit"; WorkingDir: "{app}"; Description: "配置会话防止自动锁屏"; Flags: runhidden waituntilterminated; Tasks: preventautolock
+Filename: "{app}\WxAgent.Tray.exe"; Parameters: "--config ""{app}\service.json"""; WorkingDir: "{app}"; Description: "启动 WxAgent 托盘程序"; Flags: nowait postinstall skipifsilent
+
+[UninstallRun]
+Filename: "{sys}\schtasks.exe"; Parameters: "/delete /tn ""WxAgent"" /f"; Flags: runhidden waituntilterminated; RunOnceId: "DeleteWxAgentTask"
+Filename: "{sys}\taskkill.exe"; Parameters: "/IM WxAgent.Tray.exe /F"; Flags: runhidden; RunOnceId: "StopWxAgentTray"
+Filename: "{sys}\taskkill.exe"; Parameters: "/IM WxAgent.Host.exe /F"; Flags: runhidden; RunOnceId: "StopWxAgentHost"
diff --git a/scripts/windows/Install-WxAgent.ps1 b/scripts/windows/Install-WxAgent.ps1
deleted file mode 100644
index 628edb9..0000000
--- a/scripts/windows/Install-WxAgent.ps1
+++ /dev/null
@@ -1,120 +0,0 @@
-[CmdletBinding()]
-param(
- [string]$InstallRoot = (Join-Path $env:USERPROFILE 'wx-agent'),
- [string]$TaskName = 'WxAgent',
- [switch]$Start,
- [switch]$OpenBrowser,
- [switch]$PreventAutoLock,
- [string]$Token
-)
-
-$ErrorActionPreference = 'Stop'
-$exe = Join-Path $InstallRoot 'WxAgent.Host.exe'
-$config = Join-Path $InstallRoot 'service.json'
-$credentials = Join-Path $InstallRoot 'credentials.json'
-$data = Join-Path $InstallRoot 'data'
-$url = 'http://127.0.0.1:5088/'
-
-if (-not (Test-Path $exe)) { throw "WxAgent.Host.exe was not found under $InstallRoot. Publish/copy the application first." }
-if ($TaskName -notmatch '^[A-Za-z0-9 ._-]{1,64}$') { throw 'TaskName contains unsupported characters.' }
-New-Item -ItemType Directory -Force -Path $data | Out-Null
-
-if ($PreventAutoLock) {
- powercfg /change monitor-timeout-ac 0
- powercfg /change standby-timeout-ac 0
- powercfg /change hibernate-timeout-ac 0
- powercfg /change monitor-timeout-dc 0
- powercfg /change standby-timeout-dc 0
- powercfg /change hibernate-timeout-dc 0
- powercfg /setacvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 0
- powercfg /setdcvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 0
- powercfg /setactive SCHEME_CURRENT
- New-Item -Path 'HKCU:\Control Panel\Desktop' -Force | Out-Null
- Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value 0
- Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeout -Value 0
- New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Force | Out-Null
- Set-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Name NoLockScreen -Type DWord -Value 1
-}
-
-function Write-Utf8Json([string]$Path, $Value) {
- $utf8 = New-Object System.Text.UTF8Encoding($false)
- [IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 8), $utf8)
-}
-
-function To-Hex([byte[]]$Bytes) { [BitConverter]::ToString($Bytes).Replace('-', '') }
-
-$generatedToken = $null
-if (-not (Test-Path $credentials)) {
- if ([string]::IsNullOrWhiteSpace($Token)) {
- $bytes = New-Object byte[] 32
- $rng = [Security.Cryptography.RandomNumberGenerator]::Create()
- try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
- $generatedToken = (To-Hex $bytes).ToLowerInvariant()
- $Token = $generatedToken
- }
- $sha = [Security.Cryptography.SHA256]::Create()
- try { $hash = To-Hex ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Token))).ToUpperInvariant() }
- finally { $sha.Dispose() }
- Write-Utf8Json $credentials @(@{
- PrincipalId = 'local-admin'
- TokenSha256 = $hash
- Permissions = @('read', 'content', 'manage')
- AccountIds = @()
- })
-}
-
-if (-not (Test-Path $config)) {
- Write-Utf8Json $config @{
- 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 = $credentials
- DataDirectory = $data
- QueueCapacity = 100
- ListenerSession = '文件传输助手'
- EnableListenerEvents = $false
- }
-}
-
-$user = $env:USERNAME
-$action = New-ScheduledTaskAction -Execute $exe -Argument ('serve --config "{0}"' -f $config) -WorkingDirectory $InstallRoot
-$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
-$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
-$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)
-Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
-
-$desktop = [Environment]::GetFolderPath('Desktop')
-$shell = New-Object -ComObject WScript.Shell
-$startShortcut = Join-Path $desktop 'WxAgent-Start.lnk'
-$link = $shell.CreateShortcut($startShortcut)
-$link.TargetPath = Join-Path $env:SystemRoot 'System32\schtasks.exe'
-$link.Arguments = "/run /tn `"$TaskName`""
-$link.WorkingDirectory = $InstallRoot
-$link.Description = 'Start WxAgent Desktop Agent'
-$link.Save()
-
-$openShortcut = Join-Path $desktop 'WxAgent.lnk'
-$link = $shell.CreateShortcut($openShortcut)
-$link.TargetPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
-$command = "Start-ScheduledTask -TaskName '$TaskName'; Start-Sleep -Seconds 2; Start-Process '$url'"
-$link.Arguments = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$command`""
-$link.WorkingDirectory = $InstallRoot
-$link.Description = 'Start WxAgent and open the web console'
-$link.Save()
-
-$urlShortcut = Join-Path $desktop 'WxAgent-Web.url'
-[IO.File]::WriteAllText($urlShortcut, "[InternetShortcut]`r`nURL=$url`r`n", [Text.Encoding]::ASCII)
-
-if ($Start) { Start-ScheduledTask -TaskName $TaskName }
-if ($OpenBrowser) { Start-Process $url }
-
-[pscustomobject]@{
- TaskName = $TaskName
- InstallRoot = $InstallRoot
- Url = $url
- Started = [bool]$Start
- PreventAutoLock = [bool]$PreventAutoLock
- TokenGenerated = [bool]$generatedToken
- Token = $generatedToken
-} | Format-List
diff --git a/src/WxAgent.Host/WindowsAgentBackend.cs b/src/WxAgent.Host/WindowsAgentBackend.cs
index 8368b16..ce3ec07 100644
--- a/src/WxAgent.Host/WindowsAgentBackend.cs
+++ b/src/WxAgent.Host/WindowsAgentBackend.cs
@@ -4,7 +4,7 @@ using WxAgent.Windows;
namespace WxAgent.Host;
-internal sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
+public sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
{
private readonly SemaphoreSlim bindingGate = new(1, 1);
diff --git a/src/WxAgent.Host/WxAgent.Host.csproj b/src/WxAgent.Host/WxAgent.Host.csproj
index adc42e4..8cd4719 100644
--- a/src/WxAgent.Host/WxAgent.Host.csproj
+++ b/src/WxAgent.Host/WxAgent.Host.csproj
@@ -16,6 +16,5 @@
-
diff --git a/src/WxAgent.Tray/Program.cs b/src/WxAgent.Tray/Program.cs
new file mode 100644
index 0000000..e6d3b6c
--- /dev/null
+++ b/src/WxAgent.Tray/Program.cs
@@ -0,0 +1,296 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Windows.Forms;
+using Microsoft.AspNetCore.Builder;
+using WxAgent.Host;
+using WxAgent.Service;
+
+namespace WxAgent.Tray;
+
+internal static class Program
+{
+ [STAThread]
+ private static void Main(string[] args)
+ {
+ if (args.Contains("--prevent-auto-lock", StringComparer.OrdinalIgnoreCase))
+ {
+ PowerPolicy.Apply();
+ if (args.Contains("--exit", StringComparer.OrdinalIgnoreCase)) return;
+ }
+ ApplicationConfiguration.Initialize();
+ var configPath = GetOption(args, "--config") ?? Path.Combine(AppContext.BaseDirectory, "service.json");
+ using var context = new TrayApplicationContext(Path.GetFullPath(configPath));
+ Application.Run(context);
+ }
+
+ private static string? GetOption(string[] args, string name)
+ {
+ for (var index = 0; index < args.Length - 1; index++)
+ if (args[index] == name) return args[index + 1];
+ return null;
+ }
+}
+
+internal static class PowerPolicy
+{
+ public static void Apply()
+ {
+ foreach (var arguments in new[]
+ {
+ "/change monitor-timeout-ac 0", "/change standby-timeout-ac 0", "/change hibernate-timeout-ac 0",
+ "/change monitor-timeout-dc 0", "/change standby-timeout-dc 0", "/change hibernate-timeout-dc 0",
+ "/setacvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 0", "/setdcvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 0",
+ "/setactive SCHEME_CURRENT"
+ })
+ {
+ using var process = Process.Start(new ProcessStartInfo("powercfg.exe", arguments) { CreateNoWindow = true, UseShellExecute = false });
+ process?.WaitForExit();
+ }
+ using var desktop = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(@"Control Panel\Desktop");
+ desktop?.SetValue("ScreenSaveActive", "0");
+ desktop?.SetValue("ScreenSaveTimeout", "0");
+ try
+ {
+ using var policy = Microsoft.Win32.Registry.LocalMachine.CreateSubKey(@"SOFTWARE\Policies\Microsoft\Windows\Personalization");
+ policy?.SetValue("NoLockScreen", 1, Microsoft.Win32.RegistryValueKind.DWord);
+ }
+ catch (UnauthorizedAccessException) { }
+ }
+}
+
+internal sealed class TrayApplicationContext : ApplicationContext
+{
+ private readonly string configPath;
+ private readonly Mutex instanceMutex;
+ private readonly NotifyIcon trayIcon;
+ private readonly ToolStripMenuItem serviceToggle;
+ private readonly ToolStripMenuItem reload;
+ private WebApplication? service;
+ private ServiceOptions? options;
+ private bool exiting;
+
+ public TrayApplicationContext(string configPath)
+ {
+ this.configPath = configPath;
+ instanceMutex = new Mutex(true, "Local\\WxAgent.Tray", out var created);
+ if (!created)
+ {
+ instanceMutex.Dispose();
+ MessageBox.Show("WxAgent 已经在运行。", "WxAgent", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ throw new InvalidOperationException("Another WxAgent tray instance is already running.");
+ }
+
+ serviceToggle = new ToolStripMenuItem("启动服务", null, async (_, _) => await ToggleServiceAsync());
+ reload = new ToolStripMenuItem("重新加载配置", null, async (_, _) => await ReloadAsync());
+ var menu = new ContextMenuStrip();
+ var status = new ToolStripMenuItem("正在启动…") { Enabled = false };
+ menu.Items.Add(status);
+ menu.Items.Add(new ToolStripSeparator());
+ menu.Items.Add(new ToolStripMenuItem("打开控制台", null, (_, _) => OpenConsole()));
+ menu.Items.Add(serviceToggle);
+ menu.Items.Add(reload);
+ menu.Items.Add(new ToolStripMenuItem("打开服务配置", null, (_, _) => OpenConfig()));
+ menu.Items.Add(new ToolStripSeparator());
+ menu.Items.Add(new ToolStripMenuItem("退出", null, async (_, _) => await ExitAsync()));
+
+ trayIcon = new NotifyIcon
+ {
+ Icon = SystemIcons.Application,
+ Text = "WxAgent",
+ Visible = true,
+ ContextMenuStrip = menu
+ };
+ trayIcon.DoubleClick += (_, _) => OpenConsole();
+ try
+ {
+ EnsureConfiguration();
+ StartServiceAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception exception)
+ {
+ SetStatus($"启动失败: {exception.Message}");
+ trayIcon.ShowBalloonTip(5000, "WxAgent 启动失败", exception.Message, ToolTipIcon.Error);
+ }
+ }
+
+ private ToolStripMenuItem StatusItem => (ToolStripMenuItem)trayIcon.ContextMenuStrip!.Items[0];
+
+ private void EnsureConfiguration()
+ {
+ var directory = Path.GetDirectoryName(configPath)!;
+ Directory.CreateDirectory(directory);
+ if (!File.Exists(configPath))
+ {
+ WriteJson(configPath, new ServiceOptions
+ {
+ CredentialFile = Path.Combine(directory, "credentials.json"),
+ DataDirectory = Path.Combine(directory, "data")
+ });
+ }
+
+ var loaded = ReadOptions();
+ options = loaded;
+ Directory.CreateDirectory(loaded.DataDirectory);
+ if (!File.Exists(loaded.CredentialFile))
+ {
+ var token = CreateCredentials(loaded.CredentialFile);
+ ShowToken(token);
+ }
+ }
+
+ private ServiceOptions ReadOptions() =>
+ JsonSerializer.Deserialize(File.ReadAllText(configPath), ServiceHost.Json)
+ ?? throw new InvalidDataException("service.json is empty.");
+
+ private async Task StartServiceAsync()
+ {
+ if (service is not null) return;
+ var loaded = ReadOptions();
+ loaded.Validate();
+ options = loaded;
+ service = ServiceHost.Build(loaded, new WindowsAgentBackend(loaded, new AccountBindingStore(loaded)));
+ try
+ {
+ await service.StartAsync();
+ SetStatus("服务运行中");
+ serviceToggle.Text = "停止服务";
+ reload.Enabled = true;
+ }
+ catch
+ {
+ await service.DisposeAsync();
+ service = null;
+ throw;
+ }
+ }
+
+ private async Task StopServiceAsync()
+ {
+ var current = service;
+ service = null;
+ if (current is null) return;
+ try { await current.StopAsync(); }
+ finally { await current.DisposeAsync(); }
+ SetStatus("服务已停止");
+ serviceToggle.Text = "启动服务";
+ }
+
+ private async Task ToggleServiceAsync()
+ {
+ try
+ {
+ if (service is null) await StartServiceAsync();
+ else await StopServiceAsync();
+ }
+ catch (Exception exception)
+ {
+ SetStatus($"操作失败: {exception.Message}");
+ trayIcon.ShowBalloonTip(5000, "WxAgent", exception.Message, ToolTipIcon.Error);
+ }
+ }
+
+ private async Task ReloadAsync()
+ {
+ try
+ {
+ await StopServiceAsync();
+ EnsureConfiguration();
+ await StartServiceAsync();
+ trayIcon.ShowBalloonTip(2500, "WxAgent", "配置已重新加载。", ToolTipIcon.Info);
+ }
+ catch (Exception exception)
+ {
+ SetStatus($"配置错误: {exception.Message}");
+ trayIcon.ShowBalloonTip(5000, "WxAgent 配置错误", exception.Message, ToolTipIcon.Error);
+ }
+ }
+
+ private void OpenConsole()
+ {
+ var url = (options?.ListenUrl.TrimEnd('/') ?? "http://127.0.0.1:5088") + "/";
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+
+ private void OpenConfig()
+ {
+ EnsureConfiguration();
+ Process.Start(new ProcessStartInfo("notepad.exe", $"\"{configPath}\"") { UseShellExecute = true });
+ }
+
+ private void SetStatus(string message)
+ {
+ StatusItem.Text = message.Length > 60 ? message[..60] : message;
+ trayIcon.Text = message.Length > 63 ? message[..63] : message;
+ }
+
+ private async Task ExitAsync()
+ {
+ if (exiting) return;
+ exiting = true;
+ try { await StopServiceAsync(); }
+ finally
+ {
+ trayIcon.Visible = false;
+ trayIcon.Dispose();
+ instanceMutex.ReleaseMutex();
+ instanceMutex.Dispose();
+ ExitThread();
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && !exiting)
+ {
+ exiting = true;
+ try { StopServiceAsync().GetAwaiter().GetResult(); } catch { }
+ trayIcon.Visible = false;
+ trayIcon.Dispose();
+ instanceMutex.ReleaseMutex();
+ instanceMutex.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ private static string CreateCredentials(string path)
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ var token = Convert.ToHexString(bytes).ToLowerInvariant();
+ WriteJson(path, new[]
+ {
+ new ServiceCredential("local-admin", ServiceOptions.HashToken(token), ["read", "content", "manage"], [])
+ });
+ return token;
+ }
+
+ private static void WriteJson(string path, T value)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, JsonSerializer.Serialize(value, ServiceHost.Json), new UTF8Encoding(false));
+ }
+
+ private static void ShowToken(string token)
+ {
+ using var dialog = new Form
+ {
+ Text = "WxAgent 首次配置",
+ Width = 640,
+ Height = 210,
+ StartPosition = FormStartPosition.CenterScreen,
+ MinimizeBox = false,
+ MaximizeBox = false,
+ FormBorderStyle = FormBorderStyle.FixedDialog
+ };
+ var message = new Label { Left = 18, Top = 18, Width = 585, Height = 48, Text = "首次启动已生成本地管理 Token。请复制并妥善保存;Token 不会写入明文文件。" };
+ var box = new TextBox { Left = 18, Top = 76, Width = 585, ReadOnly = true, Text = token };
+ var copy = new Button { Left = 418, Top = 120, Width = 88, Text = "复制" };
+ var close = new Button { Left = 515, Top = 120, Width = 88, Text = "继续" };
+ copy.Click += (_, _) => { Clipboard.SetText(token); copy.Text = "已复制"; };
+ close.Click += (_, _) => dialog.Close();
+ dialog.Controls.AddRange([message, box, copy, close]);
+ dialog.AcceptButton = close;
+ dialog.ShowDialog();
+ }
+}
diff --git a/src/WxAgent.Tray/WxAgent.Tray.csproj b/src/WxAgent.Tray/WxAgent.Tray.csproj
new file mode 100644
index 0000000..083b0d1
--- /dev/null
+++ b/src/WxAgent.Tray/WxAgent.Tray.csproj
@@ -0,0 +1,24 @@
+
+
+ WinExe
+ net8.0-windows10.0.19041.0
+ true
+ true
+ win-x64
+ x64
+ enable
+ enable
+ true
+ true
+ true
+ false
+ WxAgent.Tray
+
+
+
+
+
+
+
+
+