diff --git a/ThingHK/Contracts.cs b/ThingHK/Contracts.cs index d873240..55705f1 100644 --- a/ThingHK/Contracts.cs +++ b/ThingHK/Contracts.cs @@ -132,4 +132,8 @@ internal sealed class ErrorResponse [JsonSerializable(typeof(ConfigRequest[]))] [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(ErrorResponse))] +[JsonSerializable(typeof(HardwareConfig))] +[JsonSerializable(typeof(HardwareConfigResponse))] +[JsonSerializable(typeof(HardwareConfigUpdateResponse))] +[JsonSerializable(typeof(HardwareConfigUpdateRequest))] internal sealed partial class ThingHKJsonContext : JsonSerializerContext; diff --git a/ThingHK/HardwareConfig.cs b/ThingHK/HardwareConfig.cs new file mode 100644 index 0000000..6865bb9 --- /dev/null +++ b/ThingHK/HardwareConfig.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingHK; + +/// +/// 硬件监控配置:控制启用哪些硬件分组 + 过滤哪些传感器类型。 +/// +/// 设计要点: +/// - 硬件开关影响 Computer.IsXxxEnabled,需重启 Kernel 才生效(LHB 在 Open 时决定枚举范围) +/// - 传感器类型过滤在 BuildSnapshot 时生效,可热更新(无需重启) +/// - 默认配置仅覆盖概览页需要的指标:CPU/GPU/Memory/Storage + temp/load/power/clock/data +/// - 配置文件由 Tauri 通过 --config 参数传递路径,Kernel 负责加载/保存 +/// +internal sealed class HardwareConfig +{ + /// 硬件分组开关,key 为 HardwareConfigKey(cpu/gpu/memory/storage/...) + public Dictionary Hardware { get; set; } = new(); + + /// 传感器类型开关,key 为 SensorType 小写(temperature/load/power/...) + public Dictionary SensorTypes { get; set; } = new(); + + /// 默认配置:仅覆盖概览页需要的指标,最小化性能开销 + public static HardwareConfig Default => new() + { + Hardware = new Dictionary + { + ["cpu"] = true, + ["gpu"] = true, + ["memory"] = true, + ["storage"] = true, + ["motherboard"] = false, + ["controller"] = false, + ["battery"] = false, + ["network"] = false, + ["psu"] = false, + }, + SensorTypes = new Dictionary + { + ["temperature"] = true, + ["load"] = true, + ["power"] = true, + ["clock"] = true, + ["voltage"] = false, + ["fan"] = false, + ["data"] = true, + ["smalldata"] = false, + ["throughput"] = false, + ["level"] = false, + ["control"] = false, + ["factor"] = false, + ["frequency"] = false, + ["timespan"] = false, + ["energy"] = false, + ["noise"] = false, + ["conductivity"] = false, + ["humidity"] = false, + ["flow"] = false, + }, + }; + + /// 从文件加载配置,文件不存在或解析失败时返回默认配置 + public static HardwareConfig Load(string? path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return Default; + + try + { + var json = File.ReadAllText(path); + var cfg = JsonSerializer.Deserialize(json, ThingHKJsonContext.Default.HardwareConfig); + if (cfg == null) return Default; + + // 合并默认值:确保新增的硬件/传感器类型有默认值 + MergeWithDefaults(cfg); + return cfg; + } + catch + { + return Default; + } + } + + /// 保存配置到文件 + public void Save(string path) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(this, ThingHKJsonContext.Default.HardwareConfig); + File.WriteAllText(path, json); + } + + /// 检查指定传感器类型是否启用 + public bool IsSensorTypeEnabled(string sensorTypeLower) + => SensorTypes.TryGetValue(sensorTypeLower, out var enabled) ? enabled : false; + + /// 检查指定硬件分组是否启用 + public bool IsHardwareEnabled(string key) + => Hardware.TryGetValue(key, out var enabled) ? enabled : false; + + /// 合并默认值:确保配置文件缺少的新增项有默认值 + private static void MergeWithDefaults(HardwareConfig cfg) + { + var def = Default; + foreach (var (k, v) in def.Hardware) + if (!cfg.Hardware.ContainsKey(k)) cfg.Hardware[k] = v; + foreach (var (k, v) in def.SensorTypes) + if (!cfg.SensorTypes.ContainsKey(k)) cfg.SensorTypes[k] = v; + } +} + +/// +/// /config/hardware GET 响应:返回当前配置 + 可用硬件/传感器类型清单。 +/// 前端用此数据渲染配置 Dialog。 +/// +internal sealed class HardwareConfigResponse +{ + public HardwareConfig Config { get; set; } = new(); + public List AvailableHardware { get; set; } = new(); + public List AvailableSensorTypes { get; set; } = new(); +} + +/// 硬件分组元信息 +internal sealed class HardwareTypeInfo +{ + /// 配置 key(cpu/gpu/memory/...) + public string Key { get; set; } = ""; + /// 显示名 + public string Name { get; set; } = ""; + /// 是否已启用 + public bool Enabled { get; set; } + /// 是否需要管理员权限才能读取完整数据 + public bool RequiresAdmin { get; set; } +} + +/// 传感器类型元信息 +internal sealed class SensorTypeInfo +{ + /// 配置 key(temperature/load/...) + public string Key { get; set; } = ""; + /// 显示名 + public string Name { get; set; } = ""; + /// 是否已启用 + public bool Enabled { get; set; } +} + +/// /config/hardware POST 响应 +internal sealed class HardwareConfigUpdateResponse +{ + public bool Success { get; set; } + /// 硬件开关是否变化(需重启 Kernel 才生效) + public bool RestartRequired { get; set; } + public string? ConfigPath { get; set; } +} + +/// /config/hardware POST 请求体 +internal sealed class HardwareConfigUpdateRequest +{ + /// 硬件开关(可选,仅传需要变更的字段) + public Dictionary? Hardware { get; set; } + /// 传感器类型开关(可选,仅传需要变更的字段) + public Dictionary? SensorTypes { get; set; } +} diff --git a/ThingHK/HardwareManager.cs b/ThingHK/HardwareManager.cs index 96c8233..1b9fae9 100644 --- a/ThingHK/HardwareManager.cs +++ b/ThingHK/HardwareManager.cs @@ -26,6 +26,7 @@ internal sealed class HardwareManager : IDisposable private readonly Stopwatch _startupSw; private bool _ready; private bool _closed; + private HardwareConfig _config; // 缓存"哪个硬件属于快通道"——按 hardware type 判断更稳定(不同机型传感器命名不一致) // 实际分频逻辑见 SamplingScheduler,此处只暴露硬件列表给调度器 @@ -34,24 +35,31 @@ internal sealed class HardwareManager : IDisposable public bool IsAdmin => _isAdmin; public double ColdStartMs => _coldStartMs; public bool Ready => _ready; + public HardwareConfig Config => _config; - public HardwareManager(bool basic) + public HardwareManager(HardwareConfig config) { + _config = config ?? HardwareConfig.Default; _isAdmin = IsRunningAsAdmin(); _startupSw = Stopwatch.StartNew(); + // LHB 行为:主板的传感器实际通过 SubHardware(SuperIO/EC)暴露, + // 而 SuperIO 作为顶层硬件时需 IsControllerEnabled 才能枚举。 + // 联动策略:启用 motherboard 时自动启用 controller,避免主板勾选后无数据。 + bool motherboardEnabled = _config.IsHardwareEnabled("motherboard"); + bool controllerEnabled = _config.IsHardwareEnabled("controller") || motherboardEnabled; + _computer = new Computer { - IsCpuEnabled = true, - IsGpuEnabled = true, - IsMemoryEnabled = true, - IsStorageEnabled = true, - // basic 模式只开核心四类硬件(CPU/GPU/RAM/Storage),降低冷启动耗时 - IsMotherboardEnabled = !basic, - IsControllerEnabled = !basic, - IsBatteryEnabled = !basic, - IsNetworkEnabled = !basic, - IsPsuEnabled = !basic, + IsCpuEnabled = _config.IsHardwareEnabled("cpu"), + IsGpuEnabled = _config.IsHardwareEnabled("gpu"), + IsMemoryEnabled = _config.IsHardwareEnabled("memory"), + IsStorageEnabled = _config.IsHardwareEnabled("storage"), + IsMotherboardEnabled = motherboardEnabled, + IsControllerEnabled = controllerEnabled, + IsBatteryEnabled = _config.IsHardwareEnabled("battery"), + IsNetworkEnabled = _config.IsHardwareEnabled("network"), + IsPsuEnabled = _config.IsHardwareEnabled("psu"), }; _computer.Open(); @@ -68,6 +76,12 @@ internal sealed class HardwareManager : IDisposable _ready = true; } + /// 热更新传感器类型过滤(无需重启 Kernel,下次 BuildSnapshot 生效) + public void UpdateConfig(HardwareConfig newConfig) + { + _config = newConfig; + } + /// /// 全量 Update 所有硬件。 /// 由 SamplingScheduler 按通道分频调用。 @@ -130,11 +144,16 @@ internal sealed class HardwareManager : IDisposable foreach (var s in hw.Sensors) { + // 按配置过滤传感器类型:关闭的类型不加入快照,减少传输数据量 + string sensorType = s.SensorType.ToString().ToLowerInvariant(); + if (!_config.IsSensorTypeEnabled(sensorType)) + continue; + g.Sensors.Add(new SensorEntry { Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(), Name = s.Name, - Type = s.SensorType.ToString().ToLowerInvariant(), + Type = sensorType, Value = s.Value, Unit = UnitFor(s.SensorType), HardwareName = hw.Name, diff --git a/ThingHK/HttpEndpoints.cs b/ThingHK/HttpEndpoints.cs index f40c394..b7da4e8 100644 --- a/ThingHK/HttpEndpoints.cs +++ b/ThingHK/HttpEndpoints.cs @@ -10,13 +10,14 @@ namespace ThingHK; /// 路由设计(见 ThingHK_GUIDE.md 第 2.3 节): /// GET /status → 冷启动就绪探测,前端轮询判断是否可订阅 /// GET /snapshot → 一次性拉取最新缓存快照 -/// GET /stream → SSE 订阅,Kernel 主动推送 SensorSnapshot +/// GET /stream → SSE 讑阅,Kernel 主动推送 SensorSnapshot /// POST /config → 运行时调整采样间隔 +/// POST /shutdown → 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程) /// GET / → 健康检查(200 OK) /// internal static class HttpEndpoints { - public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel) + public static WebApplication MapThingHKEndpoints(this WebApplication app, KernelHost kernel, CancellationTokenSource shutdownCts) { // 全局异常捕获中间件:把未处理异常写到 stderr,便于 AOT 下排错 app.Use(async (ctx, next) => @@ -122,6 +123,116 @@ internal static class HttpEndpoints }, ThingHKJsonContext.Default.ConfigResponse); }); + // 硬件监控配置:返回当前配置 + 可用硬件/传感器类型清单 + app.MapGet("/config/hardware", () => + { + var hw = kernel.Hardware; + var cfg = hw?.Config ?? HardwareConfig.Default; + var resp = new HardwareConfigResponse + { + Config = cfg, + AvailableHardware = GetAvailableHardware(cfg), + AvailableSensorTypes = GetAvailableSensorTypes(cfg), + }; + return Results.Json(resp, ThingHKJsonContext.Default.HardwareConfigResponse); + }); + + // 硬件监控配置更新: + // - 传感器类型过滤:热更新(下次 BuildSnapshot 生效) + // - 硬件开关:需重启 Kernel 才生效(返回 restartRequired=true) + app.MapPost("/config/hardware", async Task (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); + HardwareConfigUpdateRequest? req; + try + { + req = string.IsNullOrWhiteSpace(body) + ? new HardwareConfigUpdateRequest() + : JsonSerializer.Deserialize(body, ThingHKJsonContext.Default.HardwareConfigUpdateRequest); + } + catch (JsonException ex) + { + ctx.Response.StatusCode = 400; + return Results.Json(new ErrorResponse("invalid_json", ex.Message), ThingHKJsonContext.Default.ErrorResponse); + } + if (req == null) + { + ctx.Response.StatusCode = 400; + return Results.Json(new ErrorResponse("invalid_body", "请求体为空"), ThingHKJsonContext.Default.ErrorResponse); + } + + var hw = kernel.Hardware; + if (hw == null) + { + ctx.Response.StatusCode = 503; + return Results.Json(new ErrorResponse("not_ready", "Kernel 尚未就绪"), ThingHKJsonContext.Default.ErrorResponse); + } + + var oldConfig = hw.Config; + var newConfig = new HardwareConfig + { + Hardware = new Dictionary(oldConfig.Hardware), + SensorTypes = new Dictionary(oldConfig.SensorTypes), + }; + + bool hardwareChanged = false; + if (req.Hardware != null) + { + foreach (var (k, v) in req.Hardware) + { + if (newConfig.Hardware.TryGetValue(k, out var oldVal) && oldVal != v) + { + newConfig.Hardware[k] = v; + hardwareChanged = true; + } + else if (!newConfig.Hardware.ContainsKey(k)) + { + newConfig.Hardware[k] = v; + hardwareChanged = true; + } + } + } + + if (req.SensorTypes != null) + { + foreach (var (k, v) in req.SensorTypes) + newConfig.SensorTypes[k] = v; + } + + // 热更新传感器类型过滤(立即生效) + hw.UpdateConfig(newConfig); + + // 保存到文件(供下次启动加载) + if (!string.IsNullOrEmpty(kernel.ConfigPath)) + { + try { newConfig.Save(kernel.ConfigPath); } + catch (Exception ex) { Console.Error.WriteLine($"[ThingHK] 保存配置失败: {ex.Message}"); } + } + + return Results.Json(new HardwareConfigUpdateResponse + { + Success = true, + RestartRequired = hardwareChanged, + ConfigPath = kernel.ConfigPath, + }, ThingHKJsonContext.Default.HardwareConfigUpdateResponse); + }); + + // 优雅关闭:触发 CancellationTokenSource.Cancel,让 app.RunAsync 退出。 + // 提权模式下 Tauri(普通权限)无法 TerminateProcess 管理员进程,通过此接口让 Kernel 自行退出。 + // 普通权限模式下 ProcessManager.stop 直接 kill 更快,此接口作为统一兜底。 + app.MapPost("/shutdown", () => + { + Console.Error.WriteLine("[ThingHK] 收到 /shutdown 请求,准备退出"); + // 延迟取消,确保响应先返回 + _ = Task.Run(async () => + { + await Task.Delay(100); + shutdownCts.Cancel(); + }); + return Results.Json(new HealthResponse { Ok = true, Name = "ThingHK", Version = "0.2.0" }, ThingHKJsonContext.Default.HealthResponse); + }); + return app; } @@ -138,6 +249,75 @@ internal static class HttpEndpoints await ctx.Response.WriteAsync("\n\n", ct).ConfigureAwait(false); await ctx.Response.Body.FlushAsync(ct).ConfigureAwait(false); } + + /// 可用硬件分组清单(固定列表,供前端 Dialog 渲染) + private static List GetAvailableHardware(HardwareConfig cfg) + { + // (key, displayName, requiresAdmin) + var items = new (string, string, bool)[] + { + ("cpu", "CPU", false), + ("gpu", "GPU(Intel/AMD/NVIDIA)", false), + ("memory", "内存", false), + ("storage", "存储", true), + ("motherboard", "主板", true), + ("controller", "SuperIO/嵌入式控制器", true), + ("battery", "电池", false), + ("network", "网络", false), + ("psu", "电源", true), + }; + var list = new List(items.Length); + foreach (var (key, name, reqAdmin) in items) + { + list.Add(new HardwareTypeInfo + { + Key = key, + Name = name, + Enabled = cfg.IsHardwareEnabled(key), + RequiresAdmin = reqAdmin, + }); + } + return list; + } + + /// 可用传感器类型清单(固定列表,供前端 Dialog 渲染) + private static List GetAvailableSensorTypes(HardwareConfig cfg) + { + // (key, displayName) + var items = new (string, string)[] + { + ("temperature", "温度"), + ("load", "负载"), + ("power", "功率"), + ("clock", "时钟"), + ("voltage", "电压"), + ("fan", "风扇"), + ("data", "容量"), + ("smalldata", "小容量"), + ("throughput", "吞吐"), + ("level", "等级"), + ("control", "控制"), + ("factor", "因子"), + ("frequency", "频率"), + ("timespan", "时长"), + ("energy", "能量"), + ("noise", "噪声"), + ("conductivity", "电导率"), + ("humidity", "湿度"), + ("flow", "流量"), + }; + var list = new List(items.Length); + foreach (var (key, name) in items) + { + list.Add(new SensorTypeInfo + { + Key = key, + Name = name, + Enabled = cfg.IsSensorTypeEnabled(key), + }); + } + return list; + } } /// @@ -149,11 +329,17 @@ internal sealed class KernelHost : IDisposable public HardwareManager? Hardware { get; private set; } public SamplingScheduler Scheduler { get; private set; } = null!; public Stopwatch Uptime { get; } = Stopwatch.StartNew(); + /// 配置文件路径(供 /config/hardware POST 保存) + public string? ConfigPath { get; private set; } - public async Task StartAsync(bool basic, int fastMs, int slowMs) + public async Task StartAsync(string? configPath, int fastMs, int slowMs) { + ConfigPath = configPath; + var config = HardwareConfig.Load(configPath); + Console.Error.WriteLine($"[ThingHK] 加载配置: {configPath ?? "(默认)"}, 硬件={config.Hardware.Count}项 传感器类型={config.SensorTypes.Count}项"); + // 1. 初始化硬件层(含首轮 Update,约 5 秒) - Hardware = new HardwareManager(basic); + Hardware = new HardwareManager(config); Console.Error.WriteLine($"[ThingHK] 硬件就绪: {Hardware.AllHardware.Count} 设备, 冷启动 {Hardware.ColdStartMs:F0}ms, admin={Hardware.IsAdmin}"); // 2. 启动采样调度器(会立即推送首个快照到广播 channel) diff --git a/ThingHK/Program.cs b/ThingHK/Program.cs index 4604709..04075b8 100644 --- a/ThingHK/Program.cs +++ b/ThingHK/Program.cs @@ -27,24 +27,24 @@ internal static class Program // 无参数 → 默认 serve(最常见用法) if (args.Length == 0) { - return await ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs); + return await ServeAsync(DefaultPort, null, DefaultFastMs, DefaultSlowMs, null); } // serve 子命令 var serveCmd = new Command("serve", "启动 HTTP 服务"); var portOpt = new Option("--port", () => DefaultPort, "监听端口"); - var basicOpt = new Option("--basic", "仅启用核心四类硬件,降低冷启动耗时"); - var fullOpt = new Option("--full", "启用全部硬件(含主板/控制器/网络等)"); + var configOpt = new Option("--config", "硬件监控配置文件路径(JSON)"); var fastOpt = new Option("--fast-ms", () => DefaultFastMs, "快通道采样间隔(毫秒)"); var slowOpt = new Option("--slow-ms", () => DefaultSlowMs, "慢通道采样间隔(毫秒)"); + var logFileOpt = new Option("--log-file", "将 stderr 日志写入指定文件(提权模式下控制台隐藏时使用)"); serveCmd.AddOption(portOpt); - serveCmd.AddOption(basicOpt); - serveCmd.AddOption(fullOpt); + serveCmd.AddOption(configOpt); serveCmd.AddOption(fastOpt); serveCmd.AddOption(slowOpt); - serveCmd.SetHandler((port, basic, full, fast, slow) => - ServeAsync(port, basic || !full, fast, slow), - portOpt, basicOpt, fullOpt, fastOpt, slowOpt); + serveCmd.AddOption(logFileOpt); + serveCmd.SetHandler((port, config, fast, slow, logFile) => + ServeAsync(port, config?.FullName, fast, slow, logFile), + portOpt, configOpt, fastOpt, slowOpt, logFileOpt); // scan 子命令(阶段一覆盖矩阵测试,保留) var scanCmd = new Command("scan", "扫描一次并输出覆盖矩阵(阶段一验证用)"); @@ -58,17 +58,37 @@ internal static class Program root.Add(serveCmd); root.Add(scanCmd); // 兼容阶段一的旧用法:ThingHK.exe --json --basic - root.SetHandler(() => ServeAsync(DefaultPort, basic: true, DefaultFastMs, DefaultSlowMs)); + root.SetHandler(() => ServeAsync(DefaultPort, null, DefaultFastMs, DefaultSlowMs, null)); return await root.InvokeAsync(args); } - private static async Task ServeAsync(int port, bool basic, int fastMs, int slowMs) + private static async Task ServeAsync(int port, string? configPath, int fastMs, int slowMs, FileInfo? logFile) { - Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} basic={basic} fast={fastMs}ms slow={slowMs}ms"); + // 如果指定了 --log-file,将 stderr 重定向到文件(提权模式下控制台隐藏,需要日志排查崩溃) + if (logFile != null) + { + try + { + var logStream = new StreamWriter(logFile.Open(FileMode.Create, FileAccess.Write, FileShare.Read)) + { + AutoFlush = true, + }; + Console.SetError(logStream); + // 同时重定向 stdout(避免 ASP.NET 的某些输出到 stdout) + Console.SetOut(logStream); + Console.Error.WriteLine($"[ThingHK] 日志输出到: {logFile.FullName}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ThingHK] 无法写入日志文件 {logFile.FullName}: {ex.Message}"); + } + } + + Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms"); using var kernel = new KernelHost(); - await kernel.StartAsync(basic, fastMs, slowMs); + await kernel.StartAsync(configPath, fastMs, slowMs); var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}/"); @@ -78,15 +98,16 @@ internal static class Program builder.Logging.AddProvider(new StderrLoggerProvider()); var app = builder.Build(); - app.MapThingHKEndpoints(kernel); - // Ctrl+C 优雅关闭 + // Ctrl+C 优雅关闭(cts 同时用于 /shutdown 接口触发退出) var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel(); + app.MapThingHKEndpoints(kernel, cts); + Console.Error.WriteLine($"[ThingHK] HTTP 服务监听: http://127.0.0.1:{port}/"); - Console.Error.WriteLine("[ThingHK] 路由: GET / GET /status GET /snapshot GET /stream POST /config"); + Console.Error.WriteLine("[ThingHK] 路由: GET / GET /status GET /snapshot GET /stream POST /config POST /shutdown"); try { @@ -103,7 +124,18 @@ internal static class Program private static async Task ScanAsync(bool json, bool basic) { Console.Error.WriteLine($"[ThingHK] scan 模式: json={json} basic={basic}"); - using var hw = new HardwareManager(basic); + // scan 命令保留旧 basic 参数:basic=true 用默认配置,basic=false 用全部启用配置 + var config = basic ? HardwareConfig.Default : new HardwareConfig + { + Hardware = new Dictionary + { + ["cpu"] = true, ["gpu"] = true, ["memory"] = true, ["storage"] = true, + ["motherboard"] = true, ["controller"] = true, ["battery"] = true, + ["network"] = true, ["psu"] = true, + }, + SensorTypes = HardwareConfig.Default.SensorTypes, + }; + using var hw = new HardwareManager(config); // 第二轮 Update:部分传感器首轮才初始化 hw.UpdateAll(); diff --git a/ThingHK/ThingHK_GUIDE.md b/ThingHK/ThingHK_GUIDE.md index 785bea0..d264206 100644 --- a/ThingHK/ThingHK_GUIDE.md +++ b/ThingHK/ThingHK_GUIDE.md @@ -57,6 +57,7 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程 | `/snapshot` | GET | 一次性拉取最新缓存快照 | | `/stream` | GET (SSE) | 订阅推送,Kernel 按采样间隔主动 Push `SensorSnapshot` | | `/config` | POST | 运行时调整采样间隔(`{"FastIntervalMs":500,"SlowIntervalMs":3000}`) | +| `/shutdown` | POST | 优雅关闭(提权模式下由 Tauri 调用,普通权限无法 kill 管理员进程) | ### 3.2 数据契约(显式版本化) @@ -85,14 +86,15 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程 ### 3.3 Tauri 命令与事件 -**6 个命令**: +**7 个命令**: | 命令 | 作用 | |------|------| | `monitor_kernel_info` | 查询 Kernel 二进制路径/是否存在/端口 | -| `monitor_status` | 查询运行状态(running/pid/ready/sensorCount/restartCount) | +| `monitor_status` | 查询运行状态(running/pid/ready/sensorCount/restartCount/elevated) | | `monitor_start` | 启动 Kernel + 自动订阅 SSE(命令和 setup 共用 `start_with_subscription`) | -| `monitor_stop` | 停止 Kernel + 取消 SSE 订阅 + 取消自动重连监听 | +| `monitor_start_elevated` | 以管理员权限启动 Kernel(弹 UAC),停止走 `/shutdown` 接口 | +| `monitor_stop` | 停止 Kernel(提权模式走 `/shutdown`,普通模式走 ProcessManager) | | `monitor_get_status` | 透传 Kernel `/status` | | `monitor_get_snapshot` | 透传 Kernel `/snapshot` | @@ -200,15 +202,15 @@ Rust (Tauri App) <---HTTP / SSE (127.0.0.1:8730)---> ThingHK.exe (独立进程 | [Program.cs](file:///d:/Atie/Gitea/Thing/ThingHK/Program.cs) | `serve`/`scan` 双子命令(System.CommandLine),默认无参数等价 `serve --port 8730 --basic` | | [Contracts.cs](file:///d:/Atie/Gitea/Thing/ThingHK/Contracts.cs) | 数据契约 + `ThingHKJsonContext`(JSON 源生成,camelCase) | | [HardwareManager.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HardwareManager.cs) | `HardwareManager`(封装 LHB Computer,快慢通道分类)+ `SamplingScheduler`(PeriodicTimer 分频)+ `SnapshotCache`(线程安全缓存)+ `SnapshotVisitor`(递归收集) | -| [HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) | 5 个路由 + 全局异常中间件 + `KernelHost`(统一生命周期) | +| [HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) | 6 个路由(含 `/shutdown` 优雅关闭)+ 全局异常中间件 + `KernelHost`(统一生命周期) | | [rd.xml](file:///d:/Atie/Gitea/Thing/ThingHK/rd.xml) | LHB 反射根描述符,`preserve="all"` 兜底 AOT trimming | ### Rust 集成 | 文件 | 职责 | |------|------| -| [monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) | Tauri 侧 Kernel 客户端:复制二进制、构造 `StartProcessParams`、轮询就绪、SSE 订阅、写入熔断 + 自动重连 | -| [lib.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/lib.rs) | 6 个 monitor 命令注册 + setup 自动启动 | +| [monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) | Tauri 侧 Kernel 客户端:复制二进制、构造 `StartProcessParams`、轮询就绪、SSE 订阅、写入熔断 + 自动重连、ShellExecuteW 提权启动 | +| [lib.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/lib.rs) | 7 个 monitor 命令注册 + setup 自动启动 | | [process_manager.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/process_manager.rs) | 通用进程管理(复用,含崩溃重启 + Job Object) | ### 前端 @@ -235,11 +237,42 @@ ThingHK.exe serve --port 8730 --basic --fast-ms 1000 --slow-ms 5000 ThingHK.exe scan --json --basic ``` -## 7. 后续可选优化 +## 7. 提权机制 -以下优化不阻塞当前版本发布,可在后续迭代中考虑: +### 7.1 背景 + +LibreHardwareMonitor 访问 CPU MSR(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。普通权限下可读传感器受限: + +| 权限 | 分组 | 传感器数 | 可读项 | +|------|------|----------|--------| +| 普通 | cpu/memory/gpuintel | 62 | CPU 负载/功率、内存、GPU 负载(温度/时钟全 null) | +| 管理员 | cpu/memory/gpuintel/storage | 117 | 完整温度/时钟/电压/存储 SMART | + +### 7.2 实现方案 + +采用 **ShellExecuteW "runas"** 弹 UAC 提权启动 Kernel,架构上与普通模式分离: + +- **启动**:[monitor_kernel.rs](file:///d:/Atie/Gitea/Thing/src-tauri/src/monitor_kernel.rs) `start_elevated` → 停止当前 Kernel → `ShellExecuteW "runas"` 启动管理员 Kernel(隐藏控制台窗口)→ 等待 ready → 重新订阅 SSE +- **停止**:调用 Kernel 的 `POST /shutdown` 接口(普通权限无法 `TerminateProcess` 管理员进程) +- **状态查询**:通过 HTTP `/status` 判断运行状态(pid 不可用,返回 None) + +### 7.3 提权模式限制 + +| 能力 | 普通模式 | 提权模式 | +|------|----------|----------| +| 进程管理 | ProcessManager(kill/restart/Job Object) | 不受管控(跨权限级别句柄不可用) | +| 崩溃重启 | 3s 巡检 + 自动重启 | 不支持(需手动重新提权) | +| 停止方式 | `ProcessManager.stop`(kill) | `POST /shutdown`(优雅退出) | +| PID 查询 | 可用 | 不可用(返回 None) | +| UAC 弹窗 | 无 | 每次启动需确认 | + +### 7.4 C# Kernel /shutdown 接口 + +[HttpEndpoints.cs](file:///d:/Atie/Gitea/Thing/ThingHK/HttpEndpoints.cs) 增加 `POST /shutdown` 路由,触发 `CancellationTokenSource.Cancel` 让 `app.RunAsync` 优雅退出。响应先返回 200,延迟 100ms 后取消,确保响应体完整发送。 + +## 8. 后续可选优化 1. **ProcessManager Mutex 锁阻塞问题**:`check_and_cleanup` 中 800ms `sleep`(端口等待)在持有 `processes` Mutex 锁的情况下执行,会阻塞所有进程状态查询。建议将重启逻辑移出锁作用域(已知问题,与 proxy 模块共享)。 -2. **提权策略**:通过 Windows 任务计划程序实现免 UAC 管理员启动,解锁 SMBus/EC/部分 GPU 传感器。当前以普通权限运行,覆盖大部分 CPU/GPU/内存/存储温度。 +2. **免 UAC 提权**:当前每次提权需弹 UAC,可通过 Windows 任务计划程序注册"以最高权限运行"的任务实现免 UAC 启动(需一次性管理员权限注册任务)。 3. **多机型回归**:AMD Ryzen / NVIDIA GPU / 笔记本场景的实际传感器覆盖度验证(需实际硬件)。 4. **Kernel 版本管理**:`prepare_kernel` 只在文件不存在时复制,不会覆盖更新。建议增加版本比对自动更新。 diff --git a/ThingHK/build.ps1 b/ThingHK/build.ps1 new file mode 100644 index 0000000..0f69321 --- /dev/null +++ b/ThingHK/build.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + ThingHK Native AOT build script. +.DESCRIPTION + Builds ThingHK as Native AOT single-file executable and copies it to src-tauri/binaries/ for Tauri packaging. + Usage: run .\build.ps1 in the ThingHK/ directory. +#> +[CmdletBinding()] +param( + [string]$Configuration = "Release", + [string]$Runtime = "win-x64" +) + +$ErrorActionPreference = "Stop" + +# Resolve script directory: try multiple methods for compatibility +$scriptDir = $PSScriptRoot +if (-not $scriptDir) { + $cmd = $MyInvocation.MyCommand + if ($cmd -and $cmd.Path) { + $scriptDir = Split-Path -Parent $cmd.Path + } +} +if (-not $scriptDir) { + $scriptDir = (Get-Location).Path +} + +$projectPath = Join-Path $scriptDir "ThingHK.csproj" +$publishDir = Join-Path $scriptDir "bin\$Configuration\net8.0-windows\$Runtime\publish" +$destDir = Join-Path $scriptDir "..\src-tauri\binaries" +$destPath = Join-Path $destDir "ThingHK.exe" + +Write-Host "=== ThingHK Build Started ===" -ForegroundColor Cyan +Write-Host "Config: $Configuration | Runtime: $Runtime" +Write-Host "Project: $projectPath" +Write-Host "ScriptDir: $scriptDir" + +if (-not (Test-Path $projectPath)) { + Write-Error "Project file not found: $projectPath" + exit 1 +} + +# 1. Clean old obj cache +$objDir = Join-Path $scriptDir "obj" +if (Test-Path $objDir) { + Write-Host "`n[1/4] Cleaning obj cache..." -ForegroundColor Yellow + Remove-Item $objDir -Recurse -Force -ErrorAction SilentlyContinue +} + +# 2. dotnet publish (Native AOT) +Write-Host "`n[2/4] Running dotnet publish (Native AOT)..." -ForegroundColor Yellow +& dotnet publish $projectPath -c $Configuration -r $Runtime +if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet publish failed (exit $LASTEXITCODE)" + exit $LASTEXITCODE +} + +$publishedExe = Join-Path $publishDir "ThingHK.exe" +if (-not (Test-Path $publishedExe)) { + Write-Error "Build output not found: $publishedExe" + exit 1 +} + +# 3. Ensure destination directory exists +if (-not (Test-Path $destDir)) { + Write-Host "`n[3/4] Creating destination: $destDir" -ForegroundColor Yellow + New-Item -ItemType Directory -Path $destDir -Force | Out-Null +} + +# 4. Copy to src-tauri/binaries/ +Write-Host "`n[4/4] Copying to $destPath" -ForegroundColor Yellow +Copy-Item $publishedExe $destPath -Force + +$destItem = Get-Item $destPath +Write-Host "`n=== Build Complete ===" -ForegroundColor Green +Write-Host ("Output: {0}" -f $destItem.FullName) +Write-Host ("Size: {0:N2} MB" -f ($destItem.Length / 1MB)) +Write-Host ("Time: {0}" -f $destItem.LastWriteTime) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 35ac851..f8bf094 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -711,6 +711,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1043,6 +1062,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.11" @@ -2394,6 +2419,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3021,6 +3055,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3824,6 +3878,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -3892,7 +3960,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3963,7 +4031,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4118,7 +4186,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4160,7 +4228,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4185,7 +4253,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4272,6 +4340,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -5023,10 +5092,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -5047,7 +5116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5097,6 +5166,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5119,14 +5198,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -5138,8 +5229,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -5156,6 +5247,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5167,6 +5269,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5211,6 +5324,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5665,7 +5787,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8eb5a28..0ea73fe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,10 +33,11 @@ axum = "0.7" url = "2" zip = "2" dirs = "5" +sysinfo = "0.32" [target.'cfg(windows)'.dependencies] winreg = "0.52" -windows-sys = { version = "0.52", features = ["Win32_Networking_WinInet", "Win32_Foundation"] } +windows-sys = { version = "0.52", features = ["Win32_Networking_WinInet", "Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" diff --git a/src-tauri/binaries/ThingHK.exe b/src-tauri/binaries/ThingHK.exe index 3b92245..79fcfb0 100644 Binary files a/src-tauri/binaries/ThingHK.exe and b/src-tauri/binaries/ThingHK.exe differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cba5acc..0043f92 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ +use std::sync::Arc; use tauri::Manager; mod download_engine; mod logger; mod mihomo_manager; mod monitor_kernel; +mod network_monitor; mod process_manager; use download_engine::{ @@ -23,9 +25,12 @@ use mihomo_manager::{ proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager, }; use monitor_kernel::{ - monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_start, monitor_status, + monitor_elevate_self, monitor_get_elevate_on_launch, monitor_get_hardware_config, + monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_set_elevate_on_launch, + monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status, monitor_stop, MonitorKernel, }; +use network_monitor::{network_monitor_status, NetworkMonitor}; use process_manager::{ get_all_process_status, get_process_status, start_monitoring_thread, start_process, stop_all_processes, stop_process, ProcessManager, @@ -41,12 +46,15 @@ fn quit_app( state: tauri::State<'_, ProcessManager>, mihomo: tauri::State<'_, MihomoManager>, engine: tauri::State<'_, DownloadEngine>, + monitor: tauri::State<'_, MonitorKernel>, app: tauri::AppHandle, ) { // 退出前清理系统代理,避免遗留导致网络问题 mihomo.cleanup_on_exit(); // 退出前保存下载引擎状态 engine.cleanup_on_exit(); + // 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown) + tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app)); // 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止) state.stop_all(); // 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径 @@ -104,9 +112,16 @@ pub fn run() { monitor_kernel_info, monitor_status, monitor_start, + monitor_start_elevated, + monitor_elevate_self, monitor_stop, monitor_get_status, monitor_get_snapshot, + monitor_get_elevate_on_launch, + monitor_set_elevate_on_launch, + monitor_get_hardware_config, + monitor_set_hardware_config, + network_monitor_status, downloader_get_tasks, downloader_add_task, downloader_pause_task, @@ -134,6 +149,12 @@ pub fn run() { .app_data_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")); + // 永久提权检查:如果标志已设置且当前非管理员,以管理员权限重启自身并退出 + // 必须在所有模块初始化之前执行(此时无资源需要清理) + if monitor_kernel::check_and_relaunch_if_needed(&app_data_dir) { + std::process::exit(0); + } + let mihomo = MihomoManager::new(app_data_dir.clone()); app.manage(mihomo); @@ -141,6 +162,12 @@ pub fn run() { let monitor = MonitorKernel::new(app_data_dir.clone()); app.manage(monitor); + // 初始化 NetworkMonitor(网速监控,独立于 ThingHK Kernel) + // 网速采样不依赖提权,应用启动即开始 + let network_monitor = Arc::new(NetworkMonitor::new()); + app.manage(network_monitor.clone()); + network_monitor.start(app.handle().clone()); + // 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/ let engine = DownloadEngine::new( app_data_dir.join("downloader"), @@ -179,6 +206,9 @@ pub fn run() { if let Some(engine) = app.try_state::() { engine.cleanup_on_exit(); } + if let Some(monitor) = app.try_state::() { + tauri::async_runtime::block_on(monitor.cleanup_on_exit(app)); + } if let Some(pm) = app.try_state::() { pm.stop_all(); } @@ -241,6 +271,9 @@ pub fn run() { if let Some(engine) = app.try_state::() { engine.cleanup_on_exit(); } + if let Some(monitor) = app.try_state::() { + tauri::async_runtime::block_on(monitor.cleanup_on_exit(app)); + } if let Some(pm) = app.try_state::() { pm.stop_all(); } diff --git a/src-tauri/src/monitor_kernel.rs b/src-tauri/src/monitor_kernel.rs index e614610..b114ba9 100644 --- a/src-tauri/src/monitor_kernel.rs +++ b/src-tauri/src/monitor_kernel.rs @@ -12,6 +12,7 @@ use std::fs; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -35,6 +36,155 @@ const READY_POLL_INTERVAL_MS: u64 = 500; /// 冷启动就绪总超时(阶段一实测冷启动约 5s,留 5s 余量) const READY_TIMEOUT_MS: u64 = 10_000; +// ===================== Windows 提权启动 FFI ===================== + +/// ShellExecuteW 的 nShowCmd 取值:隐藏窗口。 +/// ThingHK.exe 是控制台程序,提权启动时隐藏控制台窗口避免黑框。 +#[cfg(windows)] +const SW_HIDE: i32 = 0; + +// ShellExecuteW 返回值 <= 32 表示错误(HINSTANCE 被强制为 isize)。 +// 1223 = ERROR_CANCELLED(用户取消了 UAC 弹窗)。 +#[cfg(windows)] +#[link(name = "shell32")] +extern "system" { + fn ShellExecuteW( + hwnd: *mut std::ffi::c_void, + lp_operation: *const u16, + lp_file: *const u16, + lp_parameters: *const u16, + lp_directory: *const u16, + n_show_cmd: i32, + ) -> isize; +} + +/// 以管理员权限启动可执行文件(弹 UAC)。 +/// 通过 ShellExecuteW "runas" 动词实现,返回的进程句柄不可用(跨权限级别), +/// 因此提权后的进程不加入 ProcessManager,停止时通过 Kernel 的 /shutdown 接口。 +#[cfg(windows)] +fn shell_execute_elevated(file: &str, params: &str, dir: Option<&str>) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + + fn to_wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() + } + + let operation = to_wide("runas"); + let file_w = to_wide(file); + let params_w = to_wide(params); + let dir_w = dir.map(to_wide); + let dir_ptr = dir_w.as_ref().map_or(std::ptr::null(), |d| d.as_ptr()); + + let result = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + operation.as_ptr(), + file_w.as_ptr(), + params_w.as_ptr(), + dir_ptr, + SW_HIDE, + ) + }; + + if result <= 32 { + // 1223 = ERROR_CANCELLED:用户点了"否"或关闭了 UAC 弹窗 + if result == 1223 { + return Err("用户取消了 UAC 提权".into()); + } + Err(format!("ShellExecuteW 失败,错误码: {}", result)) + } else { + Ok(()) + } +} + +/// 检测当前 Thing 进程是否以管理员权限运行。 +/// 用于"永久提权"模式:Thing 本身是管理员时,ThingHK 子进程自动继承权限, +/// 无需 ShellExecuteW "runas",ProcessManager 可直接管理(同权限级别可 kill)。 +#[cfg(windows)] +pub fn is_thing_elevated() -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + unsafe { + let mut token: HANDLE = 0; + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return false; + } + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut ret_len = 0u32; + let success = GetTokenInformation( + token, + TokenElevation, + &mut elevation as *mut _ as *mut _, + std::mem::size_of::() as u32, + &mut ret_len, + ); + CloseHandle(token); + success != 0 && elevation.TokenIsElevated != 0 + } +} + +#[cfg(not(windows))] +pub fn is_thing_elevated() -> bool { + false +} + +// ===================== 永久提权标志持久化 ===================== + +/// 永久提权标志文件路径: {app_data_dir}/monitor/elevate.json +/// 内容: { "enabled": true } +fn elevate_flag_path(app_data_dir: &std::path::Path) -> PathBuf { + app_data_dir.join("monitor").join("elevate.json") +} + +/// 读取永久提权标志 +fn read_elevate_flag(flag_path: &std::path::Path) -> bool { + match fs::read_to_string(flag_path) { + Ok(content) => serde_json::from_str::(&content) + .ok() + .and_then(|v| v.get("enabled").and_then(|e| e.as_bool())) + .unwrap_or(false), + Err(_) => false, + } +} + +/// 写入永久提权标志 +fn write_elevate_flag(flag_path: &std::path::Path, enabled: bool) -> Result<(), String> { + if let Some(parent) = flag_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + let content = serde_json::json!({ "enabled": enabled }).to_string(); + fs::write(flag_path, content).map_err(|e| format!("写入提权标志失败: {}", e)) +} + +/// 启动时检查永久提权标志,如果已设置且当前非管理员,则 ShellExecute "runas" 重启自身。 +/// 在 `setup` 早期调用(MonitorKernel 初始化之前)。 +/// 返回 true 表示已触发提权重启,调用方应立即退出当前进程。 +pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool { + let flag_path = elevate_flag_path(app_data_dir); + if !read_elevate_flag(&flag_path) { + return false; + } + if is_thing_elevated() { + return false; // 已是管理员,无需重启 + } + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(_) => return false, + }; + eprintln!("[monitor] 检测到永久提权标志,以管理员权限重启 Thing"); + match shell_execute_elevated(&exe.to_string_lossy(), "", None) { + Ok(()) => true, + Err(e) => { + eprintln!("[monitor] 永久提权重启失败(用户可能取消了 UAC): {}", e); + false + } + } +} + // ===================== 数据结构 ===================== /// Kernel /status 响应(与 ThingHK Contracts.cs KernelStatus 对应) @@ -73,6 +223,10 @@ pub struct MonitorStatus { pub ready: bool, pub sensor_count: u32, pub restart_count: u32, + /// 是否为提权模式(通过 UAC 以管理员权限启动) + pub elevated: bool, + /// Thing 自身是否以管理员权限运行(永久提权模式) + pub thing_elevated: bool, } // ===================== MonitorKernel ===================== @@ -91,6 +245,8 @@ pub struct MonitorKernel { sub_handle: Arc>>>, /// 监听 process-status-changed 的句柄,用于在 stop 时取消监听 listener_ids: Arc>>, + /// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown) + elevated: Arc, } impl MonitorKernel { @@ -108,9 +264,25 @@ impl MonitorKernel { .unwrap_or_else(|_| Client::new()), sub_handle: Arc::new(Mutex::new(None)), listener_ids: Arc::new(Mutex::new(Vec::new())), + elevated: Arc::new(AtomicBool::new(false)), } } + /// 当前是否处于提权模式 + pub fn is_elevated(&self) -> bool { + self.elevated.load(Ordering::SeqCst) + } + + /// 永久提权标志是否已启用(后续启动自动触发 UAC) + pub fn elevate_on_launch(&self) -> bool { + read_elevate_flag(&self.root.join("elevate.json")) + } + + /// 设置/清除永久提权标志 + pub fn set_elevate_on_launch(&self, enabled: bool) -> Result<(), String> { + write_elevate_flag(&self.root.join("elevate.json"), enabled) + } + fn cores_dir(&self) -> PathBuf { self.root.join("cores") } @@ -120,15 +292,23 @@ impl MonitorKernel { fn kernel_url(&self) -> String { format!("http://127.0.0.1:{}", KERNEL_PORT) } + /// 硬件监控配置文件路径:{app_data_dir}/monitor/hardware-config.json + pub fn hardware_config_path(&self) -> PathBuf { + self.root.join("hardware-config.json") + } - /// 确保内核就位:若 cores/ 无内核,从资源目录复制 + /// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制 pub fn prepare_kernel(&self, app: &AppHandle) -> Result { let kernel = self.kernel_path(); - if !kernel.exists() { - if let Ok(res) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) { - if res.exists() { + if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) { + if src.exists() { + let need_copy = !kernel.exists() + || fs::metadata(&src) + .and_then(|s| fs::metadata(&kernel).map(|d| s.len() != d.len())) + .unwrap_or(true); + if need_copy { fs::create_dir_all(self.cores_dir()).ok(); - fs::copy(&res, &kernel).map_err(|e| format!("复制 Kernel 失败: {}", e))?; + fs::copy(&src, &kernel).map_err(|e| format!("复制 Kernel 失败: {}", e))?; } } } @@ -155,7 +335,8 @@ impl MonitorKernel { "serve".into(), "--port".into(), KERNEL_PORT.to_string(), - "--basic".into(), + "--config".into(), + self.hardware_config_path().to_string_lossy().to_string(), ], cwd: Some(self.cores_dir().to_string_lossy().to_string()), name: "ThingHK".into(), @@ -273,6 +454,12 @@ impl MonitorKernel { // 重连前先确认 Kernel 是否还活着(可能已被 stop) if !self.is_kernel_alive().await { eprintln!("[monitor] Kernel 已停止,退出 SSE 循环"); + // 提权模式下 Kernel 崩溃/退出后重置 elevated 标志, + // 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启 + if self.is_elevated() { + self.elevated.store(false, Ordering::SeqCst); + eprintln!("[monitor] 提权 Kernel 已退出,重置 elevated 标志"); + } break; } } @@ -345,7 +532,14 @@ impl MonitorKernel { /// 注册 process-status-changed 监听:当 Kernel 进程被自动重启恢复 Running 时, /// 自动重新启动 SSE 订阅(实现崩溃恢复后的自愈)。 + /// 注册前先清理旧 listener,防止重复注册导致多次 spawn SSE 订阅。 pub async fn register_auto_reconnect(self: Self, app: AppHandle) { + // 清理旧 listener(防止重复注册) + let old_ids = self.listener_ids.lock().await.drain(..).collect::>(); + for id in old_ids { + app.unlisten(id); + } + let app_clone = app.clone(); let this = self.clone(); let id = app.listen("process-status-changed", move |event| { @@ -371,14 +565,67 @@ impl MonitorKernel { /// 启动 Kernel 进程并开始 SSE 订阅(命令和 setup 自动启动共用)。 /// ProcessManager 通过 app.state 获取,无需外部传入。 + /// + /// 启动前先检测端口 8730 是否已有 Kernel 在运行(可能是上次提权遗留的进程): + /// - 如果是提权 Kernel(isAdmin=true):直接接管,设置 elevated=true,跳过 ProcessManager 启动 + /// - 如果是普通权限 Kernel:调用 /shutdown 停止后重新启动 + /// + /// 永久提权模式(Thing 自身是管理员): + /// - ThingHK 通过 ProcessManager 启动,子进程继承管理员权限 + /// - 设置 elevated=true 表示 ThingHK 在管理员权限下运行 + /// - ProcessManager 可直接 kill(同权限级别),无需 /shutdown 接口 pub async fn start_with_subscription( &self, app: &AppHandle, ) -> Result { + let thing_elevated = is_thing_elevated(); + + // 检测是否已有 Kernel 在运行(端口被占用) + if let Ok(status) = self.get_status().await { + if status.ready { + if thing_elevated { + // Thing 是管理员:停止已有 Kernel(无论什么权限),用 ProcessManager 重启以继承权限 + eprintln!("[monitor] Thing 已提权,重启 ThingHK 以继承管理员权限"); + let _ = self.shutdown_kernel().await; + tokio::time::sleep(Duration::from_millis(500)).await; + } else if status.is_admin { + // Thing 非管理员,但已有提权 Kernel:直接接管 + eprintln!("[monitor] 检测到已有提权 Kernel 运行中,直接接管"); + self.elevated.store(true, Ordering::SeqCst); + let kernel = self.clone(); + kernel.clone().register_auto_reconnect(app.clone()).await; + let app_clone = app.clone(); + let handle = tauri::async_runtime::spawn(async move { + kernel.start_subscription(app_clone).await; + }); + *self.sub_handle.lock().await = Some(handle); + // 提权模式下无 ProcessInfo,返回一个占位的 + return Ok(crate::process_manager::ProcessInfo { + id: PROCESS_ID.into(), + name: "ThingHK".into(), + status: crate::process_manager::ProcessStatus::Running, + pid: None, + restart_count: 0, + }); + } else { + // Thing 非管理员,已有普通 Kernel:先停止 + eprintln!("[monitor] 检测到已有普通权限 Kernel 运行中,先停止再重启"); + let _ = self.shutdown_kernel().await; + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } + let pm = app.state::(); let params = self.prepare_for_start(app)?; let info = pm.start(params)?; + // Thing 是管理员时,ThingHK 继承权限,标记 elevated(仍由 ProcessManager 管理) + if thing_elevated { + self.elevated.store(true, Ordering::SeqCst); + eprintln!("[monitor] ThingHK 已以管理员权限启动(继承自 Thing)"); + } + // 用 self 的 clone(共享 Arc 状态)启动订阅, // 确保 register_auto_reconnect 注册的 listener 与 sub_handle 共享, // stop_subscription 时才能正确清理 listener。 @@ -392,6 +639,117 @@ impl MonitorKernel { Ok(info) } + + /// 以管理员权限启动 Kernel(弹 UAC)。 + /// + /// 流程: + /// 1. 停止当前普通权限 Kernel(如果在运行)+ 取消 SSE 订阅 + /// 2. 等待端口释放 + /// 3. ShellExecuteW "runas" 启动提权 Kernel(隐藏控制台窗口) + /// 4. 标记 elevated=true,注册自动重连 + 启动 SSE 订阅 + /// + /// 提权模式限制(与普通模式的差异): + /// - 进程不加入 ProcessManager(跨权限级别句柄不可用) + /// - 停止通过 Kernel 的 POST /shutdown 接口(普通权限无法 TerminateProcess 管理员进程) + /// - 崩溃不支持自动重启(ProcessManager 看不到此进程) + /// - pid 不可查询(返回 None) + pub async fn start_elevated(&self, app: &AppHandle) -> Result<(), String> { + // 1. 停止当前 Kernel(可能是普通权限,也可能是已提权的) + self.stop_subscription(app).await; + let pm = app.state::(); + if pm.get_status(PROCESS_ID).is_some() { + let _ = pm.stop(PROCESS_ID); + } + if self.elevated.load(Ordering::SeqCst) { + // 已是提权模式,先通过 /shutdown 停止旧进程 + let _ = self.shutdown_kernel().await; + self.elevated.store(false, Ordering::SeqCst); + } + + // 2. 准备 Kernel 二进制 + let info = self.prepare_kernel(app)?; + if !info.exists { + return Err(format!( + "ThingHK Kernel 未安装。请将 ThingHK.exe 放置到 src-tauri/binaries/ 后重新构建。" + )); + } + + // 3. 等待端口释放(pm.stop 异步 kill 后 Windows 端口释放有延迟) + tokio::time::sleep(Duration::from_millis(800)).await; + + // 4. ShellExecute "runas" 启动提权 Kernel + // 提权模式下控制台窗口隐藏(SW_HIDE),stderr 不可见, + // 通过 --log-file 将日志写入文件,便于排查崩溃问题 + let log_path = self.root.join("kernel-elevated.log"); + let config_path = self.hardware_config_path(); + let params = format!( + "serve --port {} --config \"{}\" --log-file \"{}\"", + KERNEL_PORT, + config_path.to_string_lossy(), + log_path.to_string_lossy() + ); + #[cfg(windows)] + { + shell_execute_elevated( + &info.path, + ¶ms, + Some(&self.cores_dir().to_string_lossy()), + )?; + } + #[cfg(not(windows))] + { + let _ = params; + return Err("提权启动仅支持 Windows".into()); + } + + self.elevated.store(true, Ordering::SeqCst); + eprintln!("[monitor] 提权启动已发起,等待 Kernel ready"); + + // 5. 启动 SSE 订阅(提权模式不注册 register_auto_reconnect: + // 提权进程不归 ProcessManager 管,process-status-changed 事件不会触发, + // 注册了反而可能在其他进程状态变化时误触发 SSE 重连) + let kernel = self.clone(); + let app_clone = app.clone(); + let handle = tauri::async_runtime::spawn(async move { + kernel.start_subscription(app_clone).await; + }); + *self.sub_handle.lock().await = Some(handle); + + Ok(()) + } + + /// 调用 Kernel 的 POST /shutdown 接口,让提权 Kernel 自行退出。 + /// 普通权限 Tauri 无法 TerminateProcess 管理员进程,必须通过 HTTP 优雅关闭。 + async fn shutdown_kernel(&self) -> Result<(), String> { + let url = format!("{}/shutdown", self.kernel_url()); + let resp = self + .client + .post(&url) + .timeout(Duration::from_secs(3)) + .send() + .await + .map_err(|e| format!("请求 /shutdown 失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("/shutdown 返回 HTTP {}", resp.status())); + } + // 等待 Kernel 进程退出 + 端口释放 + tokio::time::sleep(Duration::from_millis(500)).await; + Ok(()) + } + + /// 程序退出时清理:取消 SSE 订阅 + 停止提权 Kernel。 + /// 普通权限 Kernel 和永久提权模式(Thing 也是管理员)由 ProcessManager.stop_all 统一清理; + /// 仅"提权 ThingHK"模式(Thing 普通、ThingHK 管理员)需通过 /shutdown 接口停止, + /// 否则程序关闭后提权 Kernel 会残留并占用端口,导致下次启动端口冲突。 + pub async fn cleanup_on_exit(&self, app: &AppHandle) { + self.stop_subscription(app).await; + // 仅当 ThingHK 提权但 Thing 未提权时才需要 /shutdown(ProcessManager 无法 kill 管理员进程) + if self.is_elevated() && !is_thing_elevated() { + eprintln!("[monitor] 退出清理:停止提权 Kernel(/shutdown)"); + let _ = self.shutdown_kernel().await; + self.elevated.store(false, Ordering::SeqCst); + } + } } /// 解析 SSE 事件文本,提取 data: 字段的 JSON 内容。 @@ -425,12 +783,29 @@ pub async fn monitor_status( state: tauri::State<'_, MonitorKernel>, pm: tauri::State<'_, ProcessManager>, ) -> Result { + let thing_elevated = is_thing_elevated(); + + // 提权模式(仅 ThingHK 提权,Thing 普通):进程不归 ProcessManager 管 + if state.is_elevated() && !thing_elevated { + let kernel_status = state.get_status().await.ok(); + let running = kernel_status.is_some(); + return Ok(MonitorStatus { + running, + pid: None, + ready: kernel_status.as_ref().map(|s| s.ready).unwrap_or(false), + sensor_count: kernel_status.as_ref().map(|s| s.sensor_count).unwrap_or(0), + restart_count: 0, + elevated: true, + thing_elevated: false, + }); + } + + // 普通权限模式 或 永久提权模式(Thing 管理员):通过 ProcessManager 获取进程状态 let info = pm.get_status(PROCESS_ID); let running = info .as_ref() .map(|i| matches!(i.status, crate::process_manager::ProcessStatus::Running)) .unwrap_or(false); - // 只在运行时查询 Kernel /status(避免未运行时发起无意义的 HTTP 请求) let kernel_status = if running { state.get_status().await.ok() } else { None }; Ok(MonitorStatus { running, @@ -438,6 +813,9 @@ pub async fn monitor_status( ready: kernel_status.as_ref().map(|s| s.ready).unwrap_or(false), sensor_count: kernel_status.as_ref().map(|s| s.sensor_count).unwrap_or(0), restart_count: info.as_ref().map(|i| i.restart_count).unwrap_or(0), + // 永久提权模式下 elevated=true(ThingHK 在管理员权限运行) + elevated: thing_elevated, + thing_elevated, }) } @@ -449,6 +827,50 @@ pub async fn monitor_start( state.start_with_subscription(&app).await } +/// 以管理员权限启动 Kernel(弹 UAC)。 +/// 提权后的进程不归 ProcessManager 管,停止需通过 monitor_stop(内部走 /shutdown)。 +#[tauri::command] +pub async fn monitor_start_elevated( + state: tauri::State<'_, MonitorKernel>, + app: AppHandle, +) -> Result<(), String> { + state.start_elevated(&app).await +} + +/// 永久提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。 +/// - 已是管理员:仅设置标志,不重启 +/// - 非管理员:设置标志 + ShellExecute "runas" 重启 + 退出当前进程 +#[tauri::command] +pub async fn monitor_elevate_self( + state: tauri::State<'_, MonitorKernel>, + app: AppHandle, +) -> Result<(), String> { + #[cfg(windows)] + { + // 持久化标志:后续启动时 setup 检测到此标志会自动 ShellExecute "runas" + state.set_elevate_on_launch(true)?; + + if is_thing_elevated() { + // 已是管理员,只需设置标志,无需重启 + eprintln!("[monitor] Thing 已是管理员,仅设置永久提权标志"); + return Ok(()); + } + + let exe = std::env::current_exe().map_err(|e| format!("获取当前路径失败: {}", e))?; + let exe_str = exe.to_string_lossy().to_string(); + eprintln!("[monitor] 永久提权:以管理员权限重启 Thing"); + shell_execute_elevated(&exe_str, "", None)?; + // 退出当前进程(非提权),新的提权进程会接管 + app.exit(0); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (state, app); + Err("永久提权仅支持 Windows".into()) + } +} + #[tauri::command] pub async fn monitor_stop( state: tauri::State<'_, MonitorKernel>, @@ -456,7 +878,15 @@ pub async fn monitor_stop( app: AppHandle, ) -> Result<(), String> { state.stop_subscription(&app).await; - pm.stop(PROCESS_ID) + // 仅"提权 ThingHK"模式(Thing 普通、ThingHK 管理员)需要 /shutdown + // 永久提权模式(Thing 管理员)和普通模式都用 ProcessManager kill + if state.is_elevated() && !is_thing_elevated() { + state.shutdown_kernel().await?; + state.elevated.store(false, Ordering::SeqCst); + Ok(()) + } else { + pm.stop(PROCESS_ID) + } } #[tauri::command] @@ -468,3 +898,68 @@ pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Resul pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result { state.get_snapshot().await } + +/// 查询永久提权标志是否已启用 +#[tauri::command] +pub fn monitor_get_elevate_on_launch( + state: tauri::State<'_, MonitorKernel>, +) -> bool { + state.elevate_on_launch() +} + +/// 设置/清除永久提权标志。 +/// 清除后,下次启动不再触发 UAC(当前会话权限不变)。 +#[tauri::command] +pub fn monitor_set_elevate_on_launch( + state: tauri::State<'_, MonitorKernel>, + enabled: bool, +) -> Result<(), String> { + state.set_elevate_on_launch(enabled) +} + +/// 查询硬件监控配置(透传 Kernel GET /config/hardware)。 +/// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。 +#[tauri::command] +pub async fn monitor_get_hardware_config( + state: tauri::State<'_, MonitorKernel>, +) -> Result { + let url = format!("{}/config/hardware", state.kernel_url()); + let resp = state + .client + .get(&url) + .timeout(Duration::from_secs(5)) + .send() + .await + .map_err(|e| format!("请求 /config/hardware 失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("/config/hardware 返回 HTTP {}", resp.status())); + } + resp.json().await.map_err(|e| format!("解析配置失败: {}", e)) +} + +/// 更新硬件监控配置(透传 Kernel POST /config/hardware)。 +/// 传感器类型过滤热生效;硬件开关变化返回 restartRequired=true,需重启 Kernel。 +#[tauri::command] +pub async fn monitor_set_hardware_config( + state: tauri::State<'_, MonitorKernel>, + hardware: Option>, + sensor_types: Option>, +) -> Result { + let url = format!("{}/config/hardware", state.kernel_url()); + let body = serde_json::json!({ + "hardware": hardware, + "sensorTypes": sensor_types, + }); + let resp = state + .client + .post(&url) + .timeout(Duration::from_secs(5)) + .json(&body) + .send() + .await + .map_err(|e| format!("请求 POST /config/hardware 失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("POST /config/hardware 返回 HTTP {}", resp.status())); + } + resp.json().await.map_err(|e| format!("解析响应失败: {}", e)) +} diff --git a/src-tauri/src/network_monitor.rs b/src-tauri/src/network_monitor.rs new file mode 100644 index 0000000..0b85c15 --- /dev/null +++ b/src-tauri/src/network_monitor.rs @@ -0,0 +1,123 @@ +// network_monitor.rs — 网速监控模块 +// +// 职责: +// - 后台每秒采样所有网络接口的收发字节数 +// - 计算与上一次采样的差值(速率,bytes/s) +// - 聚合所有接口得到总下载/上传速率 +// - 通过 Tauri 事件 "monitor-network" 推送给前端 +// +// 设计说明: +// - 不依赖 ThingHK(ThingHK 当前不支持网络传感器扩展) +// - 使用 sysinfo crate 的 Networks(轻量、跨平台) +// - 采样间隔与 Kernel 快通道一致(1s),保证 UI 曲线流畅 +// - 前端通过监听事件更新 UI,无需主动 invoke + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use serde::Serialize; +use sysinfo::Networks; +use tauri::{AppHandle, Emitter}; + +/// 推送给前端的网速数据 +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct NetworkSpeed { + /// 下载速率(bytes/s),所有接口聚合 + pub download_bps: u64, + /// 上传速率(bytes/s),所有接口聚合 + pub upload_bps: u64, + /// 时间戳(ms) + pub timestamp: u64, +} + +/// 网速监控器:后台任务采样并 emit 事件。 +/// +/// 使用方式: +/// 1. app.manage(NetworkMonitor::new()) 注册到 Tauri State +/// 2. 在 setup 中调用 NetworkMonitor::start(&app) 启动后台采样 +/// 3. 前端监听 "monitor-network" 事件 +pub struct NetworkMonitor { + /// 是否正在运行(用于 start/stop 幂等控制) + running: Arc, +} + +impl NetworkMonitor { + pub fn new() -> Self { + Self { + running: Arc::new(AtomicBool::new(false)), + } + } + + /// 启动后台网速采样任务。幂等:重复调用不会启动多个任务。 + pub fn start(self: Arc, app: AppHandle) { + if self.running.swap(true, Ordering::SeqCst) { + return; // 已在运行 + } + + let running = self.running.clone(); + tauri::async_runtime::spawn(async move { + // sysinfo Networks 需要可变借用,放在独立线程的本地变量 + let mut networks = Networks::new_with_refreshed_list(); + // 首次采样仅记录基线,不 emit(速率需要两次采样差值) + let mut last_rx: u64 = networks.iter().map(|(_, data)| data.total_received()).sum(); + let mut last_tx: u64 = networks.iter().map(|(_, data)| data.total_transmitted()).sum(); + + // 1s 间隔采样(与 Kernel 快通道一致) + let mut interval = tokio::time::interval(Duration::from_secs(1)); + // 跳过第一次立即触发(已记录基线) + interval.tick().await; + + loop { + interval.tick().await; + if !running.load(Ordering::SeqCst) { + break; + } + + // 刷新网络数据(sysinfo 0.32: refresh() 刷新接口列表和统计数据) + networks.refresh(); + + let cur_rx: u64 = networks.iter().map(|(_, data)| data.total_received()).sum(); + let cur_tx: u64 = networks.iter().map(|(_, data)| data.total_transmitted()).sum(); + + // 速率 = 当前累计 - 上次累计(sysinfo 的 total_received 是单调递增的累计值) + // 若接口移除/添加可能导致差值为负或异常大,做饱和减法保护 + let download_bps = cur_rx.saturating_sub(last_rx); + let upload_bps = cur_tx.saturating_sub(last_tx); + last_rx = cur_rx; + last_tx = cur_tx; + + let speed = NetworkSpeed { + download_bps, + upload_bps, + timestamp: chrono::Local::now().timestamp_millis() as u64, + }; + + // emit 给前端(失败忽略:窗口可能未就绪) + let _ = app.emit("monitor-network", &speed); + } + eprintln!("[network] 网速采样任务已退出"); + }); + } + + /// 停止后台采样 + #[allow(dead_code)] + pub fn stop(&self) { + self.running.store(false, Ordering::SeqCst); + } +} + +impl Default for NetworkMonitor { + fn default() -> Self { + Self::new() + } +} + +/// Tauri 命令:获取网速监控是否运行 +#[tauri::command] +pub fn network_monitor_status( + state: tauri::State<'_, NetworkMonitor>, +) -> bool { + state.running.load(Ordering::SeqCst) +} diff --git a/src/modules/monitor/MonitorModule.vue b/src/modules/monitor/MonitorModule.vue index 7ff33d6..83b92d6 100644 --- a/src/modules/monitor/MonitorModule.vue +++ b/src/modules/monitor/MonitorModule.vue @@ -2,10 +2,13 @@ import { Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick, Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle, - ShieldCheck, ShieldAlert, Zap, Thermometer, Clock, ChevronDown, + ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown, + KeyRound, ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks, } from '@lucide/vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' +import { appDataDir } from '@tauri-apps/api/path' +import { revealItemInDir } from '@tauri-apps/plugin-opener' import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore' import { useModuleTabs } from '@/lib/useModuleTabs' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -16,6 +19,9 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion' import { Progress } from '@/components/ui/progress' import { Separator } from '@/components/ui/separator' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' +import { Checkbox } from '@/components/ui/checkbox' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' const store = useMonitorStore() @@ -31,9 +37,9 @@ const tabsListRef = useModuleTabs(activeTab, [ // 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次) const HISTORY_LEN = 30 const cpuTempHistory = ref([]) -const cpuLoadHistory = ref([]) -const memLoadHistory = ref([]) -const gpuLoadHistory = ref([]) +const gpuTempHistory = ref([]) +const netDownHistory = ref([]) +const netUpHistory = ref([]) function pushHistory(buf: typeof cpuTempHistory, v: number | null) { if (v == null || !isFinite(v)) return @@ -41,31 +47,112 @@ function pushHistory(buf: typeof cpuTempHistory, v: number | null) { if (buf.value.length > HISTORY_LEN) buf.value.shift() } -// 监听快照变化,提取关键指标入历史 -watch(() => store.snapshot, (snap) => { - if (!snap) return - pushHistory(cpuTempHistory, store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' })) - pushHistory(cpuLoadHistory, store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' })) - // Memory 分组有两条同名 "Memory" 负载传感器(Virtual Memory + Total Memory),优先取 Total Memory - pushHistory(memLoadHistory, store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' })) - // Intel GPU 3D 负载传感器名为 "D3D 3D";部分 GPU 为 "GPU Core" - pushHistory(gpuLoadHistory, - store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ?? - store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ?? - store.findSensorValue('gpuintel', { type: 'load' }), - ) -}, { deep: false }) - // ===== 关键指标 computed ===== + +/** GPU 分组 id(兼容 intel/amd/nvidia) */ +const gpuGroupId = computed(() => { + if (store.groupById['gpuintel']) return 'gpuintel' + if (store.groupById['gpuamd']) return 'gpuamd' + if (store.groupById['gpunvidia']) return 'gpunvidia' + return null +}) + +const cpuModel = computed(() => store.groupById['cpu']?.sensors[0]?.hardwareName ?? null) const cpuTemp = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' })) const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' })) +const cpuPower = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'power' })) + +const gpuModel = computed(() => { + const gid = gpuGroupId.value + return gid ? store.groupById[gid]?.sensors[0]?.hardwareName ?? null : null +}) +const gpuTemp = computed(() => { + const gid = gpuGroupId.value + if (!gid) return null + // GPU 温度传感器命名因厂商而异,按优先级匹配 + return store.findSensorValue(gid, { name: 'GPU Core', type: 'temperature' }) + ?? store.findSensorValue(gid, { name: 'GPU Temperature', type: 'temperature' }) + ?? store.findSensorValue(gid, { type: 'temperature' }) +}) +const gpuLoad = computed(() => { + const gid = gpuGroupId.value + if (!gid) return null + return store.findSensorValue(gid, { name: 'D3D 3D', type: 'load' }) + ?? store.findSensorValue(gid, { name: 'GPU Core', type: 'load' }) + ?? store.findSensorValue(gid, { type: 'load' }) +}) +const gpuPower = computed(() => { + const gid = gpuGroupId.value + if (!gid) return null + return store.findSensorValue(gid, { name: 'GPU Power', type: 'power' }) + ?? store.findSensorValue(gid, { type: 'power' }) +}) + const memLoad = computed(() => store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' })) -const gpuLoad = computed(() => - store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ?? - store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ?? - store.findSensorValue('gpuintel', { type: 'load' }), -) -const storageTemp = computed(() => store.findSensorValue('storage', { type: 'temperature' })) +const memUsedGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Used', type: 'data' })) +const memAvailGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Available', type: 'data' })) +const memTotalGB = computed(() => { + const used = memUsedGB.value + const avail = memAvailGB.value + if (used != null && avail != null) return used + avail + return null +}) +/** 物理内存条型号列表(排除 Virtual Memory / Total Memory 虚拟分组) */ +const memModuleModels = computed(() => { + const g = store.groupById['memory'] + if (!g) return [] + const models = new Set() + for (const s of g.sensors) { + if (s.hardwareName !== 'Virtual Memory' && s.hardwareName !== 'Total Memory') { + models.add(s.hardwareName) + } + } + return Array.from(models) +}) + +/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量) */ +interface StorageDrive { + name: string + temp: number | null + usedPct: number | null + totalGB: number | null + usedGB: number | null +} +const storageDrives = computed(() => { + const g = store.groupById['storage'] + if (!g) return [] + const byHw = new Map() + for (const s of g.sensors) { + if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, []) + byHw.get(s.hardwareName)!.push(s) + } + return Array.from(byHw.entries()).map(([hw, sensors]) => { + const temp = sensors.find(s => s.type === 'temperature')?.value ?? null + const usedPct = sensors.find(s => s.name === 'Used Space')?.value ?? null + const totalGB = sensors.find(s => s.name === 'Total Space')?.value ?? null + const freeGB = sensors.find(s => s.name === 'Free Space')?.value ?? null + const usedGB = totalGB != null && freeGB != null ? totalGB - freeGB : null + return { name: hw, temp, usedPct, totalGB, usedGB } + }) +}) + +// 监听快照变化,提取温度历史(用于 CPU/GPU 温度 sparkline) +watch(() => store.snapshot, () => { + pushHistory(cpuTempHistory, cpuTemp.value) + pushHistory(gpuTempHistory, gpuTemp.value) +}, { deep: false }) + +// 监听网速事件,记录历史(用于网速 sparkline) +watch(() => store.networkSpeed, (v) => { + if (v) { + pushHistory(netDownHistory, v.downloadBps) + pushHistory(netUpHistory, v.uploadBps) + } +}, { deep: false }) + +// 网速格式化(computed 避免模板中重复调用) +const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null)) +const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null)) // ===== 工具函数 ===== @@ -92,6 +179,14 @@ function loadColor(v: number | null): string { return 'text-red-500' } +/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s) */ +function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } { + if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' } + if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' } + if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' } + return { value: bytesPerSec.toFixed(0), unit: 'B/s' } +} + /** 传感器类型 → 中文标签 */ const typeLabels: Record = { temperature: '温度', @@ -214,8 +309,151 @@ async function handleRefresh() { } } +/** 提权启动 Kernel(弹 UAC)。 + * 普通权限下 CPU 温度/时钟/存储等传感器不可读,提权后可获取完整数据。 + */ +async function handleStartElevated() { + await store.startElevated() + if (store.status?.elevated) { + toast.success('Kernel 已提权运行') + } else if (store.errorMsg) { + toast.error('提权失败', { description: store.errorMsg }) + } +} + +/** 永久提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。 + * 非管理员时进程退出;已是管理员时仅设置标志并返回。 + */ +async function handleElevateSelf() { + await store.elevateSelf() + // 非管理员时进程已退出,不会走到这里 + if (store.errorMsg) { + toast.error('永久提权失败', { description: store.errorMsg }) + } else if (store.elevateOnLaunch) { + toast.success('已启用永久提权', { description: '后续启动将自动以管理员权限运行' }) + } +} + +/** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */ +async function handleCancelElevation() { + await store.cancelElevation() + if (!store.errorMsg) { + toast.success('已取消永久提权', { description: '下次启动将以普通权限运行' }) + } +} + +// ===== Kernel 路径显示 + 复制 + 打开文件夹(参考代理模块 mihomo 信息板块) ===== +const appDataPath = ref('') +/** 路径缩略显示:只显示最后 3 段 */ +const pathShort = computed(() => { + const p = store.kernelInfo?.path + if (!p) return '—' + const parts = p.replace(/\\/g, '/').split('/') + if (parts.length <= 4) return p + return '.../' + parts.slice(-3).join('/') +}) +/** 路径完整显示:将 appData 部分替换为 %APPDATA% 便于在文件管理器地址栏打开 */ +const pathDisplay = computed(() => { + const p = store.kernelInfo?.path + if (!p) return '' + if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) { + return '%APPDATA%' + p.slice(appDataPath.value.length) + } + return p +}) +async function copyPath() { + const p = store.kernelInfo?.path + if (!p) return + try { + await navigator.clipboard.writeText(p) + toast.success('路径已复制', { description: pathDisplay.value }) + } catch { + toast.error('复制失败') + } +} +async function openFolder() { + const p = store.kernelInfo?.path + if (!p) return + try { + await revealItemInDir(p) + } catch (e) { + toast.error('打开文件夹失败', { description: String(e) }) + } +} + +// ===== 硬件监控配置 Dialog ===== +const configDialogOpen = ref(false) +/** Dialog 内编辑中的硬件开关(key → bool) */ +const editingHardware = ref>({}) +/** Dialog 内编辑中的传感器类型开关(key → bool) */ +const editingSensorTypes = ref>({}) +const savingConfig = ref(false) + +async function openConfigDialog() { + await store.fetchHardwareConfig() + if (store.hardwareConfig) { + // 深拷贝当前配置到编辑状态 + editingHardware.value = { ...store.hardwareConfig.config.hardware } + editingSensorTypes.value = { ...store.hardwareConfig.config.sensorTypes } + } + configDialogOpen.value = true +} + +async function handleSaveConfig() { + savingConfig.value = true + try { + const resp = await store.saveHardwareConfig(editingHardware.value, editingSensorTypes.value) + if (resp?.success) { + if (resp.restartRequired) { + // 检测是否启用了需要管理员权限的硬件但当前非管理员 + const needsAdmin = store.hardwareConfig?.availableHardware.some( + hw => editingHardware.value[hw.key] && hw.requiresAdmin + ) ?? false + const isElevated = store.status?.elevated || store.status?.thingElevated + + configDialogOpen.value = false + + if (needsAdmin && !isElevated) { + toast.warning('部分硬件需要管理员权限', { + description: '主板/存储等硬件需提权才能读取完整数据,建议永久提权', + duration: 6000, + }) + } else { + toast.success('配置已保存', { description: '正在重启 Kernel...' }) + } + + // 自动重启 Kernel 以应用硬件开关变更 + await store.stop() + await new Promise(r => setTimeout(r, 800)) + await store.start() + + // 等待 Kernel 就绪后拉取快照(cold start 可能需要数秒) + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 500)) + await store.refreshStatus() + if (store.status?.ready) { + await store.fetchSnapshot() + break + } + } + } else { + toast.success('配置已保存', { description: '传感器类型过滤已热生效' }) + configDialogOpen.value = false + // 热更新后立即拉取新快照以反映传感器类型过滤变化 + await store.fetchSnapshot() + } + } else if (store.errorMsg) { + toast.error('保存失败', { description: store.errorMsg }) + } + } finally { + savingConfig.value = false + } +} + // 生命周期 -onMounted(() => { +onMounted(async () => { + // 获取 appData 路径,用于将 Kernel 路径替换为 %APPDATA% 形式 + try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ } store.init() }) @@ -245,105 +483,42 @@ watch(() => store.status?.ready, (ready, prev) => { - +

