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 + + + + + + + + +