Files
wx-win-agent/node-agent/WxAgent.Tray/Program.cs
T
rogee 13c31fc902
Build web service image / build (push) Successful in 1m53s
feat: add remote control plane and whitelist reads
2026-09-12 09:46:05 +08:00

433 lines
17 KiB
C#

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.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(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 += (_, _) => OpenConsole();
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);
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);
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
};
private ServiceOptions ReadOptions() =>
JsonSerializer.Deserialize<ServiceOptions>(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;
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(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!);
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)
{
var temporary = path + ".tmp";
WriteJson(temporary, new[]
{
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), ["read", "content", "manage"], [])
});
File.Move(temporary, path, true);
}
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));
}
}
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 = 355,
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 note = new Label
{
Left = 18, Top = 170, Width = 570, Height = 72,
Text = "仅保留一个访问凭据,可直接输入自定义内容(不能为空或包含空白字符,不限制长度)。\n保存后新凭据立即生效,重新生成会使旧凭据失效。本机回环访问不需要凭据。"
};
var data = new Label
{
Left = 18, Top = 246, Width = 570, Height = 24,
Text = $"数据目录:{current.DataDirectory}", AutoEllipsis = true
};
var save = new Button { Left = 370, Top = 285, Width = 105, Text = "保存" };
var cancel = new Button { Left = 485, Top = 285, 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
};
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, note, data, save, cancel]);
form.AcceptButton = save;
form.CancelButton = cancel;
form.ShowDialog();
return result;
}
}