Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b9f71da08 |
@@ -111,7 +111,7 @@ Thing/
|
|||||||
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
||||||
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
||||||
- [x] HTTP 下载支持
|
- [x] HTTP 下载支持
|
||||||
- [ ] BT/磁力链接支持(后续支持)
|
- [x] BT/磁力链接支持(后续支持)
|
||||||
- [x] 下载任务管理(历史)
|
- [x] 下载任务管理(历史)
|
||||||
- [x] 速度限制
|
- [x] 速度限制
|
||||||
- [x] 断点续传
|
- [x] 断点续传
|
||||||
@@ -124,8 +124,8 @@ Thing/
|
|||||||
|
|
||||||
### 第三阶段:优化与完善
|
### 第三阶段:优化与完善
|
||||||
|
|
||||||
- [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md)
|
- [x] 性能优化(见 MODULE_REVIEW.md)
|
||||||
- [x] 错误处理与日志完善(B5 进程级全局日志器、异常兜底)
|
- [x] 错误处理与日志完善(全局日志器、异常兜底)
|
||||||
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
||||||
- [x] 自动更新机制
|
- [x] 自动更新机制
|
||||||
- [x] 打包发布
|
- [x] 打包发布
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ internal sealed class KernelStatus
|
|||||||
{
|
{
|
||||||
public bool Ready { get; set; }
|
public bool Ready { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
/// <summary>PawnIO 驱动是否已安装(ring0 传感器读取依赖它或 WinRing0,缺失时温度/频率通常无法读取)</summary>
|
||||||
|
public bool PawnIoInstalled { get; set; }
|
||||||
public double UptimeMs { get; set; }
|
public double UptimeMs { get; set; }
|
||||||
public int GroupCount { get; set; }
|
public int GroupCount { get; set; }
|
||||||
public int SensorCount { get; set; }
|
public int SensorCount { get; set; }
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static bool IsRunningAsAdmin()
|
internal static bool IsRunningAsAdmin()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ internal static class HttpEndpoints
|
|||||||
{
|
{
|
||||||
Ready = hw?.Ready ?? false,
|
Ready = hw?.Ready ?? false,
|
||||||
IsAdmin = hw?.IsAdmin ?? false,
|
IsAdmin = hw?.IsAdmin ?? false,
|
||||||
|
PawnIoInstalled = PawnIoSupport.IsServiceInstalled(),
|
||||||
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
||||||
GroupCount = snap?.Groups.Count ?? 0,
|
GroupCount = snap?.Groups.Count ?? 0,
|
||||||
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace ThingHK;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PawnIO 驱动支持:检测 + 静默安装。
|
||||||
|
///
|
||||||
|
/// 背景:LHM 读取 CPU 温度/频率等 ring0 数据依赖内核驱动,回退用的 WinRing0 被
|
||||||
|
/// 微软"易受攻击的驱动程序阻止列表"和部分杀软(如火绒)拦截,导致传感器缺失。
|
||||||
|
/// PawnIO 是正规签名的替代驱动(不在阻止列表、兼容 HVCI/安全启动),
|
||||||
|
/// LHM 0.9.5+ 检测到已安装时优先使用,无需任何代码开关。
|
||||||
|
///
|
||||||
|
/// 安装器约定:PawnIO_setup.exe 与 ThingHK.exe 同目录
|
||||||
|
/// (由 Tauri 侧 prepare_kernel 从资源目录随内核一起复制到 {app_data}/monitor/cores/)。
|
||||||
|
///
|
||||||
|
/// 静默参数:-install -silent(官方 CLI 参数,见 namazso/PawnIO.Setup)。
|
||||||
|
/// 退出码:0=成功;3010=成功但需重启(ERROR_SUCCESS_REBOOT_REQUIRED)。
|
||||||
|
///
|
||||||
|
/// 策略:仅在内核已提权时安装。两种提权模式(Thing 提权继承 / 仅提权 ThingHK)
|
||||||
|
/// 都只有一次 UAC,内核拿到权限后自行静默安装,避免二次弹窗。
|
||||||
|
/// serve 模式调用;scan 诊断模式不安装,保持被动。
|
||||||
|
/// </summary>
|
||||||
|
internal static class PawnIoSupport
|
||||||
|
{
|
||||||
|
/// <summary>驱动服务注册表键:存在即认为已安装</summary>
|
||||||
|
private const string ServiceKeyName = @"SYSTEM\CurrentControlSet\Services\PawnIO";
|
||||||
|
|
||||||
|
private const string SetupFileName = "PawnIO_setup.exe";
|
||||||
|
|
||||||
|
/// <summary>3010 = ERROR_SUCCESS_REBOOT_REQUIRED(安装成功但需重启生效)</summary>
|
||||||
|
private const int ExitCodeRebootRequired = 3010;
|
||||||
|
|
||||||
|
/// <summary>驱动安装通常数秒内完成,留足余量防止卡死启动流程</summary>
|
||||||
|
private const int InstallTimeoutMs = 90_000;
|
||||||
|
|
||||||
|
/// <summary>检测 PawnIO 驱动服务是否已注册</summary>
|
||||||
|
public static bool IsServiceInstalled()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(ServiceKeyName);
|
||||||
|
return key != null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保 PawnIO 就绪:已安装直接返回;未安装且当前已提权时静默安装。
|
||||||
|
/// 返回描述性结果(写入 stderr 日志 + /status 诊断)。
|
||||||
|
/// </summary>
|
||||||
|
public static string EnsureInstalled()
|
||||||
|
{
|
||||||
|
if (IsServiceInstalled())
|
||||||
|
return "already-installed";
|
||||||
|
|
||||||
|
if (!HardwareManager.IsRunningAsAdmin())
|
||||||
|
return "skipped: not elevated (温度/频率等传感器需要提权运行)";
|
||||||
|
|
||||||
|
string setupPath = Path.Combine(AppContext.BaseDirectory, SetupFileName);
|
||||||
|
if (!File.Exists(setupPath))
|
||||||
|
return $"skipped: {SetupFileName} 未找到(应随内核一起部署,见 prepare_kernel)";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = setupPath,
|
||||||
|
Arguments = "-install -silent",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
});
|
||||||
|
if (process == null)
|
||||||
|
return "failed: Process.Start 返回 null";
|
||||||
|
|
||||||
|
if (!process.WaitForExit(InstallTimeoutMs))
|
||||||
|
{
|
||||||
|
try { process.Kill(); } catch { /* 超时后进程可能已自行退出 */ }
|
||||||
|
return "failed: 安装超时";
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = process.ExitCode;
|
||||||
|
if (code == ExitCodeRebootRequired)
|
||||||
|
return "installed: 需重启后生效";
|
||||||
|
|
||||||
|
if (code != 0)
|
||||||
|
return $"failed: 安装器退出码 {code}";
|
||||||
|
|
||||||
|
return IsServiceInstalled()
|
||||||
|
? "installed"
|
||||||
|
: "failed: 安装器返回 0 但服务未注册";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,11 @@ internal static class Program
|
|||||||
|
|
||||||
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
||||||
|
|
||||||
|
// PawnIO:ring0 传感器读取的首选驱动(未安装且已提权时静默安装,
|
||||||
|
// 避开 WinRing0 被系统阻止列表/杀软拦截导致的温度/频率缺失)
|
||||||
|
string pawnIoResult = PawnIoSupport.EnsureInstalled();
|
||||||
|
Console.Error.WriteLine($"[ThingHK] PawnIO: {pawnIoResult}");
|
||||||
|
|
||||||
using var kernel = new KernelHost();
|
using var kernel = new KernelHost();
|
||||||
await kernel.StartAsync(configPath, fastMs, slowMs);
|
await kernel.StartAsync(configPath, fastMs, slowMs);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre716" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre729" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ windows-sys = { version = "0.52", features = [
|
|||||||
"Win32_Storage_Xps",
|
"Win32_Storage_Xps",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
] }
|
] }
|
||||||
# Explorer 髯キ隨ャ・ヲ髮・スコ莨・・カ繝サ・ョ髯滄摩・「髮」・ス・」・つ髮趣スャ陷茨スキ繝サ・シ郢晢スサShellWindows COM郢晢スサ闔ィ螟イ・ス・シ陞「・サ繝サ・サ郢晢スサ繝サ・シ陷肴コ倥・鬨セ蛹・スス・ィ髯具スサ繝サ・ー鬨セ・ァ郢晢スサfeature郢晢スサ隴エ・ァ髢迹壼エ輔・・カ鬩帛ク帙・繝サ・ッ陷ソ・ー繝サ・ス鬪ー蜈キ・ス・ァ繝サ・ッ
|
# Explorer 鬯ョ・ッ繝サ・キ鬮ォ・ィ繝サ・ャ郢晢スサ繝サ・ヲ鬯ョ・ョ郢晢スサ繝サ・ス繝サ・コ髣費スィ郢晢スサ・つ郢晢スサ繝サ・カ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ョ・ッ雋翫・譚溽ケ晢スサ繝サ・「鬯ョ・ョ繝サ・」郢晢スサ繝サ・ス郢晢スサ繝サ・」郢晢スサ邵コ・、・つ鬯ョ・ョ髮懶ス」繝サ・ス繝サ・ャ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・シ鬩幢ス「隴趣ス「繝サ・ス繝サ・サShellWindows COM鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮」雋サ・ス・ィ髯樊サゑスス・イ郢晢スサ繝サ・ス郢晢スサ繝サ・シ鬮ッ讖ク・ス・「郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬮ッ・キ髢ァ・エ繝サ・コ陋滂ス・郢晢スサ鬯ッ・ィ繝サ・セ髯具スケ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ィ繝サ・セ郢晢スサ繝サ・ァ鬩幢ス「隴趣ス「繝サ・ス繝サ・サfeature鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・エ郢晢スサ繝サ・ァ鬯ョ・「繝サ・ー髴托スケ陞「・シ繝サ・エ髴域鱒繝サ郢晢スサ繝サ・カ鬯ッ・ゥ陝カ蟷「・ス・ク陝カ蜷カ繝サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬮ッ・キ繝サ・ソ郢晢スサ繝サ・ー驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・ッ
|
||||||
windows = { version = "0.52", features = [
|
windows = { version = "0.52", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_System_Com",
|
"Win32_System_Com",
|
||||||
@@ -82,14 +82,14 @@ windows = { version = "0.52", features = [
|
|||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
|
|
||||||
# ===== 编译优化 =====
|
# ===== 驛帛・・ッ蜿ー・シ莨懷密 =====
|
||||||
# dev 构建启用增量编译 + 行号级调试(提速本地迭代),仅影响 `tauri dev`
|
# dev 隴ォ繝サ・サ・コ陷キ・ッ騾包スィ陟・ィ」纃シ驛帛・・ッ繝サ+ 髯ヲ謔滓差驛、・ァ髫ケ繝サ・ッ蛹・スシ蝓溽スイ鬨セ貊捺た陜ィ・ー髴托スュ闔会ス」繝サ莨夲スシ蠕。・サ繝サ・ス・ア陷ゥ繝サ`tauri dev`
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
incremental = true
|
incremental = true
|
||||||
debug = "line-tables-only"
|
debug = "line-tables-only"
|
||||||
|
|
||||||
# release 譫・サコ逖ヲ霄ォ・壼悉隨ヲ蜿キ + LTO・悟㍼蟆丞ョ芽」・桁菴鍋ァッ
|
# release 髫エ・ォ郢晢スサ繝サ・サ繝サ・コ鬨セ蜴・スス・ヲ鬮エ繝サ・ス・ォ郢晢スサ陞「・シ隰碑崟蝨キ繝サ・ヲ髯キ・ソ繝サ・キ + LTO郢晢スサ隰疲コ倩ゥ宣劑繝サ・ク讖ク・ス・ョ髣・スス繝サ・」郢晢スサ隴ッ竏ャ謚・ェー蜈キ・ス・ァ繝サ・ッ
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -44,6 +44,8 @@ pub mod events {
|
|||||||
// OSD 窗口
|
// OSD 窗口
|
||||||
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
|
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
|
||||||
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
|
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
|
||||||
|
pub const OSD_GAME_ACTIVE: &str = "osd-game-active";
|
||||||
|
pub const OSD_GAME_INACTIVE: &str = "osd-game-inactive";
|
||||||
pub const OSD_START_DRAG: &str = "osd-start-drag";
|
pub const OSD_START_DRAG: &str = "osd-start-drag";
|
||||||
pub const OSD_END_DRAG: &str = "osd-end-drag";
|
pub const OSD_END_DRAG: &str = "osd-end-drag";
|
||||||
// 截图
|
// 截图
|
||||||
|
|||||||
@@ -663,6 +663,67 @@ impl DownloadEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BT 任务重新入会:句柄缺失(元数据未解析完/句柄被移除)时,重新加入会话以取回句柄。
|
||||||
|
/// librqbit 对已加入的种子返回 AlreadyManaged(复用现有句柄,已下载片段保留),
|
||||||
|
/// 因此对暂停→继续、错误→继续等场景都幂等、安全。
|
||||||
|
async fn reenter_bt(&self, id: &str, url: &str, dir: &str) -> Result<(), String> {
|
||||||
|
let added = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(crate::download_engine::torrent::INSPECT_TIMEOUT_SECS),
|
||||||
|
self.inner.torrent.add_async(url, dir),
|
||||||
|
).await;
|
||||||
|
let (_, handle) = match added {
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
Ok(Err(e)) => return Err(e),
|
||||||
|
Err(_) => return Err("解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存回句柄(句柄存在则更新,缺失则新建)
|
||||||
|
{
|
||||||
|
let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match handles.get_mut(id) {
|
||||||
|
Some(h) => h.bt = Some(handle.clone()),
|
||||||
|
None => {
|
||||||
|
handles.insert(
|
||||||
|
id.to_string(),
|
||||||
|
TaskHandle {
|
||||||
|
gen: self.inner.next_gen.fetch_add(1, Ordering::SeqCst) + 1,
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
progress: Vec::new(),
|
||||||
|
join: Mutex::new(None),
|
||||||
|
bt: Some(handle.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待元数据就绪(本地 .torrent 秒回;磁力依赖网络,带超时)
|
||||||
|
if let Err(e) = self.inner.torrent.wait_initialized(&handle).await {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
// 刷新结构化任务元数据(filename / infohash / 文件列表 / 大小)
|
||||||
|
match self.inner.torrent.inspect(url).await {
|
||||||
|
Ok(info) => {
|
||||||
|
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(t) = tasks.get_mut(id) {
|
||||||
|
t.filename = info.name.clone();
|
||||||
|
t.info_hash = Some(info.info_hash.clone());
|
||||||
|
t.bt_files = info.files.clone();
|
||||||
|
t.total_size = info.total_size;
|
||||||
|
t.bt_metadata_ready = true;
|
||||||
|
t.error = None;
|
||||||
|
// 复位为 Queued:start_download 只在 Queued 下继续推进(首调时已置 Active)
|
||||||
|
if matches!(t.status, TaskStatus::Active | TaskStatus::Error) {
|
||||||
|
t.status = TaskStatus::Queued;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
self.persist_now();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// BT 元数据解析失败:任务置为 Error 并通知前端刷新
|
/// BT 元数据解析失败:任务置为 Error 并通知前端刷新
|
||||||
fn fail_bt_resolve(engine: &DownloadEngine, id: &str, error: String) {
|
fn fail_bt_resolve(engine: &DownloadEngine, id: &str, error: String) {
|
||||||
{
|
{
|
||||||
@@ -1245,15 +1306,28 @@ impl DownloadEngine {
|
|||||||
// ===== BitTorrent 下载分支 =====
|
// ===== BitTorrent 下载分支 =====
|
||||||
if is_bt {
|
if is_bt {
|
||||||
let Some(bt) = existing_bt else {
|
let Some(bt) = existing_bt else {
|
||||||
// 无 BT 句柄(异常):标记 Error
|
// 无 BT 句柄:常见于元数据尚未解析完就点了"暂停/继续",或句柄已被移除。
|
||||||
{
|
// 不能直接报"句柄缺失"(否则此类任务点"继续"会立即失败),改为后台重新加入
|
||||||
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
// 会话(如已加入则复用已有句柄,已下载片段保留),元数据就绪后再进入下载。
|
||||||
if let Some(task) = tasks.get_mut(&id) {
|
let engine2 = self.clone();
|
||||||
task.status = TaskStatus::Error;
|
let id2 = id.clone();
|
||||||
task.error = Some("BitTorrent 句柄缺失".to_string());
|
let url2 = task.url.clone();
|
||||||
|
let dir2 = task.dir.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = engine2.reenter_bt(&id2, &url2, &dir2).await {
|
||||||
|
crate::logger::log_error("download", &format!("BT 任务重入会话失败: {}", e));
|
||||||
|
let mut tasks = engine2.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(t) = tasks.get_mut(&id2) {
|
||||||
|
if t.status == TaskStatus::Active || t.status == TaskStatus::Queued {
|
||||||
|
t.status = TaskStatus::Error;
|
||||||
|
t.error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine2.persist_now();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
engine2.start_download(id2);
|
||||||
self.persist_now();
|
});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -414,13 +414,21 @@ impl TorrentDownloader {
|
|||||||
.map_err(|e| format!("暂停种子失败: {}", e))
|
.map_err(|e| format!("暂停种子失败: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 继续种子
|
/// 继续种子(幂等):恢复已暂停的种子。
|
||||||
|
/// 若种子本已处于 Live(例如 select_bt_files 的 set_only_files 内部已恢复过、
|
||||||
|
/// 或暂停期间被其他逻辑恢复),直接视为成功,避免 librqbit 报 "torrent is already live"
|
||||||
|
/// 而导致 pause→resume 链路被误判为失败。
|
||||||
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||||
let session = self.get_session().await?;
|
let session = self.get_session().await?;
|
||||||
session
|
match handle.stats().state {
|
||||||
.unpause(handle)
|
librqbit::TorrentStatsState::Live => Ok(()),
|
||||||
.await
|
librqbit::TorrentStatsState::Error => Err("种子处于错误状态,无法继续".to_string()),
|
||||||
.map_err(|e| format!("继续种子失败: {}", e))
|
librqbit::TorrentStatsState::Paused
|
||||||
|
| librqbit::TorrentStatsState::Initializing { .. } => session
|
||||||
|
.unpause(handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("继续种子失败: {}", e)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
|
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
|
||||||
|
|||||||
+14
-5
@@ -43,7 +43,8 @@ use monitor_kernel::{
|
|||||||
use network_monitor::network_status;
|
use network_monitor::network_status;
|
||||||
use osd_window::{
|
use osd_window::{
|
||||||
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
||||||
osd_set_topmost, osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch,
|
osd_set_topmost, osd_start_drag_watch, osd_start_game_watch, osd_start_topmost_watch,
|
||||||
|
osd_stop_watch,
|
||||||
};
|
};
|
||||||
use process_manager::{
|
use process_manager::{
|
||||||
process_all_status, process_start, process_status,
|
process_all_status, process_start, process_status,
|
||||||
@@ -84,7 +85,10 @@ use quickpanel::{
|
|||||||
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
|
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
|
||||||
};
|
};
|
||||||
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
||||||
use updater::{app_version, update_check, update_install, update_thinghk};
|
use updater::{
|
||||||
|
app_version, update_check, update_install, update_thinghk_apply, update_thinghk_cancel,
|
||||||
|
update_thinghk_confirm, ThinghkUpdateState,
|
||||||
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn quit_app(app: tauri::AppHandle) {
|
fn quit_app(app: tauri::AppHandle) {
|
||||||
@@ -109,8 +113,9 @@ fn export_bindings() {
|
|||||||
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
||||||
.error_handling(ErrorHandlingMode::Throw)
|
.error_handling(ErrorHandlingMode::Throw)
|
||||||
.commands(collect_commands![
|
.commands(collect_commands![
|
||||||
// 应用更新(4)
|
// 应用更新(6)
|
||||||
app_version, update_check, update_install, update_thinghk,
|
app_version, update_check, update_install, update_thinghk_apply,
|
||||||
|
update_thinghk_confirm, update_thinghk_cancel,
|
||||||
// proxy(20)
|
// proxy(20)
|
||||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
|
||||||
@@ -175,12 +180,15 @@ pub fn run() {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.manage(ProcessManager::new())
|
.manage(ProcessManager::new())
|
||||||
|
.manage(ThinghkUpdateState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
quit_app,
|
quit_app,
|
||||||
app_version,
|
app_version,
|
||||||
update_check,
|
update_check,
|
||||||
update_install,
|
update_install,
|
||||||
update_thinghk,
|
update_thinghk_apply,
|
||||||
|
update_thinghk_confirm,
|
||||||
|
update_thinghk_cancel,
|
||||||
process_start,
|
process_start,
|
||||||
process_stop,
|
process_stop,
|
||||||
process_status,
|
process_status,
|
||||||
@@ -238,6 +246,7 @@ pub fn run() {
|
|||||||
osd_set_topmost,
|
osd_set_topmost,
|
||||||
osd_start_drag_watch,
|
osd_start_drag_watch,
|
||||||
osd_start_topmost_watch,
|
osd_start_topmost_watch,
|
||||||
|
osd_start_game_watch,
|
||||||
osd_stop_watch,
|
osd_stop_watch,
|
||||||
downloader_get_tasks,
|
downloader_get_tasks,
|
||||||
downloader_add_task,
|
downloader_add_task,
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
|
|||||||
pub struct KernelStatus {
|
pub struct KernelStatus {
|
||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
/// PawnIO 驱动是否已安装;旧版内核无此字段,Option 兼容
|
||||||
|
pub pawn_io_installed: Option<bool>,
|
||||||
pub uptime_ms: f64,
|
pub uptime_ms: f64,
|
||||||
pub group_count: u32,
|
pub group_count: u32,
|
||||||
pub sensor_count: u32,
|
pub sensor_count: u32,
|
||||||
@@ -370,7 +372,9 @@ impl MonitorKernel {
|
|||||||
self.root.join("hardware-config.json")
|
self.root.join("hardware-config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制
|
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制。
|
||||||
|
/// 同时把 PawnIO_setup.exe(可选资源)复制过去——内核提权启动时会静默安装它,
|
||||||
|
/// 作为 WinRing0 被系统/杀软拦截时读取温度/频率的替代驱动。
|
||||||
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
||||||
let kernel = self.kernel_path();
|
let kernel = self.kernel_path();
|
||||||
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
||||||
@@ -385,6 +389,24 @@ impl MonitorKernel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PawnIO 安装器:可选资源,缺失时仅影响自动安装能力(不影响内核运行)
|
||||||
|
if let Ok(setup_src) = app.path().resolve("binaries/PawnIO_setup.exe", BaseDirectory::Resource) {
|
||||||
|
if setup_src.exists() {
|
||||||
|
let setup_dest = self.cores_dir().join("PawnIO_setup.exe");
|
||||||
|
let need_copy = !setup_dest.exists()
|
||||||
|
|| fs::metadata(&setup_src)
|
||||||
|
.and_then(|s| fs::metadata(&setup_dest).map(|d| s.len() != d.len()))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if need_copy {
|
||||||
|
fs::create_dir_all(self.cores_dir()).ok();
|
||||||
|
if let Err(e) = fs::copy(&setup_src, &setup_dest) {
|
||||||
|
crate::logger::log_warn("monitor", &format!("复制 PawnIO_setup.exe 失败: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MonitorKernelInfo {
|
Ok(MonitorKernelInfo {
|
||||||
path: kernel.to_string_lossy().to_string(),
|
path: kernel.to_string_lossy().to_string(),
|
||||||
exists: kernel.exists(),
|
exists: kernel.exists(),
|
||||||
@@ -489,12 +511,20 @@ impl MonitorKernel {
|
|||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
match resp.json::<KernelStatus>().await {
|
match resp.json::<KernelStatus>().await {
|
||||||
Ok(s) if s.ready => {
|
Ok(s) if s.ready => {
|
||||||
|
// PawnIO 诊断:已提权但驱动缺失时,温度/频率等 ring0 传感器大概率无法读取
|
||||||
|
if s.is_admin && s.pawn_io_installed == Some(false) {
|
||||||
|
crate::logger::log_warn(
|
||||||
|
"monitor",
|
||||||
|
"Kernel 已提权但 PawnIO 驱动未安装,CPU 温度/频率可能无法读取(检查 cores/PawnIO_setup.exe 是否随包部署)",
|
||||||
|
);
|
||||||
|
}
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
crate::constants::events::MONITOR_READY,
|
crate::constants::events::MONITOR_READY,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"isAdmin": s.is_admin,
|
"isAdmin": s.is_admin,
|
||||||
"sensorCount": s.sensor_count,
|
"sensorCount": s.sensor_count,
|
||||||
"providers": s.providers,
|
"providers": s.providers,
|
||||||
|
"pawnIoInstalled": s.pawn_io_installed,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
+150
-4
@@ -15,9 +15,12 @@ use tauri::{AppHandle, Emitter};
|
|||||||
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
/// 任务栏覆盖监视线程停止标志
|
/// 任务栏覆盖监视线程停止标志
|
||||||
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
|
/// 游戏全屏监视线程停止标志
|
||||||
|
static GAME_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
||||||
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
static GAME_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
|
||||||
fn drag_stop() -> &'static Arc<AtomicBool> {
|
fn drag_stop() -> &'static Arc<AtomicBool> {
|
||||||
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
@@ -27,6 +30,10 @@ fn topmost_stop() -> &'static Arc<AtomicBool> {
|
|||||||
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn game_stop() -> &'static Arc<AtomicBool> {
|
||||||
|
GAME_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
|
}
|
||||||
|
|
||||||
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
|
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
|
||||||
fn stop_drag_thread() {
|
fn stop_drag_thread() {
|
||||||
drag_stop().store(true, Ordering::SeqCst);
|
drag_stop().store(true, Ordering::SeqCst);
|
||||||
@@ -51,18 +58,35 @@ fn stop_topmost_thread() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 停止游戏全屏监视线程并等待其退出
|
||||||
|
fn stop_game_thread() {
|
||||||
|
game_stop().store(true, Ordering::SeqCst);
|
||||||
|
if let Some(h) = GAME_HANDLE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.take()
|
||||||
|
{
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod win_api {
|
mod win_api {
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use windows_sys::Win32::Foundation::{POINT, RECT};
|
use windows_sys::Win32::Foundation::{POINT, RECT};
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||||
|
};
|
||||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
|
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowLongW,
|
||||||
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
GetWindowRect, GetWindowThreadProcessId, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
||||||
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
|
GWL_EXSTYLE, GWL_STYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE,
|
||||||
SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
|
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
|
||||||
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
|
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
|
||||||
};
|
};
|
||||||
|
/// 供模块外全屏判定使用的窗口样式常量(pub re-export)
|
||||||
|
pub use windows_sys::Win32::UI::WindowsAndMessaging::WS_CAPTION;
|
||||||
|
|
||||||
/// windows-sys 的 HWND 类型别名(isize)
|
/// windows-sys 的 HWND 类型别名(isize)
|
||||||
pub type Hwnd = isize;
|
pub type Hwnd = isize;
|
||||||
@@ -225,6 +249,41 @@ mod win_api {
|
|||||||
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取窗口样式(GWL_STYLE)
|
||||||
|
pub fn get_window_style(hwnd: Hwnd) -> isize {
|
||||||
|
unsafe { GetWindowLongW(hwnd, GWL_STYLE) as isize }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断窗口是否属于本进程(Thing 自身窗口不参与全屏判定)
|
||||||
|
pub fn is_own_process(hwnd: Hwnd) -> bool {
|
||||||
|
let mut pid: u32 = 0;
|
||||||
|
unsafe {
|
||||||
|
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||||
|
}
|
||||||
|
pid != 0 && pid == std::process::id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取窗口所在显示器(最近匹配)的矩形(物理像素)
|
||||||
|
pub fn get_monitor_rect(hwnd: Hwnd) -> Option<RECT> {
|
||||||
|
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||||
|
if monitor == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut info = MONITORINFO {
|
||||||
|
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||||
|
rcMonitor: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||||
|
rcWork: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||||
|
dwFlags: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if GetMonitorInfoW(monitor, &mut info) != 0 {
|
||||||
|
Some(info.rcMonitor)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
||||||
@@ -369,11 +428,98 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断窗口是否为全屏应用(无边框/独占全屏游戏)
|
||||||
|
///
|
||||||
|
/// 判定条件(全部满足):
|
||||||
|
/// 1. 无 WS_CAPTION 样式 —— 排除普通应用的"最大化"(即使系统任务栏设为自动隐藏,
|
||||||
|
/// 最大化窗口覆盖率也接近 100%,但它们带标题栏,靠样式即可区分)
|
||||||
|
/// 2. 非本进程窗口(Thing 主窗口/悬浮窗自身)
|
||||||
|
/// 3. 窗口矩形与所在显示器矩形的交集覆盖率 ≥ 95%(兼容缩放/1px 误差)
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_fullscreen_game_window(hwnd: isize) -> bool {
|
||||||
|
if win_api::get_window_style(hwnd) & (win_api::WS_CAPTION as isize) != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if win_api::is_own_process(hwnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (Some(win_rect), Some(mon_rect)) = (
|
||||||
|
win_api::get_window_rect(hwnd),
|
||||||
|
win_api::get_monitor_rect(hwnd),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let iw = (win_rect.right.min(mon_rect.right) - win_rect.left.max(mon_rect.left)).max(0) as i64;
|
||||||
|
let ih = (win_rect.bottom.min(mon_rect.bottom) - win_rect.top.max(mon_rect.top)).max(0) as i64;
|
||||||
|
let mw = (mon_rect.right - mon_rect.left).max(0) as i64;
|
||||||
|
let mh = (mon_rect.bottom - mon_rect.top).max(0) as i64;
|
||||||
|
let mon_area = (mw * mh).max(1);
|
||||||
|
iw * ih * 100 >= mon_area * 95
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动游戏全屏监视
|
||||||
|
///
|
||||||
|
/// 轮询检测前景窗口是否为全屏应用(无边框/独占全屏游戏),
|
||||||
|
/// 状态变化时发出 `osd-game-active` / `osd-game-inactive` 事件。
|
||||||
|
/// 前端据此隐藏/恢复 OSD:透明置顶 WebView 悬浮窗会占用 DWM 合成路径,
|
||||||
|
/// 禁用游戏的独立翻转(MPO),是游戏中掉帧的根源;隐藏悬浮窗即可排除影响。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn osd_start_game_watch(app: AppHandle) -> Result<(), String> {
|
||||||
|
// 停止旧线程,等待其退出后再启动新线程
|
||||||
|
stop_game_thread();
|
||||||
|
let stop_flag = game_stop().clone();
|
||||||
|
stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let mut fullscreen_active = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if stop_flag.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let fg = win_api::get_foreground_window();
|
||||||
|
let fullscreen = fg != 0
|
||||||
|
&& win_api::get_class_name(fg)
|
||||||
|
.map_or(false, |c| !win_api::is_system_ui_class(&c))
|
||||||
|
&& is_fullscreen_game_window(fg);
|
||||||
|
|
||||||
|
if fullscreen != fullscreen_active {
|
||||||
|
fullscreen_active = fullscreen;
|
||||||
|
let event = if fullscreen {
|
||||||
|
crate::constants::events::OSD_GAME_ACTIVE
|
||||||
|
} else {
|
||||||
|
crate::constants::events::OSD_GAME_INACTIVE
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit(event, ());
|
||||||
|
crate::logger::log_info(
|
||||||
|
"osd",
|
||||||
|
&format!("全屏应用前台: {}", if fullscreen { "是 → 隐藏 OSD" } else { "否 → 恢复 OSD" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(1000));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Ok(mut guard) = GAME_HANDLE.lock() {
|
||||||
|
*guard = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 停止所有 OSD 监视线程
|
/// 停止所有 OSD 监视线程
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn osd_stop_watch() {
|
pub fn osd_stop_watch() {
|
||||||
stop_drag_thread();
|
stop_drag_thread();
|
||||||
stop_topmost_thread();
|
stop_topmost_thread();
|
||||||
|
stop_game_thread();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ use tauri::{AppHandle, Emitter, Manager};
|
|||||||
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
// CREATE_NEW_CONSOLE = 0x00000010,强制为控制台类子进程新开一个可见控制台窗口。
|
||||||
|
// 从 GUI 宿主(无控制台)直接 spawn cmd/powershell 等控制台程序时若不设置,
|
||||||
|
// 子进程会挂到隐藏控制台/不显示窗口,表现为"点击没反应"。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub const CREATE_NEW_CONSOLE: u32 = 0x00000010;
|
||||||
|
|
||||||
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -505,11 +505,35 @@ pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Resu
|
|||||||
|
|
||||||
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
||||||
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
||||||
|
/// - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE,
|
||||||
|
/// 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
|
||||||
|
/// - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
|
||||||
|
/// 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
||||||
|
let lower = command.to_lowercase();
|
||||||
|
if lower.ends_with(".msc") {
|
||||||
|
// 控制台文件:通过 mmc 打开(GUI 程序,无需新控制台)
|
||||||
|
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||||
|
let path = format!("{}\\System32\\{}", system_root, command);
|
||||||
|
let mut c = std::process::Command::new("mmc");
|
||||||
|
c.arg(&path);
|
||||||
|
return c
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("运行系统命令失败: {}", e));
|
||||||
|
}
|
||||||
let mut cmd = std::process::Command::new(&command);
|
let mut cmd = std::process::Command::new(&command);
|
||||||
cmd.args(&args);
|
cmd.args(&args);
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
let c = lower;
|
||||||
|
if c == "cmd" || c == "powershell" || c == "pwsh" {
|
||||||
|
cmd.creation_flags(crate::process_manager::CREATE_NEW_CONSOLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-101
@@ -2,18 +2,65 @@
|
|||||||
//! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
|
//! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
|
||||||
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
|
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
|
||||||
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
|
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
|
||||||
//! - ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖 {app_data}/monitor/cores/ThingHK.exe
|
//! - ThingHK 内核:下载由前端下载模块完成 → apply 命令 need_stop 等待确认 → 解压覆盖
|
||||||
//! mihomo 内核更新继续复用代理模块已有的 GitHub 下载机制,不在此模块处理。
|
//! {app_data}/monitor/cores/ThingHK.exe(与代理模块 mihomo 内核更新同模式)
|
||||||
use futures_util::StreamExt;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{atomic::{AtomicBool, Ordering}, Mutex};
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::sync::{oneshot, watch};
|
||||||
|
|
||||||
use crate::constants::events::UPDATE_PROGRESS;
|
use crate::constants::events::UPDATE_PROGRESS;
|
||||||
|
|
||||||
|
/// 用户主动取消 ThingHK 更新的标记错误信息(前端据此静默处理,不弹错误 toast)
|
||||||
|
const THINGHK_UPDATE_CANCELLED: &str = "更新已取消";
|
||||||
|
|
||||||
|
/// ThingHK 内核更新的跨命令状态:apply 过程中 need_stop 阶段等待前端确认。
|
||||||
|
/// 与 MihomoManager 的 install_confirm/kernel_cancel 同构。
|
||||||
|
pub struct ThinghkUpdateState {
|
||||||
|
/// need_stop 等待阶段的确认通道(前端调 confirm 命令时唤醒 apply 继续)
|
||||||
|
confirm_tx: Mutex<Option<oneshot::Sender<()>>>,
|
||||||
|
/// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回)
|
||||||
|
cancel_flag: AtomicBool,
|
||||||
|
cancel_tx: watch::Sender<bool>,
|
||||||
|
cancel_rx: watch::Receiver<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThinghkUpdateState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, rx) = watch::channel(false);
|
||||||
|
Self {
|
||||||
|
confirm_tx: Mutex::new(None),
|
||||||
|
cancel_flag: AtomicBool::new(false),
|
||||||
|
cancel_tx: tx,
|
||||||
|
cancel_rx: rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端已停止监控内核,唤醒 apply 继续解压替换
|
||||||
|
fn confirm(&self) {
|
||||||
|
if let Some(tx) = self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消更新:置位取消标志并唤醒 apply 等待循环
|
||||||
|
fn cancel(&self) {
|
||||||
|
self.cancel_flag.store(true, Ordering::SeqCst);
|
||||||
|
let _ = self.cancel_tx.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进入新的 apply 流程前复位取消标志
|
||||||
|
fn reset(&self) {
|
||||||
|
self.cancel_flag.store(false, Ordering::SeqCst);
|
||||||
|
let _ = self.cancel_tx.send(false);
|
||||||
|
*self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 发布仓库(Gitea)
|
/// 发布仓库(Gitea)
|
||||||
const GITEA_REPO: &str = "LFeng/Thing";
|
const GITEA_REPO: &str = "LFeng/Thing";
|
||||||
const GITEA_BASE: &str = "https://gitea.atie.fun";
|
const GITEA_BASE: &str = "https://gitea.atie.fun";
|
||||||
@@ -125,72 +172,8 @@ async fn fetch_latest_release() -> Result<LatestRelease, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 下载 / 解压 ----------
|
// ---------- 解压 ----------
|
||||||
|
// ThingHK 内核更新包由前端下载模块负责下载(同 mihomo),此处仅解压替换。
|
||||||
/// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度
|
|
||||||
async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> {
|
|
||||||
// 注意:reqwest 的 timeout 是"从连接到响应体读完"的总超时。大文件(如
|
|
||||||
// thing.exe 10+MB)在慢速网络下 30s 内读不完会被掐断流导致下载到一半失败
|
|
||||||
// (此前 bug:更新包总在 ~50% 处中断)。这里只限制连接建立 15s,
|
|
||||||
// 总超时放宽到 10 分钟兜底防止永久悬挂。
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.connect_timeout(std::time::Duration::from_secs(15))
|
|
||||||
.timeout(std::time::Duration::from_secs(600))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
|
||||||
let resp = client
|
|
||||||
.get(url)
|
|
||||||
.header("User-Agent", "thing-app")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("下载请求失败: {}", e))?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("下载失败: HTTP {}", resp.status()));
|
|
||||||
}
|
|
||||||
let total = resp.content_length();
|
|
||||||
let mut stream = resp.bytes_stream();
|
|
||||||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
|
||||||
let mut downloaded: u64 = 0;
|
|
||||||
let mut last_percent: u8 = 0;
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
let chunk = chunk.map_err(|e| format!("读取下载流失败: {}", e))?;
|
|
||||||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
|
||||||
downloaded += chunk.len() as u64;
|
|
||||||
let percent = match total {
|
|
||||||
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 100.0) as u8,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
if percent >= last_percent + 1 {
|
|
||||||
last_percent = percent;
|
|
||||||
let _ = app.emit(
|
|
||||||
UPDATE_PROGRESS,
|
|
||||||
UpdateProgress {
|
|
||||||
stage: "downloading".into(),
|
|
||||||
percent,
|
|
||||||
downloaded_bytes: downloaded,
|
|
||||||
total_bytes: total,
|
|
||||||
message: format!(
|
|
||||||
"已下载 {:.2} MB / {:.2} MB",
|
|
||||||
downloaded as f64 / 1024.0 / 1024.0,
|
|
||||||
total.unwrap_or(0) as f64 / 1024.0 / 1024.0
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
|
|
||||||
let _ = app.emit(
|
|
||||||
UPDATE_PROGRESS,
|
|
||||||
UpdateProgress {
|
|
||||||
stage: "downloaded".into(),
|
|
||||||
percent: 100,
|
|
||||||
downloaded_bytes: downloaded,
|
|
||||||
total_bytes: total,
|
|
||||||
message: "下载完成".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
||||||
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
|
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
|
||||||
@@ -377,47 +360,72 @@ pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件
|
/// 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
|
||||||
|
/// need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
|
||||||
|
/// 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
|
pub async fn update_thinghk_apply(
|
||||||
let result = update_thinghk_inner(&app).await;
|
app: AppHandle,
|
||||||
|
state: tauri::State<'_, ThinghkUpdateState>,
|
||||||
|
zip_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.reset();
|
||||||
|
let result = update_thinghk_apply_inner(&app, &state, PathBuf::from(&zip_path)).await;
|
||||||
if let Err(ref e) = result {
|
if let Err(ref e) = result {
|
||||||
// 失败时 emit error 阶段,避免前端进度卡在最后状态无提示
|
// 取消是用户主动行为,静默返回即可;其余失败 emit error 阶段避免前端进度卡死
|
||||||
let _ = app.emit(
|
if e != THINGHK_UPDATE_CANCELLED {
|
||||||
UPDATE_PROGRESS,
|
let _ = app.emit(
|
||||||
UpdateProgress {
|
UPDATE_PROGRESS,
|
||||||
stage: "error".into(),
|
UpdateProgress {
|
||||||
percent: 0,
|
stage: "error".into(),
|
||||||
downloaded_bytes: 0,
|
percent: 0,
|
||||||
total_bytes: None,
|
downloaded_bytes: 0,
|
||||||
message: e.clone(),
|
total_bytes: None,
|
||||||
},
|
message: e.clone(),
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
async fn update_thinghk_apply_inner(
|
||||||
let latest = fetch_latest_release().await?;
|
app: &AppHandle,
|
||||||
let asset = latest
|
state: &ThinghkUpdateState,
|
||||||
.assets
|
zip_path: PathBuf,
|
||||||
.iter()
|
) -> Result<(), String> {
|
||||||
.find(|a| a.name.starts_with("thing-hk_") && a.name.ends_with(".zip"))
|
if !zip_path.exists() {
|
||||||
.ok_or("未在 release 中找到 ThingHK 内核包".to_string())?;
|
return Err(format!("下载文件不存在: {}", zip_path.display()));
|
||||||
// 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用
|
|
||||||
if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() {
|
|
||||||
monitor.stop_subscription(app).await;
|
|
||||||
}
|
}
|
||||||
if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() {
|
|
||||||
let _ = pm.stop("monitor");
|
// need_stop:等待前端停止监控内核并确认(exe 被占用会导致覆盖失败)。
|
||||||
|
// 确认/取消由 confirm/cancel 命令跨命令唤醒(与 mihomo need_stop 同构)。
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "need_stop".into(),
|
||||||
|
percent: 90,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: "需要停止监控内核才能继续安装".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (tx, mut rx) = oneshot::channel::<()>();
|
||||||
|
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||||
|
let mut cancel_rx = state.cancel_rx.clone();
|
||||||
|
loop {
|
||||||
|
if state.cancel_flag.load(Ordering::SeqCst) {
|
||||||
|
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
return Err(THINGHK_UPDATE_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut rx => break,
|
||||||
|
_ = cancel_rx.changed() => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 等待进程退出释放文件句柄
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
|
// 解压阶段
|
||||||
let temp_dir = std::env::temp_dir().join("thing-update");
|
|
||||||
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
|
|
||||||
let zip_path = temp_dir.join(&asset.name);
|
|
||||||
download_with_progress(app, &asset.browser_download_url, &zip_path).await?;
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
UPDATE_PROGRESS,
|
UPDATE_PROGRESS,
|
||||||
UpdateProgress {
|
UpdateProgress {
|
||||||
@@ -428,9 +436,11 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
|||||||
message: "正在解压内核...".into(),
|
message: "正在解压内核...".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let temp_dir = std::env::temp_dir().join("thing-update");
|
||||||
let extract_dir = temp_dir.join("thinghk_extract");
|
let extract_dir = temp_dir.join("thinghk_extract");
|
||||||
let _ = fs::remove_dir_all(&extract_dir);
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
extract_zip(&zip_path, &extract_dir)?;
|
extract_zip(&zip_path, &extract_dir)?;
|
||||||
|
|
||||||
// 在解压目录中查找 ThingHK.exe
|
// 在解压目录中查找 ThingHK.exe
|
||||||
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
|
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
|
||||||
let app_data = app
|
let app_data = app
|
||||||
@@ -441,6 +451,7 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
|||||||
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
|
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
|
||||||
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
|
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
|
||||||
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
|
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
|
||||||
|
|
||||||
// 清理临时文件
|
// 清理临时文件
|
||||||
let _ = fs::remove_file(&zip_path);
|
let _ = fs::remove_file(&zip_path);
|
||||||
let _ = fs::remove_dir_all(&extract_dir);
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
@@ -457,6 +468,22 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn update_thinghk_confirm(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||||||
|
state.confirm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn update_thinghk_cancel(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||||||
|
state.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
|
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
|
||||||
if let Ok(entries) = fs::read_dir(dir) {
|
if let Ok(entries) = fs::read_dir(dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"identifier": "thing.lfeng.me",
|
"identifier": "thing.lfeng.me",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:14210",
|
||||||
"beforeBuildCommand": "bun run build",
|
"beforeBuildCommand": "bun run build",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
|
|||||||
+14
-2
@@ -16,8 +16,16 @@ export const commands = {
|
|||||||
* 调用返回前会触发应用退出。
|
* 调用返回前会触发应用退出。
|
||||||
*/
|
*/
|
||||||
updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }),
|
updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }),
|
||||||
/** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */
|
/**
|
||||||
updateThinghk: () => __TAURI_INVOKE<null>("update_thinghk"),
|
* 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
|
||||||
|
* need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
|
||||||
|
* 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
|
||||||
|
*/
|
||||||
|
updateThinghkApply: (zipPath: string) => __TAURI_INVOKE<null>("update_thinghk_apply", { zipPath }),
|
||||||
|
/** 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待) */
|
||||||
|
updateThinghkConfirm: () => __TAURI_INVOKE<null>("update_thinghk_confirm"),
|
||||||
|
/** 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试) */
|
||||||
|
updateThinghkCancel: () => __TAURI_INVOKE<null>("update_thinghk_cancel"),
|
||||||
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
|
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
|
||||||
/** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */
|
/** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */
|
||||||
proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }),
|
proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }),
|
||||||
@@ -110,6 +118,10 @@ export const commands = {
|
|||||||
/**
|
/**
|
||||||
* 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
* 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
||||||
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
||||||
|
* - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE,
|
||||||
|
* 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
|
||||||
|
* - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
|
||||||
|
* 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
|
||||||
*/
|
*/
|
||||||
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
|
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
|
||||||
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */
|
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export const EVENTS = {
|
|||||||
osdContentSize: 'osd-content-size',
|
osdContentSize: 'osd-content-size',
|
||||||
osdSystemUiActive: 'osd-system-ui-active',
|
osdSystemUiActive: 'osd-system-ui-active',
|
||||||
osdSystemUiInactive: 'osd-system-ui-inactive',
|
osdSystemUiInactive: 'osd-system-ui-inactive',
|
||||||
|
/** 前台出现全屏应用(游戏):OSD 应隐藏以避免游戏掉帧 */
|
||||||
|
osdGameActive: 'osd-game-active',
|
||||||
|
/** 全屏应用退出前台:OSD 可恢复显示 */
|
||||||
|
osdGameInactive: 'osd-game-inactive',
|
||||||
osdStartDrag: 'osd-start-drag',
|
osdStartDrag: 'osd-start-drag',
|
||||||
osdEndDrag: 'osd-end-drag',
|
osdEndDrag: 'osd-end-drag',
|
||||||
monitorReady: 'monitor-ready',
|
monitorReady: 'monitor-ready',
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ const logger = createLogger('main')
|
|||||||
// 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用)
|
// 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用)
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault())
|
document.addEventListener('contextmenu', (e) => e.preventDefault())
|
||||||
|
|
||||||
|
// 良性通知过滤:ResizeObserver 回调引发的布局变化在同一帧内级联时,
|
||||||
|
// 浏览器会派发此 ErrorEvent(规范定义为"通知"而非异常,无可操作信息)。
|
||||||
|
// 监控数据每秒刷新、reka-ui 组件挂载时高发,直接忽略避免污染日志。
|
||||||
|
const BENIGN_RESIZE_OBSERVER_RE = /^ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i
|
||||||
|
|
||||||
// 全局未捕获异常日志
|
// 全局未捕获异常日志
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
|
if (event.message && BENIGN_RESIZE_OBSERVER_RE.test(event.message)) return
|
||||||
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
|
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
|
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
|
||||||
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
|
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
|
||||||
Eye, EyeOff, MousePointerClick, Plus, PencilLine,
|
Eye, EyeOff, MousePointerClick, Plus, PencilLine,
|
||||||
CircuitBoard, BatteryFull,
|
CircuitBoard, BatteryFull, Gamepad2,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import type { LucideIcon } from '@lucide/vue'
|
import type { LucideIcon } from '@lucide/vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
@@ -2156,6 +2156,20 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
@update:model-value="updateOsdConfig('clickThrough', Boolean($event))"
|
@update:model-value="updateOsdConfig('clickThrough', Boolean($event))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 游戏全屏自动隐藏 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Gamepad2 class="size-3.5 text-muted-foreground" />
|
||||||
|
游戏全屏时自动隐藏
|
||||||
|
</Label>
|
||||||
|
<span class="text-[11px] text-muted-foreground">检测到全屏应用(游戏)前台时隐藏悬浮窗,退出后自动恢复,避免游戏掉帧</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
:model-value="osdConfig.gameAutoHide"
|
||||||
|
@update:model-value="updateOsdConfig('gameAutoHide', Boolean($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -512,6 +512,13 @@ onMounted(async () => {
|
|||||||
console.error('[OSD] 启动置顶监视失败:', e)
|
console.error('[OSD] 启动置顶监视失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动游戏全屏监视(前台全屏应用时通知主窗口隐藏 OSD,避免游戏掉帧)
|
||||||
|
try {
|
||||||
|
await invoke('osd_start_game_watch')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[OSD] 启动游戏全屏监视失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听主窗口推送的 OSD 配置(低频通道)
|
// 监听主窗口推送的 OSD 配置(低频通道)
|
||||||
unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
|
unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
|
||||||
config.value = e.payload.config
|
config.value = e.payload.config
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { getCurrentWindow, LogicalSize, Effect, EffectState } from '@tauri-apps/
|
|||||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings'
|
import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings'
|
||||||
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue'
|
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, ChevronDown, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue'
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getAllHistoryItems, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
import HistoryPicker from './HistoryPicker.vue'
|
import HistoryPicker from './HistoryPicker.vue'
|
||||||
|
|
||||||
@@ -143,14 +142,32 @@ const renameResults = ref<RenameResult[] | null>(null)
|
|||||||
const renameOkCount = ref(0)
|
const renameOkCount = ref(0)
|
||||||
let renameTimer: ReturnType<typeof setTimeout> | null = null
|
let renameTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// ===== 历史分区(从 results 中分离历史项与其他结果) =====
|
// ===== 结果分区(从 results 中分离历史项与其他结果) =====
|
||||||
// 展示顺序:目录操作(批量解压/重命名/删除)> 历史 > 更多历史 > 其他
|
// 展示顺序:目录操作(批量解压/重命名/删除)> 历史(折叠分组)> 其他
|
||||||
const dirActionItems = computed(() => results.value.filter(r => r.group === '目录操作'))
|
// 目录操作为置顶行为项;历史在空查询时置顶但默认折叠,按 Tab 展开
|
||||||
const historyItems = computed(() => results.value.filter(r => r.group === '历史'))
|
const dirActionItems = ref<QPItem[]>([])
|
||||||
const otherItems = computed(() => results.value.filter(r => r.group !== '历史' && r.group !== '目录操作'))
|
const otherItems = computed(() => results.value)
|
||||||
// Accordion 中的更多历史项(不参与键盘上下导航,仅鼠标点击)
|
// 历史项单独从 localStorage 加载(不再混入 results),默认折叠
|
||||||
const moreHistoryItems = ref<QPItem[]>([])
|
const historyItems = ref<QPItem[]>([])
|
||||||
const moreHistoryCount = ref(0)
|
const historyExpanded = ref(false)
|
||||||
|
|
||||||
|
// 键盘导航扁平化序号:目录操作 + 历史(展开时)+ 其他
|
||||||
|
const dirCount = computed(() => dirActionItems.value.length)
|
||||||
|
const historyCount = computed(() => historyItems.value.length)
|
||||||
|
const otherCount = computed(() => otherItems.value.length)
|
||||||
|
const otherNavStart = computed(() => dirCount.value + (historyExpanded.value ? historyCount.value : 0))
|
||||||
|
const navTotal = computed(() => otherNavStart.value + otherCount.value)
|
||||||
|
// 键盘导航项(selectedIndex 指向该扁平数组):目录操作 + 历史(展开时)+ 其他
|
||||||
|
const navItems = computed<QPItem[]>(() => [
|
||||||
|
...dirActionItems.value,
|
||||||
|
...(historyExpanded.value ? historyItems.value : []),
|
||||||
|
...otherItems.value,
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
collapseSubActions()
|
||||||
|
historyExpanded.value = !historyExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
|
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
|
||||||
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
|
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
|
||||||
@@ -192,24 +209,25 @@ async function doSearch() {
|
|||||||
const seq = ++searchSeq
|
const seq = ++searchSeq
|
||||||
const q = query.value.trim()
|
const q = query.value.trim()
|
||||||
if (!q) {
|
if (!q) {
|
||||||
// 空查询:当前目录文件操作(若检测到 Explorer 目录)+ 历史置顶 + 系统相关条目
|
// 空查询:目录操作(若检测到 Explorer 目录)+ 历史置顶(默认折叠)+ 系统相关条目
|
||||||
// (程序相关设置不参与默认展示;所有 Provider 空查询零 IPC,首屏即时)
|
// (程序相关设置不参与默认展示;所有 Provider 空查询零 IPC,首屏即时)
|
||||||
const items = await aggregateSearch('')
|
const items = await aggregateSearch('')
|
||||||
if (seq !== searchSeq) return // 过期请求丢弃
|
if (seq !== searchSeq) return // 过期请求丢弃
|
||||||
const dirItems = getExplorerActions()
|
dirActionItems.value = getExplorerActions()
|
||||||
results.value = applyHistoryBoost([...dirItems, ...items])
|
results.value = applyHistoryBoost(items)
|
||||||
|
// 历史置顶但默认折叠,按 Tab 展开(每次显示重置为折叠态)
|
||||||
|
historyItems.value = getAllHistoryItems()
|
||||||
|
historyExpanded.value = false
|
||||||
selectedIndex.value = 0
|
selectedIndex.value = 0
|
||||||
// 加载更多历史(Accordion 折叠区,不参与键盘导航)
|
|
||||||
moreHistoryItems.value = getMoreHistoryItems()
|
|
||||||
moreHistoryCount.value = getMoreHistoryCount()
|
|
||||||
// 后台加载应用图标(含历史中的图标)
|
// 后台加载应用图标(含历史中的图标)
|
||||||
void loadAppIconsForResults(results.value)
|
void loadAppIconsForResults(results.value)
|
||||||
void loadAppIconsForResults(moreHistoryItems.value)
|
void loadAppIconsForResults(historyItems.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 非空查询:清空历史分区
|
// 非空查询:清空历史分区与目录操作
|
||||||
moreHistoryItems.value = []
|
dirActionItems.value = []
|
||||||
moreHistoryCount.value = 0
|
historyItems.value = []
|
||||||
|
historyExpanded.value = false
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const items = await aggregateSearch(q)
|
const items = await aggregateSearch(q)
|
||||||
@@ -680,7 +698,7 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
// 子动作展开/收起
|
// 子动作展开/收起
|
||||||
function toggleSubActions(idx: number) {
|
function toggleSubActions(idx: number) {
|
||||||
const item = results.value[idx]
|
const item = navItems.value[idx]
|
||||||
if (!item?.subActions?.length) return
|
if (!item?.subActions?.length) return
|
||||||
if (subActionExpanded.value === idx) {
|
if (subActionExpanded.value === idx) {
|
||||||
subActionExpanded.value = null
|
subActionExpanded.value = null
|
||||||
@@ -697,7 +715,7 @@ function collapseSubActions() {
|
|||||||
// 当前展开的子动作列表
|
// 当前展开的子动作列表
|
||||||
function currentSubActions(): QPSubAction[] {
|
function currentSubActions(): QPSubAction[] {
|
||||||
if (subActionExpanded.value === null) return []
|
if (subActionExpanded.value === null) return []
|
||||||
return results.value[subActionExpanded.value]?.subActions || []
|
return navItems.value[subActionExpanded.value]?.subActions || []
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 键盘导航 =====
|
// ===== 键盘导航 =====
|
||||||
@@ -725,7 +743,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
const expanded = subActionExpanded.value !== null
|
const expanded = subActionExpanded.value !== null
|
||||||
const subs = currentSubActions()
|
const subs = currentSubActions()
|
||||||
const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined
|
const expandedItem = expanded ? navItems.value[subActionExpanded.value!] : undefined
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
// 子动作导航模式
|
// 子动作导航模式
|
||||||
@@ -760,7 +778,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
// 结果列表导航模式
|
// 结果列表导航模式
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1)
|
selectedIndex.value = Math.min(selectedIndex.value + 1, navTotal.value - 1)
|
||||||
scrollSelectedIntoView()
|
scrollSelectedIntoView()
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -768,17 +786,23 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
scrollSelectedIntoView()
|
scrollSelectedIntoView()
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const item = results.value[selectedIndex.value]
|
const item = navItems.value[selectedIndex.value]
|
||||||
if (item) executeItem(item)
|
if (item) executeItem(item)
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
hideWindow()
|
hideWindow()
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
// Tab 展开子动作
|
if (!query.value.trim()) {
|
||||||
const item = results.value[selectedIndex.value]
|
// 默认视图:Tab 展开/收起历史分组
|
||||||
if (item?.subActions?.length) {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleSubActions(selectedIndex.value)
|
toggleHistory()
|
||||||
|
} else {
|
||||||
|
// 搜索视图:Tab 展开子动作
|
||||||
|
const item = navItems.value[selectedIndex.value]
|
||||||
|
if (item?.subActions?.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleSubActions(selectedIndex.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1393,58 +1417,35 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 历史置顶项(可键盘导航,索引偏移 dirActionItems.length) -->
|
<!-- 历史分组:置顶、默认折叠,按 Tab 展开 -->
|
||||||
<template v-for="(item, idx) in historyItems" :key="item.id">
|
<div v-if="historyCount > 0" class="qp-history-section">
|
||||||
<div
|
<div
|
||||||
class="qp-item"
|
class="qp-item qp-history-trigger"
|
||||||
:class="{ 'qp-item-selected': (idx + dirActionItems.length) === selectedIndex }"
|
@click="toggleHistory"
|
||||||
@click="executeItem(item)"
|
|
||||||
@mouseenter="onItemHover(idx + dirActionItems.length)"
|
|
||||||
>
|
>
|
||||||
<img
|
<History class="size-4 text-muted-foreground shrink-0" />
|
||||||
v-if="item.iconUrl"
|
<div class="flex-1 min-w-0">
|
||||||
:src="item.iconUrl"
|
<p class="text-sm">历史</p>
|
||||||
class="qp-app-icon shrink-0"
|
<p class="text-xs text-muted-foreground">{{ historyCount }} 条最近记录</p>
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
:is="groupIcon(item.group)"
|
|
||||||
class="size-4 text-muted-foreground shrink-0"
|
|
||||||
:class="isAppLike(item) ? '' : 'mt-0.5'"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
|
|
||||||
<p class="text-sm truncate">{{ item.title }}</p>
|
|
||||||
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
<kbd class="qp-kbd shrink-0" @click.stop="toggleHistory">Tab</kbd>
|
||||||
<CornerDownLeft
|
<ChevronDown
|
||||||
v-if="(idx + dirActionItems.length) === selectedIndex"
|
v-if="historyExpanded"
|
||||||
class="h-3.5 w-3.5 text-primary shrink-0"
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
|
<ChevronRight
|
||||||
|
v-else
|
||||||
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<!-- 展开后的历史项(索引偏移 dirCount,可键盘导航) -->
|
||||||
|
<template v-if="historyExpanded">
|
||||||
<!-- 更多历史 Accordion(固定在历史下方,不参与键盘导航) -->
|
<template v-for="(item, idx) in historyItems" :key="item.id">
|
||||||
<Accordion
|
|
||||||
v-if="moreHistoryCount > 0"
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
class="qp-more-history"
|
|
||||||
>
|
|
||||||
<AccordionItem value="more" class="border-0">
|
|
||||||
<AccordionTrigger class="qp-more-trigger">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<History class="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
更多历史({{ moreHistoryCount }} 条)
|
|
||||||
</span>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent class="qp-more-content">
|
|
||||||
<div
|
<div
|
||||||
v-for="item in moreHistoryItems"
|
class="qp-item"
|
||||||
:key="item.id"
|
:class="{ 'qp-item-selected': (dirCount + idx) === selectedIndex }"
|
||||||
class="qp-item qp-more-item"
|
|
||||||
@click="executeItem(item)"
|
@click="executeItem(item)"
|
||||||
|
@mouseenter="onItemHover(dirCount + idx)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.iconUrl"
|
v-if="item.iconUrl"
|
||||||
@@ -1463,18 +1464,22 @@ onUnmounted(() => {
|
|||||||
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
||||||
|
<CornerDownLeft
|
||||||
|
v-if="(dirCount + idx) === selectedIndex"
|
||||||
|
class="h-3.5 w-3.5 text-primary shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</template>
|
||||||
</AccordionItem>
|
</template>
|
||||||
</Accordion>
|
</div>
|
||||||
|
|
||||||
<!-- 其他结果(设置/应用/系统等,可键盘导航,索引偏移 dirActionItems.length + historyItems.length) -->
|
<!-- 其他结果(设置/应用/系统等,可键盘导航,索引偏移 otherNavStart) -->
|
||||||
<template v-for="(item, idx) in otherItems" :key="item.id">
|
<template v-for="(item, idx) in otherItems" :key="item.id">
|
||||||
<div
|
<div
|
||||||
class="qp-item"
|
class="qp-item"
|
||||||
:class="{ 'qp-item-selected': (idx + dirActionItems.length + historyItems.length) === selectedIndex }"
|
:class="{ 'qp-item-selected': (otherNavStart + idx) === selectedIndex }"
|
||||||
@click="executeItem(item)"
|
@click="executeItem(item)"
|
||||||
@mouseenter="onItemHover(idx + dirActionItems.length + historyItems.length)"
|
@mouseenter="onItemHover(otherNavStart + idx)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.iconUrl"
|
v-if="item.iconUrl"
|
||||||
@@ -1494,21 +1499,21 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
v-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) !== selectedIndex"
|
v-if="item.subActions?.length && (otherNavStart + idx) !== selectedIndex"
|
||||||
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
/>
|
/>
|
||||||
<kbd
|
<kbd
|
||||||
v-else-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) === selectedIndex"
|
v-else-if="item.subActions?.length && (otherNavStart + idx) === selectedIndex"
|
||||||
class="qp-kbd shrink-0"
|
class="qp-kbd shrink-0"
|
||||||
@click.stop="toggleSubActions(idx + dirActionItems.length + historyItems.length)"
|
@click.stop="toggleSubActions(otherNavStart + idx)"
|
||||||
>Tab</kbd>
|
>Tab</kbd>
|
||||||
<CornerDownLeft
|
<CornerDownLeft
|
||||||
v-else-if="(idx + dirActionItems.length + historyItems.length) === selectedIndex"
|
v-else-if="(otherNavStart + idx) === selectedIndex"
|
||||||
class="h-3.5 w-3.5 text-primary shrink-0"
|
class="h-3.5 w-3.5 text-primary shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 子动作展开面板 -->
|
<!-- 子动作展开面板 -->
|
||||||
<div v-if="subActionExpanded === (idx + dirActionItems.length + historyItems.length) && item.subActions?.length" class="qp-sub-panel">
|
<div v-if="subActionExpanded === (otherNavStart + idx) && item.subActions?.length" class="qp-sub-panel">
|
||||||
<div
|
<div
|
||||||
v-for="(sub, sIdx) in item.subActions"
|
v-for="(sub, sIdx) in item.subActions"
|
||||||
:key="sub.id"
|
:key="sub.id"
|
||||||
@@ -1529,7 +1534,8 @@ onUnmounted(() => {
|
|||||||
<!-- 底部提示 -->
|
<!-- 底部提示 -->
|
||||||
<div class="qp-footer">
|
<div class="qp-footer">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
||||||
<span v-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
|
<span v-if="!query.trim()"><kbd>Tab</kbd> 历史</span>
|
||||||
|
<span v-else-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
|
||||||
<span v-else><kbd>1-9</kbd> 快捷执行</span>
|
<span v-else><kbd>1-9</kbd> 快捷执行</span>
|
||||||
<span><kbd>Enter</kbd> 执行</span>
|
<span><kbd>Enter</kbd> 执行</span>
|
||||||
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
||||||
@@ -1995,35 +2001,15 @@ onUnmounted(() => {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 更多历史 Accordion */
|
/* 历史折叠分组 */
|
||||||
.qp-more-history {
|
.qp-history-section {
|
||||||
margin: 0 6px 4px;
|
margin: 0 6px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qp-more-trigger {
|
.qp-history-trigger {
|
||||||
padding: 6px 12px;
|
min-height: 36px;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
min-height: 28px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
/* 覆盖 reka-ui 默认 py-4 */
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qp-more-trigger:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qp-more-content {
|
|
||||||
/* 覆盖 AccordionContent 默认 pb-4 */
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qp-more-item {
|
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qp-item-selected:hover {
|
.qp-item-selected:hover {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import type { QPItem, QPProvider } from './types'
|
import type { QPItem, QPProvider } from './types'
|
||||||
import { appRankFromPath } from './utils'
|
import { appRankFromPath } from './utils'
|
||||||
import { HistoryProvider } from './history'
|
|
||||||
import { CommandProvider } from './command'
|
import { CommandProvider } from './command'
|
||||||
import { CustomCommandProvider } from './customCommand'
|
import { CustomCommandProvider } from './customCommand'
|
||||||
import { AppProvider } from './app'
|
import { AppProvider } from './app'
|
||||||
@@ -21,7 +20,6 @@ let providers: QPProvider[] | null = null
|
|||||||
export function getProviders(): QPProvider[] {
|
export function getProviders(): QPProvider[] {
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
providers = [
|
providers = [
|
||||||
new HistoryProvider(),
|
|
||||||
new CommandProvider(),
|
new CommandProvider(),
|
||||||
new CustomCommandProvider(),
|
new CustomCommandProvider(),
|
||||||
new AppProvider(),
|
new AppProvider(),
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* history Provider:最近交互记录。
|
* history Provider:最近交互记录。
|
||||||
* 记录持久化到 localStorage,空查询时置顶展示最近几条;点击历史项时
|
* 记录持久化到 localStorage,供 QuickPanel 顶部折叠分组加载;点击历史项时
|
||||||
* 重新聚合搜索恢复原 action。
|
* 重新聚合搜索恢复原 action。
|
||||||
*/
|
*/
|
||||||
import { STORAGE_KEYS } from '@/lib/constants'
|
import { STORAGE_KEYS } from '@/lib/constants'
|
||||||
import type { HistoryEntry, QPItem, QPProvider } from './types'
|
import type { HistoryEntry, QPItem } from './types'
|
||||||
import { aggregateSearch } from './aggregate'
|
import { aggregateSearch } from './aggregate'
|
||||||
|
|
||||||
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
|
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
|
||||||
const HISTORY_MAX = 50
|
const HISTORY_MAX = 50
|
||||||
|
|
||||||
/** 空查询时默认展示的历史条数(置顶部分) */
|
|
||||||
export const HISTORY_PREVIEW_COUNT = 3
|
|
||||||
|
|
||||||
function loadHistoryEntries(): HistoryEntry[] {
|
function loadHistoryEntries(): HistoryEntry[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
|
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
|
||||||
@@ -76,32 +73,8 @@ export function clearHistory() {
|
|||||||
localStorage.removeItem(HISTORY_ITEMS_KEY)
|
localStorage.removeItem(HISTORY_ITEMS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */
|
/** 获取全部历史项(最近优先),供顶部可折叠的历史分组使用 */
|
||||||
export function getTopHistoryItems(): QPItem[] {
|
export function getAllHistoryItems(): QPItem[] {
|
||||||
const entries = loadHistoryEntries()
|
const entries = loadHistoryEntries()
|
||||||
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
|
return entries.map(buildHistoryItem)
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
|
|
||||||
export function getMoreHistoryItems(): QPItem[] {
|
|
||||||
const entries = loadHistoryEntries()
|
|
||||||
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取剩余历史数量(用于 Accordion 标题显示) */
|
|
||||||
export function getMoreHistoryCount(): number {
|
|
||||||
const entries = loadHistoryEntries()
|
|
||||||
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HistoryProvider implements QPProvider {
|
|
||||||
id = 'history'
|
|
||||||
label = '历史'
|
|
||||||
priority = 99 // 最高优先级,空查询时显示在最前
|
|
||||||
|
|
||||||
async search(query: string): Promise<QPItem[]> {
|
|
||||||
if (query.trim()) return [] // 历史只在空查询时显示
|
|
||||||
// 只返回置顶3条,剩余由 Accordion 承载
|
|
||||||
return getTopHistoryItems()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ export { loadAppIconsForResults, invalidateAppIconCache } from './app'
|
|||||||
export { setFileIndexReady } from './file'
|
export { setFileIndexReady } from './file'
|
||||||
export { invalidateCustomCommandsCache } from './customCommand'
|
export { invalidateCustomCommandsCache } from './customCommand'
|
||||||
export {
|
export {
|
||||||
HISTORY_PREVIEW_COUNT,
|
|
||||||
recordHistoryItem,
|
recordHistoryItem,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
getTopHistoryItems,
|
getAllHistoryItems,
|
||||||
getMoreHistoryItems,
|
|
||||||
getMoreHistoryCount,
|
|
||||||
} from './history'
|
} from './history'
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ const SYSTEM_COMMANDS: SystemCommandDef[] = [
|
|||||||
command: 'taskmgr',
|
command: 'taskmgr',
|
||||||
args: [],
|
args: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-devmgmt',
|
||||||
|
title: '设备管理器',
|
||||||
|
subtitle: 'devmgmt.msc',
|
||||||
|
keywords: ['devmgmt', '设备管理', '硬件', '驱动', 'sheb'],
|
||||||
|
command: 'devmgmt.msc',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sys-explorer',
|
id: 'sys-explorer',
|
||||||
title: '资源管理器',
|
title: '资源管理器',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/sto
|
|||||||
import { useSearchStore } from '@/stores/searchStore'
|
import { useSearchStore } from '@/stores/searchStore'
|
||||||
import { useProcessStore } from '@/stores/processStore'
|
import { useProcessStore } from '@/stores/processStore'
|
||||||
import { useDownloaderStore } from '@/stores/downloaderStore'
|
import { useDownloaderStore } from '@/stores/downloaderStore'
|
||||||
|
import { useMonitorStore } from '@/stores/monitorStore'
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||||
import { getModuleIcon } from '@/modules/icons'
|
import { getModuleIcon } from '@/modules/icons'
|
||||||
import { commands, type UpdateCheckResult } from '@/lib/bindings'
|
import { commands, type UpdateCheckResult } from '@/lib/bindings'
|
||||||
import { EVENTS } from '@/lib/constants'
|
import { EVENTS } from '@/lib/constants'
|
||||||
@@ -44,7 +46,6 @@ const appUpdating = ref(false)
|
|||||||
/** ThingHK 内核更新中 */
|
/** ThingHK 内核更新中 */
|
||||||
const kernelUpdating = ref(false)
|
const kernelUpdating = ref(false)
|
||||||
const progress = ref<UpdateProgress | null>(null)
|
const progress = ref<UpdateProgress | null>(null)
|
||||||
const thinghkExists = ref(false)
|
|
||||||
let progressUnlisten: UnlistenFn | null = null
|
let progressUnlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
const installTypeText = computed(() =>
|
const installTypeText = computed(() =>
|
||||||
@@ -215,19 +216,203 @@ const cancelUpdateDownload = async () => {
|
|||||||
cancelAppDownload?.()
|
cancelAppDownload?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新 ThingHK 内核:后端先停止监控内核再覆盖文件 */
|
/** 更新 ThingHK 内核:下载模块下载 → apply 命令 need_stop 等待确认 → 停止内核 → 解压替换。
|
||||||
|
* 与代理模块 mihomo 内核更新同模式。 */
|
||||||
|
const thinghkExists = ref(false)
|
||||||
|
/** 需要停止监控内核的确认弹窗状态(need_stop 阶段由后端 event 触发) */
|
||||||
|
const thinghkConfirmState = ref<{ open: boolean; wasRunning: boolean; busy: boolean; resolved: boolean }>({
|
||||||
|
open: false,
|
||||||
|
wasRunning: false,
|
||||||
|
busy: false,
|
||||||
|
resolved: false,
|
||||||
|
})
|
||||||
|
const monitorStore = useMonitorStore()
|
||||||
|
/** 下载中可取消(沿用应用更新下载的取消交互) */
|
||||||
|
const kernelDownloadCancellable = ref(false)
|
||||||
|
/** 取消 ThingHK 内核包下载的唤醒回调 */
|
||||||
|
let cancelKernelDownload: (() => void) | null = null
|
||||||
|
|
||||||
|
/** 停止 Kernel 确认弹窗中处理中标记(防止重复触发) */
|
||||||
|
let handlingThinghkNeedStop = false
|
||||||
|
const handleThinghkNeedStop = () => {
|
||||||
|
if (handlingThinghkNeedStop) return
|
||||||
|
handlingThinghkNeedStop = true
|
||||||
|
try {
|
||||||
|
// 若程序被最小化/隐藏到后台,先弹到前台再显示确认框
|
||||||
|
invoke('quickpanel_focus_main_window').catch(() => { /* 忽略 */ })
|
||||||
|
const wasRunning = monitorStore.status?.running ?? false
|
||||||
|
thinghkConfirmState.value = { open: true, wasRunning, busy: false, resolved: false }
|
||||||
|
} finally {
|
||||||
|
handlingThinghkNeedStop = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认停止内核并唤醒后端 apply 继续解压替换 */
|
||||||
|
const onThinghkNeedStopConfirm = async () => {
|
||||||
|
const s = thinghkConfirmState.value
|
||||||
|
if (s.resolved) return
|
||||||
|
// 置 resolved 防止 AlertDialog 的 update:open(false) 兜底逻辑把确认误判为取消
|
||||||
|
s.resolved = true
|
||||||
|
s.busy = true
|
||||||
|
try {
|
||||||
|
if (s.wasRunning) {
|
||||||
|
await monitorStore.stop()
|
||||||
|
}
|
||||||
|
await invoke('update_thinghk_confirm')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('停止监控内核失败', { description: String(e) })
|
||||||
|
// 停止失败则中止更新,避免替换阶段因 exe 占用而报错
|
||||||
|
try { await invoke('update_thinghk_cancel') } catch { /* 忽略 */ }
|
||||||
|
} finally {
|
||||||
|
s.busy = false
|
||||||
|
s.open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消 ThingHK 内核更新(中止后端等待中的 apply 流程,zip 保留便于重试) */
|
||||||
|
const onThinghkNeedStopCancel = () => {
|
||||||
|
const s = thinghkConfirmState.value
|
||||||
|
if (s.resolved) return
|
||||||
|
s.resolved = true
|
||||||
|
s.open = false
|
||||||
|
invoke('update_thinghk_cancel').catch(() => { /* 忽略 */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AlertDialog 关闭事件的兜底判定:cancel/action 点击会先 update:open(false) 再 click,
|
||||||
|
* 仅在此前未被 click 处理器 resolve 时才当作取消(遮罩/Esc 关闭),否则会误判确认/取消。 */
|
||||||
|
const onThinghkNeedStopOpenChange = (open: boolean) => {
|
||||||
|
const s = thinghkConfirmState.value
|
||||||
|
if (!open && !s.resolved) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!thinghkConfirmState.value.resolved) onThinghkNeedStopCancel()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
thinghkConfirmState.value.open = open
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消 ThingHK 内核包下载(仅下载阶段) */
|
||||||
|
const cancelKernelUpdateDownload = () => {
|
||||||
|
if (!kernelDownloadCancellable.value) return
|
||||||
|
cancelKernelDownload?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 ThingHK 内核 */
|
||||||
const updateThinghkKernel = async () => {
|
const updateThinghkKernel = async () => {
|
||||||
if (kernelUpdating.value) return
|
if (kernelUpdating.value) return
|
||||||
|
const result = updateResult.value
|
||||||
|
if (!result) return
|
||||||
|
// 从 release assets 中定位 ThingHK 内核包(zip);找不到则提示手动下载
|
||||||
|
const asset = result.assets.find(a => {
|
||||||
|
const n = a.name.toLowerCase()
|
||||||
|
return (n.includes('thing-hk') || n.includes('thinghk')) && n.endsWith('.zip')
|
||||||
|
})
|
||||||
|
if (!asset) {
|
||||||
|
toast.error('未找到 ThingHK 内核更新包', {
|
||||||
|
description: '请确认 release 资产中已上传 ThingHK 内核 zip 包',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
kernelUpdating.value = true
|
kernelUpdating.value = true
|
||||||
|
progress.value = {
|
||||||
|
stage: 'downloading',
|
||||||
|
percent: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: asset.size || null,
|
||||||
|
message: '准备开始下载...',
|
||||||
|
}
|
||||||
|
|
||||||
|
let taskId: string | null = null
|
||||||
|
let downloadProgressFn: UnlistenFn | null = null
|
||||||
|
// 用对象持有完成事件解绑函数,避免闭包内赋值导致的 TS 类型收窄问题(同 proxyStore)
|
||||||
|
const completeHolder: { fn: UnlistenFn | null } = { fn: null }
|
||||||
|
let downloadOk = false
|
||||||
try {
|
try {
|
||||||
await commands.updateThinghk()
|
// 确保下载模块事件监听已注册(下载器 UI 与这里共用事件流)
|
||||||
|
try { await downloaderStore.startEventListeners() } catch { /* 忽略 */ }
|
||||||
|
taskId = await downloaderStore.addTask(asset.browserDownloadUrl, asset.name, undefined, {}, false)
|
||||||
|
|
||||||
|
// 下载进度 → 更新进度条
|
||||||
|
downloadProgressFn = await listen<{
|
||||||
|
id: string; completedSize: number; totalSize: number; speed: number; status: string
|
||||||
|
}>('download-progress', (e) => {
|
||||||
|
if (e.payload.id !== taskId || !kernelUpdating.value) return
|
||||||
|
const pct = e.payload.totalSize > 0
|
||||||
|
? Math.round((e.payload.completedSize / e.payload.totalSize) * 100)
|
||||||
|
: 0
|
||||||
|
progress.value = {
|
||||||
|
stage: 'downloading',
|
||||||
|
percent: pct,
|
||||||
|
downloadedBytes: e.payload.completedSize,
|
||||||
|
totalBytes: e.payload.totalSize,
|
||||||
|
message: e.payload.speed > 0
|
||||||
|
? `正在下载... ${fmtSpeed(e.payload.speed)}`
|
||||||
|
: '正在下载...',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 等待下载完成 / 失败 / 取消
|
||||||
|
kernelDownloadCancellable.value = true
|
||||||
|
const dlResult = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (r: { ok: boolean; error?: string }) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
cancelKernelDownload = null
|
||||||
|
resolve(r)
|
||||||
|
}
|
||||||
|
// 任务添加后瞬间进入终态(如探测即失败)
|
||||||
|
const initial = downloaderStore.tasks.find(t => t.id === taskId)
|
||||||
|
if (initial) {
|
||||||
|
if (initial.status === 'complete') { finish({ ok: true }); return }
|
||||||
|
if (initial.status === 'error') { finish({ ok: false, error: initial.error || '下载失败' }); return }
|
||||||
|
}
|
||||||
|
cancelKernelDownload = () => finish({ ok: false, error: '已取消下载' })
|
||||||
|
listen<{ id: string; status: string; error: string | null }>('download-complete', (e) => {
|
||||||
|
if (e.payload.id === taskId) {
|
||||||
|
if (e.payload.status === 'complete') finish({ ok: true })
|
||||||
|
else finish({ ok: false, error: e.payload.error || '下载失败' })
|
||||||
|
}
|
||||||
|
}).then(fn => { completeHolder.fn = fn })
|
||||||
|
})
|
||||||
|
kernelDownloadCancellable.value = false
|
||||||
|
if (!dlResult.ok) throw new Error(dlResult.error || '下载失败')
|
||||||
|
downloadOk = true
|
||||||
|
|
||||||
|
// 取下载文件路径 → 移除任务记录(保留文件,apply 命令内部会解压并清理)
|
||||||
|
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
|
||||||
|
if (!dlTask) throw new Error('下载任务未找到')
|
||||||
|
const zipPath = dlTask.dir + '/' + dlTask.filename
|
||||||
|
try {
|
||||||
|
await downloaderStore.removeTask(taskId, false)
|
||||||
|
taskId = null
|
||||||
|
} catch { /* 任务清理失败不阻断更新 */ }
|
||||||
|
|
||||||
|
// 应用阶段:apply 内部 need_stop 等待前端停止内核并确认 → 解压替换 → 完成
|
||||||
|
await invoke('update_thinghk_apply', { zipPath })
|
||||||
await loadAppInfo()
|
await loadAppInfo()
|
||||||
toast.success('ThingHK 内核更新完成')
|
toast.success('ThingHK 内核更新完成')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[updater] ThingHK 更新失败', e)
|
console.error('[updater] ThingHK 更新失败', e)
|
||||||
toast.error('ThingHK 内核更新失败', { description: String(e) })
|
const msg = String(e)
|
||||||
|
if (msg.includes('已取消下载')) {
|
||||||
|
toast.info('已取消更新下载')
|
||||||
|
} else if (msg.includes('更新已取消')) {
|
||||||
|
toast.info('已取消内核更新')
|
||||||
|
} else {
|
||||||
|
toast.error('ThingHK 内核更新失败', { description: msg })
|
||||||
|
}
|
||||||
|
// 清理下载任务:下载失败/取消时删除半成品文件;apply 失败保留 zip 便于重试
|
||||||
|
if (taskId) {
|
||||||
|
try { await downloaderStore.removeTask(taskId, !downloadOk) } catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
kernelUpdating.value = false
|
kernelUpdating.value = false
|
||||||
|
progress.value = null
|
||||||
|
kernelDownloadCancellable.value = false
|
||||||
|
cancelKernelDownload = null
|
||||||
|
if (downloadProgressFn) downloadProgressFn()
|
||||||
|
if (completeHolder.fn) completeHolder.fn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +425,14 @@ onMounted(() => {
|
|||||||
// 监听更新进度事件(应用更新与 ThingHK 内核更新共用)
|
// 监听更新进度事件(应用更新与 ThingHK 内核更新共用)
|
||||||
listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
|
listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
|
||||||
progress.value = e.payload
|
progress.value = e.payload
|
||||||
if (e.payload.stage === 'done') {
|
const p = e.payload
|
||||||
|
if (p.stage === 'need_stop') {
|
||||||
|
// ThingHK 内核更新:解压替换前需停止监控内核,弹窗确认(若在后台自动弹到前台)
|
||||||
|
handleThinghkNeedStop()
|
||||||
|
} else if (p.stage === 'done') {
|
||||||
kernelUpdating.value = false
|
kernelUpdating.value = false
|
||||||
progress.value = null
|
progress.value = null
|
||||||
} else if (e.payload.stage === 'error') {
|
} else if (p.stage === 'error') {
|
||||||
// ThingHK 内核更新失败(后端 emit);应用更新失败走命令 reject 路径
|
// ThingHK 内核更新失败(后端 emit);应用更新失败走命令 reject 路径
|
||||||
kernelUpdating.value = false
|
kernelUpdating.value = false
|
||||||
}
|
}
|
||||||
@@ -407,6 +596,16 @@ const onDragEnd = () => {
|
|||||||
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
|
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2 border-t border-border/60 mt-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label class="text-base font-medium">静默启动</Label>
|
||||||
|
<p class="text-sm text-muted-foreground">启动后不打开主界面,静默驻留托盘(托盘左键可呼出)</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
:model-value="appStore.silentAutoStart"
|
||||||
|
@update:model-value="(checked: boolean) => appStore.toggleSilentAutoStart(checked)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -675,8 +874,42 @@ const onDragEnd = () => {
|
|||||||
<span>{{ progress.message }}</span>
|
<span>{{ progress.message }}</span>
|
||||||
<span class="font-mono">{{ progress.percent }}%</span>
|
<span class="font-mono">{{ progress.percent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress :model-value="progress.percent" />
|
<div class="flex items-center gap-2">
|
||||||
|
<Progress :model-value="progress.percent" class="flex-1" />
|
||||||
|
<Button
|
||||||
|
v-if="kernelDownloadCancellable"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-5 px-1.5 text-xs"
|
||||||
|
@click="cancelKernelUpdateDownload"
|
||||||
|
>
|
||||||
|
<X class="size-3 mr-0.5" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 停止监控内核确认(need_stop 阶段:与 mihomo 同模式) -->
|
||||||
|
<AlertDialog :open="thinghkConfirmState.open" @update:open="onThinghkNeedStopOpenChange">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>停止监控内核后继续</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{{
|
||||||
|
thinghkConfirmState.wasRunning
|
||||||
|
? '内核更新包已下载。安装新内核前需要停止监控内核,点击「停止并继续」将自动停止监控并完成安装。'
|
||||||
|
: '内核更新包已下载。即将安装新内核,点击「继续」完成安装。'
|
||||||
|
}}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel @click="onThinghkNeedStopCancel">取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction :disabled="thinghkConfirmState.busy" @click="onThinghkNeedStopConfirm">
|
||||||
|
{{ thinghkConfirmState.busy ? '正在停止...' : (thinghkConfirmState.wasRunning ? '停止并继续' : '继续') }}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const theme = ref<Theme>('system')
|
const theme = ref<Theme>('system')
|
||||||
const effect = ref<EffectType>('mica')
|
const effect = ref<EffectType>('mica')
|
||||||
const isAutoStart = ref(false)
|
const isAutoStart = ref(false)
|
||||||
|
const silentAutoStart = ref(false)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
|
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
|
||||||
const moduleOrder = ref<string[]>(initModuleOrder())
|
const moduleOrder = ref<string[]>(initModuleOrder())
|
||||||
@@ -88,6 +89,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
if (settings.theme) theme.value = settings.theme
|
if (settings.theme) theme.value = settings.theme
|
||||||
if (settings.effect) effect.value = settings.effect
|
if (settings.effect) effect.value = settings.effect
|
||||||
|
if (typeof settings.isAutoStart === 'boolean') isAutoStart.value = settings.isAutoStart
|
||||||
|
if (typeof settings.silentAutoStart === 'boolean') silentAutoStart.value = settings.silentAutoStart
|
||||||
if (settings.modules) {
|
if (settings.modules) {
|
||||||
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
|
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
|
||||||
savedModules.forEach(sm => {
|
savedModules.forEach(sm => {
|
||||||
@@ -127,6 +130,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
theme: theme.value,
|
theme: theme.value,
|
||||||
effect: effect.value,
|
effect: effect.value,
|
||||||
isAutoStart: isAutoStart.value,
|
isAutoStart: isAutoStart.value,
|
||||||
|
silentAutoStart: silentAutoStart.value,
|
||||||
modules: modulesData,
|
modules: modulesData,
|
||||||
moduleOrder: moduleOrder.value
|
moduleOrder: moduleOrder.value
|
||||||
}))
|
}))
|
||||||
@@ -323,6 +327,11 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSilentAutoStart = (checked?: boolean) => {
|
||||||
|
silentAutoStart.value = checked !== undefined ? checked : !silentAutoStart.value
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
const applyTheme = async () => {
|
const applyTheme = async () => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
@@ -472,6 +481,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
} finally {
|
} finally {
|
||||||
|
// 静默启动:仅当未开启时才显示主窗口(开启后启动静默驻留托盘,托盘左键可呼出)
|
||||||
|
if (silentAutoStart.value) return
|
||||||
try {
|
try {
|
||||||
const tauriWindow = getCurrentWindow()
|
const tauriWindow = getCurrentWindow()
|
||||||
await tauriWindow.show()
|
await tauriWindow.show()
|
||||||
@@ -486,6 +497,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
effect,
|
effect,
|
||||||
systemDark,
|
systemDark,
|
||||||
isAutoStart,
|
isAutoStart,
|
||||||
|
silentAutoStart,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
modules,
|
modules,
|
||||||
moduleOrder,
|
moduleOrder,
|
||||||
@@ -497,6 +509,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
setTheme,
|
setTheme,
|
||||||
setEffect,
|
setEffect,
|
||||||
toggleAutoStart,
|
toggleAutoStart,
|
||||||
|
toggleSilentAutoStart,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
applyEffect,
|
applyEffect,
|
||||||
init,
|
init,
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export interface OsdConfig {
|
|||||||
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
|
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
|
||||||
overlayX?: number | null
|
overlayX?: number | null
|
||||||
overlayY?: number | null
|
overlayY?: number | null
|
||||||
|
/** 游戏全屏时自动隐藏悬浮窗(前台全屏应用会因置顶透明窗口掉帧,默认开启) */
|
||||||
|
gameAutoHide: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS) */
|
/** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS) */
|
||||||
@@ -274,6 +276,7 @@ function defaultOsdConfig(): OsdConfig {
|
|||||||
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
|
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
|
||||||
overlayX: null,
|
overlayX: null,
|
||||||
overlayY: null,
|
overlayY: null,
|
||||||
|
gameAutoHide: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +343,10 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
*/
|
*/
|
||||||
const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
||||||
|
|
||||||
|
/** 前台是否为全屏应用(游戏)。由 Rust 侧 osd-game-active/inactive 事件驱动,
|
||||||
|
* 用于游戏时隐藏 OSD(透明置顶窗口会占用 DWM 合成路径导致游戏掉帧) */
|
||||||
|
const gameFullscreen = ref(false)
|
||||||
|
|
||||||
/** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */
|
/** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */
|
||||||
let osdSaveTimer: ReturnType<typeof setTimeout> | null = null
|
let osdSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
/** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */
|
/** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */
|
||||||
@@ -367,6 +374,8 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
|
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
|
||||||
async function pushOsdState() {
|
async function pushOsdState() {
|
||||||
if (!osdConfig.value.overlayEnabled) return
|
if (!osdConfig.value.overlayEnabled) return
|
||||||
|
// 游戏全屏自动隐藏期间停止推送:OSD 窗口已隐藏,推送只会白白消耗 IPC 和 WebView JS 时间片
|
||||||
|
if (gameFullscreen.value && osdConfig.value.gameAutoHide) return
|
||||||
try {
|
try {
|
||||||
const map = sensorKeyMap.value
|
const map = sensorKeyMap.value
|
||||||
const data: Record<string, number | null> = {}
|
const data: Record<string, number | null> = {}
|
||||||
@@ -1190,12 +1199,24 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
// 开启时若显示项为空则不创建窗口
|
// 开启时若显示项为空则不创建窗口
|
||||||
if (osdConfig.value.overlayItems.length === 0) return
|
if (osdConfig.value.overlayItems.length === 0) return
|
||||||
|
// 游戏全屏自动隐藏期间不创建窗口(退出全屏时由 osd-game-inactive 统一恢复)
|
||||||
|
if (gameFullscreen.value && osdConfig.value.gameAutoHide) return
|
||||||
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
|
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
|
||||||
} else {
|
} else {
|
||||||
hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e))
|
hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 游戏中切换"自动隐藏"开关:立即生效(关闭时恢复显示,开启时立即隐藏)
|
||||||
|
osdWatchStops.push(watch(() => osdConfig.value.gameAutoHide, (enabled) => {
|
||||||
|
if (!osdConfig.value.overlayEnabled || !gameFullscreen.value) return
|
||||||
|
if (enabled) {
|
||||||
|
hideOverlayWindow().catch(e => logger.error('[OSD] 游戏全屏隐藏悬浮窗失败: ' + e))
|
||||||
|
} else {
|
||||||
|
ensureOverlayWindow().catch(e => logger.error('[OSD] 恢复悬浮窗失败: ' + e))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
// 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在
|
// 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在
|
||||||
osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => {
|
osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => {
|
||||||
if (!osdConfig.value.overlayEnabled) return
|
if (!osdConfig.value.overlayEnabled) return
|
||||||
|
|||||||
+4
-2
@@ -54,14 +54,16 @@ export default defineConfig(async () => ({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
// 1420/1421 落在 Windows 保留端口段 1333-1432 内(Hyper-V/WinNAT 动态保留),
|
||||||
|
// 绑定时报 EACCES,故改用 14210/14211(netsh excludedportrange 确认可用)
|
||||||
|
port: 14210,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
host,
|
host,
|
||||||
port: 1421,
|
port: 14211,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
Reference in New Issue
Block a user