433 lines
17 KiB
C#
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;
|
|
}
|
|
}
|