正在加载...

-
- -

Kernel 未启动

- -
- -
- - - - - Kernel 状态 - - {{ stateMeta[store.connState].text }} - + +
+ + + + + CPU + {{ cpuModel ?? '--' }} - -
-
- 进程 - {{ store.status?.running ? '运行中' : '已停止' }} -
-
- PID - {{ store.status?.pid ?? '--' }} -
-
- 就绪 - {{ store.status?.ready ? '是' : '否' }} -
-
- 传感器数 - {{ store.status?.sensorCount ?? '--' }} -
-
- 重启次数 - {{ store.status?.restartCount ?? 0 }} -
-
- 事件数 - {{ store.eventCount }} -
-
- -
- - - 权限 - - - {{ store.snapshot?.isAdmin ? '管理员' : '普通' }} - -
-
- - 非管理员运行,部分传感器(电压/主板/SMBus)可能不可读。可在设置页查看降级说明。 -
-
- - -
-
-
- - - - - - CPU - - - -
+ + +
封装温度
-
+
{{ fmt(cpuTemp, 0) }}°C
- - +
+
功耗
+
{{ fmt(cpuPower, 1) }}W
+
+ +
+
-
+
总负载 {{ fmt(cpuLoad, 0) }}%
@@ -352,64 +527,203 @@ watch(() => store.status?.ready, (ready, prev) => { - - - - - 内存 + + + + + GPU + {{ gpuModel ?? '--' }} - -
-
- 内存负载 - {{ fmt(memLoad, 0) }}% + + +
+
+
核心温度
+
+ {{ fmt(gpuTemp, 0) }}°C +
- +
+
功耗
+
{{ fmt(gpuPower, 1) }}W
+
+ + +
- - - -
- - - - - - - GPU - - - -
-
- 3D 负载 + +
+
+ 3D 负载 {{ fmt(gpuLoad, 0) }}%
-
暂无 GPU 数据
- - - - + + + + + 内存 + + {{ memTotalGB != null ? fmt(memTotalGB, 0) + ' GB' : '--' }} + + + + + +
+
+
已使用
+
+ {{ fmt(memUsedGB, 1) }} / {{ fmt(memTotalGB, 1) }} GB +
+
+
+ +
+
+ 负载 + {{ fmt(memLoad, 0) }}% +
+ +
+
+
+ + + + + + 网络 + + + +
+ +
+
下载
+
+
+ {{ downSpeed.value }}{{ downSpeed.unit }} +
+ + + +
+
+ +
+
上传
+
+
+ {{ upSpeed.value }}{{ upSpeed.unit }} +
+ + + +
+
+
+
+
+ + + + + 存储 - -
温度
-
- {{ fmt(storageTemp, 0) }}°C + +
+
+
+ {{ drive.name }} + {{ fmt(drive.temp, 0) }}°C +
+
+
+ 使用率 + {{ fmt(drive.usedPct, 0) }}% +
+ +
+
+ 容量 + {{ fmt(drive.usedGB, 1) }} / {{ fmt(drive.totalGB, 1) }} GB +
+
+
+ +
暂无存储数据
+
+ + + + + + + Kernel 状态 +
+ + {{ stateMeta[store.connState].text }} + + + {{ store.snapshot?.isAdmin ? '管理员' : '普通' }} + + + 永久提权 + + + 提权 + +
+
+
+ +
+ PID: {{ store.status?.pid ?? '--' }} + 传感器: {{ store.status?.sensorCount ?? '--' }} + 重启: {{ store.status?.restartCount ?? 0 }} + 事件: {{ store.eventCount }} +
+ + + + + + + + + + +
- - + +
SSE 连接断开
@@ -419,8 +733,8 @@ watch(() => store.status?.ready, (ready, prev) => { - - + +
错误
@@ -513,6 +827,9 @@ watch(() => store.status?.ready, (ready, prev) => { +
Kernel 由 ProcessManager 统一管理,应用启动时自动拉起,崩溃自动重启。 @@ -538,9 +855,38 @@ watch(() => store.status?.ready, (ready, prev) => { {{ store.kernelInfo.exists ? '是' : '否' }}
-
+
路径 - {{ store.kernelInfo.path }} +
+ + + {{ pathDisplay || pathShort }} + {{ pathShort }} + + {{ store.kernelInfo.path ?? '' }} + + +
@@ -552,16 +898,48 @@ watch(() => store.status?.ready, (ready, prev) => { 权限与降级 - -

当前权限:{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}

+ +
+ 当前权限 +
+ + {{ store.snapshot?.isAdmin ? '管理员' : '普通' }} + + + 永久提权 + + + 提权模式 + +
+

- LibreHardwareMonitor 访问 SMBus、部分 EC 传感器、某些 GPU 传感器需要管理员权限。 - 默认非提权运行,覆盖大部分 CPU/GPU 温度(通过 OHM RPC/WMI 仍可读),牺牲部分主板/电压传感器。 + LibreHardwareMonitor 访问 CPU MSR(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。 + 普通权限下可读:CPU 负载/功率、内存、GPU 负载。不可读:CPU 温度/时钟、存储、主板/电压。

-

- - 提权策略(任务计划程序免 UAC 启动)将在阶段五实现,当前以普通权限运行。 + +

+
+ +
+ 提权:仅以管理员权限启动 ThingHK(弹 UAC)。ThingHK 崩溃不自动重启,停止走 /shutdown 接口。 +
+
+
+ +
+ 永久提权:以管理员权限重启 Thing 自身,ThingHK 子进程继承权限。标志持久化,后续每次启动自动触发 UAC;可在概览页点击"取消提权"关闭。 +
+
+
+

+ + 永久提权模式:Thing 与 ThingHK 均以管理员权限运行,ProcessManager 可直接 kill,崩溃自动重启可用。 +

+

+ + 提权模式运行中:仅 ThingHK 管理员权限,进程不归 ProcessManager 管控,崩溃不自动重启;停止通过 /shutdown 接口优雅退出。

@@ -590,4 +968,76 @@ watch(() => store.status?.ready, (ready, prev) => {
+ + + + + + + 监控项配置 + + + 勾选需要监控的硬件和传感器类型。硬件开关变更需重启 Kernel,传感器类型过滤立即生效。 + + + + +
+ +
+

硬件分组

+
+
+
+ + + 含 SuperIO/EC(自动联动) +
+ + 需管理员 + +
+
+
+ + + + +
+

传感器类型

+
+
+ + +
+
+
+
+
+ + + + + +
+
diff --git a/src/stores/monitorStore.ts b/src/stores/monitorStore.ts index 8eb3587..b790dec 100644 --- a/src/stores/monitorStore.ts +++ b/src/stores/monitorStore.ts @@ -42,6 +42,10 @@ export interface MonitorStatus { ready: boolean sensorCount: number restartCount: number + /** 是否为提权模式(通过 UAC 以管理员权限启动) */ + elevated: boolean + /** Thing 自身是否以管理员权限运行(永久提权模式) */ + thingElevated: boolean } export interface MonitorKernelInfo { @@ -50,6 +54,48 @@ export interface MonitorKernelInfo { port: number } +// ===== 硬件监控配置(与 ThingHK HardwareConfig.cs 对应) ===== + +export interface HardwareTypeInfo { + key: string + name: string + enabled: boolean + requiresAdmin: boolean +} + +export interface SensorTypeInfo { + key: string + name: string + enabled: boolean +} + +export interface HardwareConfig { + hardware: Record + sensorTypes: Record +} + +export interface HardwareConfigResponse { + config: HardwareConfig + availableHardware: HardwareTypeInfo[] + availableSensorTypes: SensorTypeInfo[] +} + +export interface HardwareConfigUpdateResponse { + success: boolean + restartRequired: boolean + configPath: string | null +} + +/** 网速数据(由 Tauri network_monitor 模块推送,独立于 ThingHK Kernel) */ +export interface NetworkSpeed { + /** 下载速率(bytes/s) */ + downloadBps: number + /** 上传速率(bytes/s) */ + uploadBps: number + /** 时间戳(ms) */ + timestamp: number +} + /** 连接状态机:与后端事件一一对应 */ export type ConnectionState = 'idle' | 'loading' | 'connected' | 'disconnected' | 'error' @@ -70,9 +116,18 @@ export const useMonitorStore = defineStore('monitor', () => { const starting = ref(false) const stopping = ref(false) + /** 网速数据(由 network_monitor 后台任务推送,独立于 Kernel) */ + const networkSpeed = ref(null) + /** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */ const initialized = ref(false) + /** 永久提权标志是否已启用(后续启动自动触发 UAC) */ + const elevateOnLaunch = ref(false) + + /** 硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */ + const hardwareConfig = ref(null) + let unlistenFns: UnlistenFn[] = [] // ===== getters ===== @@ -122,8 +177,6 @@ export const useMonitorStore = defineStore('monitor', () => { errorMsg.value = null } catch (e) { logger.error('获取状态失败: ' + e) - } finally { - initialized.value = true } return status.value } @@ -169,6 +222,96 @@ export const useMonitorStore = defineStore('monitor', () => { } } + /** 以管理员权限重启 Kernel(弹 UAC)。 + * 停止当前 Kernel → ShellExecute "runas" → 等待 ready → 重新订阅 SSE。 + * 提权后进程不归 ProcessManager 管,停止走 /shutdown 接口。 + */ + async function startElevated() { + if (starting.value) return + starting.value = true + errorMsg.value = null + try { + await invoke('monitor_start_elevated') + await refreshStatus() + } catch (e) { + errorMsg.value = String(e) + logger.error('提权启动失败: ' + e) + } finally { + starting.value = false + } + } + + /** 永久提权:以管理员权限重启 Thing 自身,ThingHK 子进程会继承管理员权限。 + * 非管理员时进程退出;已是管理员时仅设置标志并返回。 */ + async function elevateSelf() { + if (starting.value) return + starting.value = true + errorMsg.value = null + try { + await invoke('monitor_elevate_self') + // 命令返回说明已是管理员(非管理员时进程已 exit,不会走到这里) + await refreshElevateOnLaunch() + } catch (e) { + errorMsg.value = String(e) + logger.error('永久提权失败: ' + e) + } finally { + starting.value = false + } + } + + /** 刷新永久提权标志状态 */ + async function refreshElevateOnLaunch() { + try { + elevateOnLaunch.value = await invoke('monitor_get_elevate_on_launch') + } catch (e) { + logger.error('获取提权标志失败: ' + e) + } + return elevateOnLaunch.value + } + + /** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */ + async function cancelElevation() { + try { + await invoke('monitor_set_elevate_on_launch', { enabled: false }) + await refreshElevateOnLaunch() + } catch (e) { + errorMsg.value = String(e) + logger.error('取消提权失败: ' + e) + } + } + + /** 拉取硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */ + async function fetchHardwareConfig() { + try { + hardwareConfig.value = await invoke('monitor_get_hardware_config') + } catch (e) { + logger.error('获取硬件配置失败: ' + e) + } + return hardwareConfig.value + } + + /** 保存硬件监控配置。 + * 传感器类型过滤热生效;硬件开关变化返回 restartRequired=true。 + * 调用方负责根据 restartRequired 决定是否重启 Kernel。 */ + async function saveHardwareConfig( + hardware: Record, + sensorTypes: Record, + ): Promise { + try { + const resp = await invoke('monitor_set_hardware_config', { + hardware, + sensorTypes, + }) + // 刷新本地缓存的配置 + await fetchHardwareConfig() + return resp + } catch (e) { + errorMsg.value = String(e) + logger.error('保存硬件配置失败: ' + e) + return null + } + } + /** 主动拉取一次性快照(切回 tab 时立即填充,不等下一个 SSE tick) */ async function fetchSnapshot() { try { @@ -196,7 +339,8 @@ export const useMonitorStore = defineStore('monitor', () => { refreshStatus() })) unlistenFns.push(await listen('monitor-loading', () => { - // 状态由 status 轮询反映 + // 后端正在等待 Kernel ready,刷新状态以反映 running=true + refreshStatus() })) unlistenFns.push(await listen('monitor-disconnected', () => { logger.warn('SSE 断开,等待自动重连') @@ -206,6 +350,10 @@ export const useMonitorStore = defineStore('monitor', () => { errorMsg.value = e.payload?.message ?? 'Kernel 错误' logger.error('Kernel 错误: ' + JSON.stringify(e.payload)) })) + // 网速监控事件(独立于 Kernel,应用启动即推送) + unlistenFns.push(await listen('monitor-network', (e) => { + networkSpeed.value = e.payload + })) } function unsubscribe() { @@ -213,13 +361,29 @@ export const useMonitorStore = defineStore('monitor', () => { unlistenFns = [] } - /** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照 */ + /** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照。 + * 首次加载期间 initialized=false,UI 显示 loading 占位(隐藏启动按钮等), + * 避免与后端 setup 异步自动启动竞态导致按钮误显示。 */ async function init() { - await Promise.all([refreshStatus(), refreshKernelInfo()]) - await subscribe() - // 若 Kernel 已就绪,立即拉一次快照避免 UI 空白 - if (status.value?.ready) { - await fetchSnapshot() + try { + await Promise.all([refreshStatus(), refreshKernelInfo(), refreshElevateOnLaunch()]) + await subscribe() + // 若 Kernel 已就绪,立即拉一次快照避免 UI 空白 + if (status.value?.ready) { + await fetchSnapshot() + } + // 自动启动竞态修复:setup 中 start_with_subscription 是异步 spawn, + // 首次 refreshStatus 可能返回 running=false(进程还未拉起)。 + // 若状态仍为 idle,短暂重试以等待自动启动生效。 + if (!status.value?.running) { + for (let i = 0; i < 5; i++) { + await new Promise(r => setTimeout(r, 500)) + await refreshStatus() + if (status.value?.running) break + } + } + } finally { + initialized.value = true } } @@ -239,6 +403,9 @@ export const useMonitorStore = defineStore('monitor', () => { starting, stopping, initialized, + networkSpeed, + elevateOnLaunch, + hardwareConfig, // getters connState, isLive, @@ -247,7 +414,13 @@ export const useMonitorStore = defineStore('monitor', () => { findSensorValue, refreshStatus, refreshKernelInfo, + refreshElevateOnLaunch, start, + startElevated, + elevateSelf, + cancelElevation, + fetchHardwareConfig, + saveHardwareConfig, stop, fetchSnapshot, init,