feat: add tray app and Setup.exe installer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 客户端使用:
|
||||
|
||||
|
||||
@@ -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 真机验证流程
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -16,6 +16,5 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WxAgent.Windows\WxAgent.Windows.csproj" />
|
||||
<ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" />
|
||||
<None Include="..\..\scripts\windows\Install-WxAgent.ps1" Link="Install-WxAgent.ps1" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<ServiceOptions>(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<T>(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<AssemblyName>WxAgent.Tray</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WxAgent.Host\WxAgent.Host.csproj" />
|
||||
<ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\WxAgent.Service\wwwroot\**\*" Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user