using System.Diagnostics; using System.Net; using System.Text; using System.Text.Json; using System.Windows.Forms; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Logging; using WxAgent.Host; using WxAgent.Service; namespace WxAgent.Tray; internal static class Program { [STAThread] private static void Main(string[] args) { if (args.Length != 0) { MessageBox.Show("请直接双击 WxAgent.Tray.exe 运行;启动参数和命令行配置不受支持。", "WxAgent", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } ApplicationConfiguration.Initialize(); var configPath = Path.Combine(AppContext.BaseDirectory, "service.json"); using var context = new TrayApplicationContext(configPath); Application.Run(context); } } 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(new ToolStripMenuItem("复制工作台地址", null, (_, _) => CopyConsoleAddress())); 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 = Icon.ExtractAssociatedIcon(Application.ExecutablePath) ?? SystemIcons.Application, Text = "WxAgent", Visible = true, ContextMenuStrip = menu }; trayIcon.DoubleClick += (_, _) => OpenConfig(); try { var firstRun = EnsureConfiguration(); StartServiceAsync().GetAwaiter().GetResult(); if (firstRun) OpenConfig(); } catch (Exception exception) { LogError("Tray startup failed.", exception); SetStatus($"启动失败: {exception.Message}"); trayIcon.ShowBalloonTip(5000, "WxAgent 启动失败", exception.Message, ToolTipIcon.Error); } } private ToolStripMenuItem StatusItem => (ToolStripMenuItem)trayIcon.ContextMenuStrip!.Items[0]; private bool EnsureConfiguration() { var directory = Path.GetDirectoryName(configPath)!; Directory.CreateDirectory(directory); if (!File.Exists(configPath)) { var initialToken = ServiceOptions.GenerateToken(); var initial = new ServiceOptions { AccessToken = initialToken, CredentialFile = Path.Combine(directory, "credentials.json"), DataDirectory = Path.Combine(directory, "data") }; Directory.CreateDirectory(initial.DataDirectory); WriteJson(configPath, initial); WriteCredentials(initial.CredentialFile, initialToken, initial.EnableValidationOperations); options = initial; return true; } var loaded = ReadOptions(); var credentialFile = ResolvePath(loaded.CredentialFile, directory, "credentials.json"); var dataDirectory = ResolvePath(loaded.DataDirectory, directory, "data"); var token = ServiceOptions.IsValidAccessToken(loaded.AccessToken) ? loaded.AccessToken! : ServiceOptions.GenerateToken(); var normalized = WithConfiguration(loaded, token, credentialFile, dataDirectory); var changed = !ServiceOptions.IsValidAccessToken(loaded.AccessToken) || !string.Equals(loaded.CredentialFile, credentialFile, StringComparison.OrdinalIgnoreCase) || !string.Equals(loaded.DataDirectory, dataDirectory, StringComparison.OrdinalIgnoreCase); if (changed) WriteJson(configPath, normalized); Directory.CreateDirectory(normalized.DataDirectory); // Always rewrite one canonical record so old/malformed/multi-record files self-heal on tray startup. WriteCredentials(normalized.CredentialFile, token, normalized.EnableValidationOperations); options = normalized; return changed; } private static string ResolvePath(string? value, string baseDirectory, string fallback) { if (string.IsNullOrWhiteSpace(value)) return Path.Combine(baseDirectory, fallback); try { return Path.GetFullPath(value, baseDirectory); } catch (ArgumentException) { return Path.Combine(baseDirectory, fallback); } } private static ServiceOptions WithConfiguration(ServiceOptions source, string token, string credentialFile, string dataDirectory) => new() { ListenUrl = source.ListenUrl, AllowExternal = source.AllowExternal, AllowedHosts = source.AllowedHosts ?? [], AllowedOrigins = source.AllowedOrigins ?? [], AccessToken = token, CredentialFile = credentialFile, DataDirectory = dataDirectory, Remote = source.Remote, Reporting = source.Reporting, RemoteConfigurationFile = source.RemoteConfigurationFile, EnableValidationOperations = source.EnableValidationOperations, PreventAutoLock = source.PreventAutoLock }; private ServiceOptions ReadOptions() => JsonSerializer.Deserialize(File.ReadAllText(configPath), ServiceHost.ConfigurationJson) ?? throw new InvalidDataException("service.json is empty."); private async Task StartServiceAsync() { if (service is not null) return; var loaded = ReadOptions(); loaded.Validate(); options = loaded; if (loaded.PreventAutoLock) PowerPolicy.Apply(); service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded), loaded), logPath: GetLogPath()); 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) { LogError("Service toggle failed.", 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) { LogError("Configuration reload failed.", exception); SetStatus($"配置错误: {exception.Message}"); trayIcon.ShowBalloonTip(5000, "WxAgent 配置错误", exception.Message, ToolTipIcon.Error); } } private string GetLogPath() => Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log"); private void LogError(string message, Exception exception) => RuntimeLog.Append(GetLogPath(), LogLevel.Error, "WxAgent.Tray", message, exception); private string GetConsoleUrl() { if (!Uri.TryCreate(options?.ListenUrl ?? "http://127.0.0.1:5088", UriKind.Absolute, out var uri)) return "http://127.0.0.1:5088/"; var host = uri.Host; if (IPAddress.TryParse(host.Trim('[', ']'), out var address) && (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))) host = "127.0.0.1"; var formattedHost = host.Contains(':') ? $"[{host.Trim('[', ']')}]" : host; return $"{uri.Scheme}://{formattedHost}:{uri.Port}/"; } private void OpenConsole() { Process.Start(new ProcessStartInfo(GetConsoleUrl()) { UseShellExecute = true }); } private void CopyConsoleAddress() { var url = GetConsoleUrl(); Clipboard.SetText(url); trayIcon.ShowBalloonTip(2000, "WxAgent", $"工作台地址已复制:{url}", ToolTipIcon.Info); } private void OpenConfig() { try { EnsureConfiguration(); if (ServiceSettingsEditor.Show(options!) is not { } edited) return; WriteCredentials(edited.CredentialFile, edited.AccessToken!, edited.EnableValidationOperations); WriteJson(configPath, edited); _ = ReloadAsync(); } catch (Exception exception) { LogError("Service settings save failed.", exception); trayIcon.ShowBalloonTip(5000, "WxAgent 服务设置失败", exception.Message, ToolTipIcon.Error); } } 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 void WriteCredentials(string path, string token, bool includeValidationWrite) { var temporary = path + ".tmp"; var permissions = includeValidationWrite ? new[] { "read", "content", "write", "manage" } : new[] { "read", "content", "manage" }; WriteJson(temporary, new[] { new ServiceCredential("local-admin", ServiceOptions.HashToken(token), permissions, []) }); File.Move(temporary, path, true); } private static void WriteJson(string path, T value) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, JsonSerializer.Serialize(value, ServiceHost.Json), new UTF8Encoding(false)); } } internal static class ServiceSettingsEditor { public static ServiceOptions? Show(ServiceOptions current) { if (!Uri.TryCreate(current.ListenUrl, UriKind.Absolute, out var uri)) throw new InvalidDataException("ListenUrl is invalid."); var host = uri.Host.Trim('[', ']'); var token = current.AccessToken ?? ServiceOptions.GenerateToken(); ServiceOptions? result = null; using var form = new Form { Text = "WxAgent 服务设置", Width = 620, Height = 405, StartPosition = FormStartPosition.CenterScreen, MinimizeBox = false, MaximizeBox = false, FormBorderStyle = FormBorderStyle.FixedDialog }; var listenLabel = new Label { Left = 18, Top = 18, Width = 120, Text = "监听地址" }; var hostBox = new TextBox { Left = 145, Top = 14, Width = 245, Text = host }; var portBox = new NumericUpDown { Left = 400, Top = 14, Width = 90, Minimum = 1, Maximum = 65535, Value = uri.Port }; var external = new CheckBox { Left = 145, Top = 52, Width = 430, Text = "允许远程机器连接(仅在可信内网启用)", Checked = current.AllowExternal }; var tokenLabel = new Label { Left = 18, Top = 91, Width = 120, Text = "访问凭据" }; var tokenBox = new TextBox { Left = 145, Top = 87, Width = 345, Text = token, MaxLength = 0 }; var copy = new Button { Left = 500, Top = 85, Width = 88, Text = "复制" }; var regenerate = new Button { Left = 145, Top = 123, Width = 105, Text = "重新生成" }; var validation = new CheckBox { Left = 145, Top = 158, Width = 430, Text = "启用本机验证写操作(仅授权测试机)", Checked = current.EnableValidationOperations }; var preventAutoLock = new CheckBox { Left = 145, Top = 187, Width = 430, Text = "防止自动息屏、睡眠和锁屏", Checked = current.PreventAutoLock }; var note = new Label { Left = 18, Top = 220, Width = 570, Height = 48, Text = "仅保留一个访问凭据,可直接输入自定义内容(不能为空或包含空白字符)。\n验证写操作只应在明确授权的测试机启用;本机回环访问不需要凭据。" }; var data = new Label { Left = 18, Top = 278, Width = 570, Height = 24, Text = $"数据目录:{current.DataDirectory}", AutoEllipsis = true }; var save = new Button { Left = 370, Top = 330, Width = 105, Text = "保存" }; var cancel = new Button { Left = 485, Top = 330, Width = 105, Text = "取消" }; copy.Click += (_, _) => { Clipboard.SetText(tokenBox.Text); copy.Text = "已复制"; }; regenerate.Click += (_, _) => { if (MessageBox.Show("重新生成并保存后当前访问凭据会立即失效,继续吗?", "WxAgent", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) == DialogResult.OK) tokenBox.Text = ServiceOptions.GenerateToken(); }; save.Click += (_, _) => { if (!IPAddress.TryParse(hostBox.Text.Trim().Trim('[', ']'), out var address)) { MessageBox.Show("监听地址必须是 IP 地址。", "WxAgent", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var formattedHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); var edited = new ServiceOptions { ListenUrl = $"http://{formattedHost}:{portBox.Value}", AllowExternal = external.Checked, AllowedHosts = current.AllowedHosts, AllowedOrigins = current.AllowedOrigins, AccessToken = tokenBox.Text, CredentialFile = current.CredentialFile, DataDirectory = current.DataDirectory, Remote = current.Remote, Reporting = current.Reporting, RemoteConfigurationFile = current.RemoteConfigurationFile, EnableValidationOperations = validation.Checked, PreventAutoLock = preventAutoLock.Checked }; try { edited.Validate(); } catch (Exception exception) { MessageBox.Show(exception.Message, "WxAgent 设置无效", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } result = edited; form.Close(); }; cancel.Click += (_, _) => form.Close(); form.Controls.AddRange([listenLabel, hostBox, portBox, external, tokenLabel, tokenBox, copy, regenerate, validation, preventAutoLock, note, data, save, cancel]); form.AcceptButton = save; form.CancelButton = cancel; form.ShowDialog(); return result; } }