refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
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
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user