diff --git a/package.json b/package.json index b121997..a1a9c0c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "thing", "private": true, - "version": "26.8.4", + "version": "26.8.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 70f9e5b..6d0fa78 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5951,7 +5951,7 @@ dependencies = [ [[package]] name = "thing" -version = "26.8.4" +version = "26.8.5" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5985,6 +5985,7 @@ dependencies = [ "url", "walkdir", "windows 0.52.0", + "windows-capture", "windows-sys 0.52.0", "winreg 0.52.0", "zip", @@ -6908,6 +6909,19 @@ dependencies = [ "windows-numerics 0.3.1", ] +[[package]] +name = "windows-capture" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09046acbe9b6d039cde50e8c8178d95bb0f9b33ed7feccc4465dadc5404b31b4" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-future 0.3.2", +] + [[package]] name = "windows-collections" version = "0.2.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eb21671..a3502e9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thing" -version = "26.8.4" +version = "26.8.5" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -47,6 +47,9 @@ walkdir = "2" notify = { version = "6", features = [] } librqbit = "9" bytes = "1" +# HDR 截图:Windows 高级色彩(HDR)下 BitBlt 会把 scRGB 线性像素当 sRGB 直出导致过曝/失真, +# 需改用 Windows Graphics Capture(Rgba16F)+ God→sRGB 色调映射。仅在检测到 HDR 时启用。 +windows-capture = "2.0.1" [target.'cfg(windows)'.dependencies] winreg = "0.52" @@ -68,8 +71,9 @@ windows-sys = { version = "0.52", features = [ "Win32_Graphics_Dwm", "Win32_Storage_Xps", "Win32_Storage_FileSystem", + "Win32_Devices_Display", ] } -# Explorer 鬯ョ・ッ繝サ・キ鬮ォ・ィ繝サ・ャ郢晢スサ繝サ・ヲ鬯ョ・ョ郢晢スサ繝サ・ス繝サ・コ髣費スィ郢晢スサ・つ€郢晢スサ繝サ・カ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ョ・ッ雋翫・譚溽ケ晢スサ繝サ・「鬯ョ・ョ繝サ・」郢晢スサ繝サ・ス郢晢スサ繝サ・」郢晢スサ邵コ・、・つ€鬯ョ・ョ髮懶ス」繝サ・ス繝サ・ャ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・シ鬩幢ス「隴趣ス「繝サ・ス繝サ・サShellWindows COM鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮」雋サ・ス・ィ髯樊サゑスス・イ郢晢スサ繝サ・ス郢晢スサ繝サ・シ鬮ッ讖ク・ス・「郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬮ッ・キ髢ァ・エ繝サ・コ陋滂ス・郢晢スサ鬯ッ・ィ繝サ・セ髯具スケ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ィ繝サ・セ郢晢スサ繝サ・ァ鬩幢ス「隴趣ス「繝サ・ス繝サ・サfeature鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・エ郢晢スサ繝サ・ァ鬯ョ・「繝サ・ー髴托スケ陞「・シ繝サ・エ髴域鱒繝サ郢晢スサ繝サ・カ鬯ッ・ゥ陝カ蟷「・ス・ク陝カ蜷カ繝サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬮ッ・キ繝サ・ソ郢晢スサ繝サ・ー驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・ッ +# Explorer 鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・ャ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ョ・」髮具スサ繝サ・ス繝サ・ィ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ郢晢スサ邵コ・、・つ€鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ鬮ョ迢暦スソ・ォ郢晢スサ髫エ螢ス・コ・ス繝サ・ケ隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬩搾スオ繝サ・コ郢晢スサ繝サ・、郢晢スサ邵コ・、・つ€鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ャ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ鬯ョ・」鬲・シ夲スス・ス繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サShellWindows COM鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・」鬮ョ蜈キ・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ隶灘・・スス・サ郢ァ謇假スス・ス繝サ・ス郢晢スサ繝サ・イ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ョ繝サ・ッ髫カ蜴・スス・ク郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ鬯ョ・「繝サ・ァ郢晢スサ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・コ鬮ッ蛹コ・サ繧托スス・ス繝サ・・鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・セ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・ケ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・セ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サfeature鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・エ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ョ・エ隰・∞・ス・ス繝サ・ケ鬮ッ讖ク・ス・「郢晢スサ繝サ・シ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ョ・エ陜捺サ・スア蝣、・ケ譎「・ス・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・カ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・ク鬮ッ譎「・ス・カ髯キ・キ繝サ・カ驛「譎「・ス・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・ソ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ェ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ョ・ッ繝サ・キ鬮」魃会スス・ィ郢晢スサ繝サ・ス郢晢スサ繝サ・キ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ windows = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Com", @@ -77,19 +81,23 @@ windows = { version = "0.52", features = [ "Win32_System_Variant", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", + # HDR 检测:枚举 DXGI 输出色彩空间(advanced color 时输出 G2084/PQ 空间) + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_Graphics_Gdi", ] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" -# ===== 驛帛・・ッ蜿ー・シ莨懷密 ===== -# dev 隴ォ繝サ・サ・コ陷キ・ッ騾包スィ陟・ィ」纃シ驛帛・・ッ繝サ+ 髯ヲ謔滓差驛、・ァ髫ケ繝サ・ッ蛹・スシ蝓溽スイ鬨セ貊捺た陜ィ・ー髴托スュ闔会ス」繝サ莨夲スシ蠕。・サ繝サ・ス・ア陷ゥ繝サ`tauri dev` +# ===== 鬯ゥ蟶幢スク蟶吶・郢晢スサ繝サ・ッ髯キ・ソ繝サ・ー郢晢スサ繝サ・シ髣費スィ隲幢スキ陝・・===== +# dev 鬮ォ・エ繝サ・ォ驛「譎「・ス・サ郢晢スサ繝サ・サ郢晢スサ繝サ・コ鬮ッ・キ繝サ・キ郢晢スサ繝サ・ッ鬯ィ・セ陋ケ繝サ・ス・ス繝サ・ィ鬮ッ貅倥・繝サ・ィ繝サ・」驛、繝サ・ス・シ鬯ゥ蟶幢スク蟶吶・郢晢スサ繝サ・ッ驛「譎「・ス・サ+ 鬯ョ・ッ繝サ・ヲ髫ー逍イ・サ轣假スキ・ョ鬯ゥ蟷「・ス・、郢晢スサ繝サ・ァ鬯ョ・ォ繝サ・ケ驛「譎「・ス・サ郢晢スサ繝サ・ッ髯具スケ郢晢スサ繝サ・ス繝サ・シ髯懈瑳・コ・ス繝サ・ス繝サ・イ鬯ッ・ィ繝サ・セ髮矩醍袖邵コ貊・活繝サ・ィ郢晢スサ繝サ・ー鬯ョ・エ隰・∞・ス・ス繝サ・ュ鬮」豈費スシ螟イ・ス・ス繝サ・」驛「譎「・ス・サ髣費スィ陞滂スイ繝サ・ス繝サ・シ髯溷桁・ス・。郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ア鬮ッ・キ繝サ・ゥ驛「譎「・ス・サ`tauri dev` [profile.dev] incremental = true debug = "line-tables-only" -# release 髫エ・ォ郢晢スサ繝サ・サ繝サ・コ鬨セ蜴・スス・ヲ鬮エ繝サ・ス・ォ郢晢スサ陞「・シ隰碑崟蝨キ繝サ・ヲ髯キ・ソ繝サ・キ + LTO郢晢スサ隰疲コ倩ゥ宣劑繝サ・ク讖ク・ス・ョ髣・スス繝サ・」郢晢スサ隴ッ竏ャ謚・ェー蜈キ・ス・ァ繝サ・ッ +# release 鬯ョ・ォ繝サ・エ郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ッ・ィ繝サ・セ髯キ・エ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ヲ鬯ッ・ョ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ッ讖ク・ス・「郢晢スサ繝サ・シ鬮ォ・ー驕貞、ァ・エ貅ッ闊峨・・キ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ョ・ッ繝サ・キ郢晢スサ繝サ・ソ驛「譎「・ス・サ郢晢スサ繝サ・キ + LTO鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・ー騾搾スイ繝サ・コ陋滂スゥ繝サ・ゥ陞ウ・」陷蛾・・ケ譎「・ス・サ郢晢スサ繝サ・ク髫カ蜴・スス・ク郢晢スサ繝サ・ス郢晢スサ繝サ・ョ鬯ョ・」郢晢スサ繝サ・ス繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・ッ驕カ謫セ・ス・ャ髫ー螢ケ繝サ繝サ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・ッ [profile.release] strip = true lto = true diff --git a/src-tauri/capabilities/screenshot.json b/src-tauri/capabilities/screenshot.json index d9da867..e646c66 100644 --- a/src-tauri/capabilities/screenshot.json +++ b/src-tauri/capabilities/screenshot.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "screenshot", - "description": "Capability for screenshot overlay, editor and pin windows", - "windows": ["screenshot-overlay*", "screenshot-editor-*", "screenshot-pin"], + "description": "Capability for screenshot overlay, editor, pin and scroll-control windows", + "windows": ["screenshot-overlay*", "screenshot-editor*", "screenshot-pin", "screenshot-scroll-*"], "permissions": [ "core:default", "core:window:allow-hide", @@ -18,6 +18,7 @@ "core:window:allow-close", "core:event:allow-emit", "core:event:allow-listen", + "core:webview:allow-create-webview-window", "dialog:default", "snap-layout:default" ] diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 9340efa..0163ede 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -12,6 +12,8 @@ pub mod windows { pub const SCREENSHOT_OVERLAY: &str = "screenshot-overlay"; #[allow(dead_code)] pub const SCREENSHOT_PIN: &str = "screenshot-pin"; + #[allow(dead_code)] + pub const SCREENSHOT_SCROLL: &str = "screenshot-scroll"; } /// Tauri 事件名(与前端 constants::EVENTS 对应) @@ -51,6 +53,14 @@ pub mod events { // 截图 pub const SCREENSHOT_SHORTCUT: &str = "screenshot-shortcut"; pub const SCREENSHOT_PIN_SHORTCUT: &str = "screenshot-pin-shortcut"; + /// 截图结果导出(含覆盖层/编辑器/滚动截图会话) + pub const SCREENSHOT_EXPORTED: &str = "screenshot-exported"; + /// 滚动截图会话:实时进度 { width, height, auto } + pub const SCROLL_PROGRESS: &str = "screenshot-scroll-progress"; + /// 滚动截图会话:完成并导出(负载与 SCREENSHOT_EXPORTED 相同) + pub const SCROLL_COMPLETE: &str = "screenshot-scroll-complete"; + /// 滚动截图会话:已取消(无负载) + pub const SCROLL_CANCELLED: &str = "screenshot-scroll-cancelled"; // 内核安装进度 pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress"; // 后端自动切换节点完成(前端据以刷新节点列表并提示) diff --git a/src-tauri/src/download_engine/engine.rs b/src-tauri/src/download_engine/engine.rs index f46cc98..c2e42d4 100644 --- a/src-tauri/src/download_engine/engine.rs +++ b/src-tauri/src/download_engine/engine.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -115,8 +115,8 @@ struct EngineInner { http: HttpDownloader, /// BitTorrent 下载器(封装 librqbit 全局会话) torrent: TorrentDownloader, - /// 下载是否使用代理(true=尊重系统代理,false=强制直连) - /// 独立于 settings 存储,避免下载过程中反复锁 settings;save_settings 时同步更新 + /// 下载是否使用代理(true=尝试走本地 mihomo 代理(mihomo 运行时才走),false=强制直连)。 + /// 不再依赖系统代理。独立于 settings 存储,避免下载过程中反复锁 settings;save_settings 时同步更新 use_proxy: AtomicBool, /// Tauri 应用句柄(用于发事件) app_handle: AppHandle, @@ -126,6 +126,11 @@ struct EngineInner { next_gen: AtomicU64, /// 持久化节流:上次保存时间 last_save: Mutex, + /// mihomo 代理地址解析缓存(含解析时刻),避免每个任务反复探测控制端 + mihomo_proxy_cache: Mutex)>>, + /// HTTP 任务元数据后台探测中(占位任务已建但尚未完成探测/分段): + /// 期间 schedule 跳过此类任务,防止用"未知大小/单段"提前启动;探测完成后移除并重新调度 + http_pending: Mutex>, } impl DownloadEngine { @@ -161,7 +166,7 @@ impl DownloadEngine { let use_proxy = settings.use_proxy; // BT 会话默认目录(下载目录,每个任务用 output_folder 覆盖) // 解析 BT 代理地址(若开启且代理模块可用) - let bt_socks = bt_proxy_addr(&app_handle, settings.bt_use_proxy); + let bt_socks = bt_proxy_addr(&app_handle, settings.use_proxy); let torrent = TorrentDownloader::new(PathBuf::from(&settings.download_dir)); // 同步 BT 专属设置(上传限速 / 监听端口 / 代理) torrent.set_settings(settings.bt_upload_limit_kb, settings.bt_listen_port, bt_socks); @@ -180,6 +185,8 @@ impl DownloadEngine { started: AtomicBool::new(false), next_gen: AtomicU64::new(0), last_save: Mutex::new(Instant::now()), + mihomo_proxy_cache: Mutex::new(None), + http_pending: Mutex::new(HashSet::new()), }), }; @@ -227,6 +234,7 @@ impl DownloadEngine { } let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst); + self.ensure_mihomo_proxy().await; let probe = self.inner.http.probe(url, headers, use_proxy).await; let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone(); let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone()); @@ -439,50 +447,50 @@ impl DownloadEngine { return self.add_bt_task(url, dir, only_files).await; } - // 探测资源信息 - let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst); - let probe = self.inner.http.probe(&url, &headers, use_proxy).await; - + // HTTP 任务采用"立即占位 + 后台探测": + // 点击添加后立即返回 id 并让任务出现在列表,探测/分段/调度全部放后台, + // 避免添加操作阻塞在慢速探测(如 mihomo 未就绪/网络不通)上,也防止用户因"没反应"多次点击。 let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone()); + let task_dir = dir.clone().unwrap_or_else(|| settings.download_dir.clone()); - // 确定文件名 - let mut task_filename = filename - .or_else(|| probe.as_ref().ok().and_then(|p| p.filename.clone())) - .unwrap_or_else(|| { - url.split('?') - .next() - .and_then(|u| u.rsplit('/').next()) - .filter(|n| !n.is_empty()) - .map(|n| n.to_string()) - .unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp())) - }); - - // 自动重命名:若磁盘已存在同名文件,追加 (1)、(2)... - if auto_rename { - task_filename = self.generate_unique_filename(&task_dir, &task_filename); + // 1) 后端 URL 去重:同 URL 且非终态任务已存在 → 直接复用既有 id,不重复创建。 + // 接住多次点击/重复转发造成的重复添加。 + if settings.check_duplicate { + let dup_id = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks + .values() + .find(|t| { + t.protocol == TaskProtocol::Http + && t.url == url + && matches!( + t.status, + TaskStatus::Queued | TaskStatus::Active | TaskStatus::Paused + ) + }) + .map(|t| t.id.clone()) + }; + if let Some(id) = dup_id { + return Ok(id); + } } let id = self.inner.storage.next_task_id(); - // 创建分段(受 continue_download 设置控制:关闭时强制单线程、不支持续传) - let resume_enabled = settings.continue_download; - let segments = match &probe { - Ok(p) if resume_enabled && p.supports_resume && p.total_size.map(|s| s > 0).unwrap_or(false) => { - split_segments(p.total_size.unwrap(), settings.max_connections) - } - _ => vec![Segment { - index: 0, - start: 0, - end: 0, - completed: 0, - }], - }; - - let total_size = probe.as_ref().ok().and_then(|p| p.total_size).unwrap_or(0); - let supports_resume = resume_enabled - && probe.as_ref().ok().map(|p| p.supports_resume).unwrap_or(false); + // 2) 占位文件名:优先用调用方指定,否则先用 URL 推导,探测完成后用 Content-Disposition 修正 + let mut task_filename = filename.clone().unwrap_or_else(|| { + url.split('?') + .next() + .and_then(|u| u.rsplit('/').next()) + .filter(|n| !n.is_empty()) + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp())) + }); + if auto_rename { + task_filename = self.generate_unique_filename(&task_dir, &task_filename); + } + // 3) 立即插入占位任务:Queued(未探测完成前 schedule 会跳过),未知大小单段 let task = DownloadTask { id: id.clone(), url: url.clone(), @@ -493,47 +501,128 @@ impl DownloadEngine { bt_files: Vec::new(), bt_metadata_ready: false, status: TaskStatus::Queued, - total_size, + total_size: 0, completed_size: 0, speed: 0, - supports_resume, - segments: segments.clone(), + supports_resume: false, + segments: vec![Segment { + index: 0, + start: 0, + end: 0, + completed: 0, + }], error: None, created_at: chrono::Utc::now().timestamp_millis(), - headers, + headers: headers.clone(), }; - - // 如果探测失败,标记为 Error - let task = match probe { - Ok(_) => task, - Err(e) => DownloadTask { - status: TaskStatus::Error, - error: Some(e), - ..task - }, - }; - { let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); tasks.insert(id.clone(), task); } + // 标记该任务元数据探测中,避免被并发调度提前启动 + self.inner + .http_pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id.clone()); self.persist_now(); + self.emit_task_added(&id); - // 如果探测成功,尝试调度 - let should_schedule = { - let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); - tasks.get(&id).map(|t| t.status == TaskStatus::Queued).unwrap_or(false) - }; - if should_schedule { - self.schedule(); - } + // 4) 后台探测/分段/调度:不阻塞本函数,点击添加后立即返回 + let engine = self.clone(); + let rid = id.clone(); + let raw_url = url.clone(); + let raw_headers = headers.clone(); + let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst); + tauri::async_runtime::spawn(async move { + engine.ensure_mihomo_proxy().await; + let probe = engine.inner.http.probe(&raw_url, &raw_headers, use_proxy).await; - // 通知前端有新任务加入(扩展通过 HTTP API 添加时,前端需要刷新) - let _ = self.inner.app_handle.emit(crate::constants::events::DOWNLOAD_ADDED, serde_json::json!({ "id": id })); + // 探测结束,解除 pending 门控 + engine + .inner + .http_pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&rid); + + let mut should_schedule = false; + // 先在锁内应用非文件名字段,并取出目录/探测文件名(释放锁后再做唯一化命名, + // 因为 generate_unique_filename 内部会再锁 tasks,持锁调用会死锁) + let rename_from: Option<(String, Option)> = { + let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + match tasks.get_mut(&rid) { + Some(t) => match &probe { + Ok(p) => { + t.total_size = p.total_size.unwrap_or(0); + let resume = settings.continue_download + && p.supports_resume + && p.total_size.map(|s| s > 0).unwrap_or(false); + t.supports_resume = resume; + t.segments = if resume { + split_segments(p.total_size.unwrap(), settings.max_connections) + } else { + vec![Segment { + index: 0, + start: 0, + end: p.total_size.map(|s| s.saturating_sub(1)).unwrap_or(0), + completed: 0, + }] + }; + if t.status == TaskStatus::Queued { + should_schedule = true; + } + // 调用方未指定文件名时,才后续用 Content-Disposition 修正 + if filename.is_none() { + Some((t.dir.clone(), p.filename.clone())) + } else { + None + } + } + Err(e) => { + if t.status == TaskStatus::Queued { + t.status = TaskStatus::Error; + t.error = Some(e.clone()); + } + None + } + }, + None => None, + } + }; + + // 释放 tasks 锁后,若探测返回了真实文件名,重新做唯一化并写回 + if let Some((dir_owned, Some(real_name))) = rename_from { + let final_name = if auto_rename { + engine.generate_unique_filename(&dir_owned, &real_name) + } else { + real_name + }; + let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(&rid) { + t.filename = final_name; + } + } + + engine.persist_now(); + // 探测完成:刷新大小/文件名/分段/错误态到前端 + engine.emit_task_added(&rid); + if should_schedule { + engine.schedule(); + } + }); Ok(id) } + /// 通知前端任务已新增/元数据已更新(用于刷新任务列表) + fn emit_task_added(&self, id: &str) { + let _ = self.inner.app_handle.emit( + crate::constants::events::DOWNLOAD_ADDED, + serde_json::json!({ "id": id }), + ); + } + /// 添加 BitTorrent 任务(磁力链 / 本地 .torrent)— 全异步添加。 /// 立即创建占位任务并**立即返回** id(不阻塞);加入会话与元数据解析全在后台进行。 /// 注意:librqbit 的 add_torrent 对磁力会在元数据就绪前阻塞返回,因此绝不能在此 await。 @@ -1157,8 +1246,9 @@ impl DownloadEngine { .use_proxy .store(settings.use_proxy, Ordering::SeqCst); - // 同步 BT 专属设置(上传限速 / 监听端口 / 代理;监听端口、代理首次创建会话时生效) - let bt_socks = bt_proxy_addr(&self.inner.app_handle, settings.bt_use_proxy); + // 同步 BT 专属设置(上传限速 / 监听端口 / 代理;监听端口、代理首次创建会话时生效)。 + // BT 是否走代理跟随下载设置 use_proxy(bt_use_proxy 不再独立控制) + let bt_socks = bt_proxy_addr(&self.inner.app_handle, settings.use_proxy); self.inner .torrent .set_settings(settings.bt_upload_limit_kb, settings.bt_listen_port, bt_socks); @@ -1175,6 +1265,68 @@ impl DownloadEngine { self.inner.started.load(Ordering::SeqCst) } + /// 读取代理模块(mihomo)配置:mixed_port 与 external_controller(settings.json)。 + /// 无配置/端口非法返回 None。 + fn mihomo_proxy_config(&self) -> Option<(u16, String)> { + let dir = self.inner.app_handle.path().app_data_dir().ok()?; + let p = dir.join("proxy").join("settings.json"); + let s = std::fs::read_to_string(p).ok()?; + let v: serde_json::Value = serde_json::from_str(&s).ok()?; + let port = v.get("mixedPort").and_then(|x| x.as_u64())?; + if port == 0 || port > 65535 { + return None; + } + let controller = v + .get("externalController") + .and_then(|x| x.as_str()) + .unwrap_or("127.0.0.1:9090") + .to_string(); + Some((port as u16, controller)) + } + + /// 解析当前 mihomo 显式代理地址(带 TTL 缓存): + /// 代理开关开启 & mihomo 控制端在线 → `http://127.0.0.1:{mixed_port}`; + /// 否则 → None(直连)。不再依赖系统代理。 + async fn mihomo_proxy(&self) -> Option { + // 先查缓存(短锁,不跨 await 持有) + { + let cache = self + .inner + .mihomo_proxy_cache + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some((at, addr)) = cache.as_ref() { + if at.elapsed() < Duration::from_secs(5) { + return addr.clone(); + } + } + } + + // 重新解析:读配置端口 + 探测控制端确认 mihomo 运行 + let addr = match self.mihomo_proxy_config() { + Some((port, controller)) => { + if self.inner.http.probe_controller(&controller).await { + Some(format!("http://127.0.0.1:{}", port)) + } else { + None + } + } + None => None, + }; + if let Ok(mut cache) = self.inner.mihomo_proxy_cache.lock() { + *cache = Some((Instant::now(), addr.clone())); + } + addr + } + + /// 下载/探测前,把解析出的 mihomo 代理地址应用到 HTTP 下载器。 + /// 代理开关关闭时强制直连。 + async fn ensure_mihomo_proxy(&self) { + let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst); + let proxy = if use_proxy { self.mihomo_proxy().await } else { None }; + self.inner.http.configure_mihomo_proxy(proxy); + } + /// 退出时清理:停止所有下载、保存状态 pub fn cleanup_on_exit(&self) { // 取消所有活跃下载(HTTP 设 cancel 标志;BT 中止等待 future) @@ -1219,13 +1371,20 @@ impl DownloadEngine { let (active_count, queued_ids) = { let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let pending = self.inner.http_pending.lock().unwrap_or_else(|e| e.into_inner()); let active = tasks.values().filter(|t| t.status == TaskStatus::Active).count(); let mut queued: Vec<_> = tasks .values() // 跳过元数据未就绪的 BT 任务(异步添加后台解析中,等待用户勾选文件) + // 及 HTTP 占位任务(后台探测未完成,未得到大小/分段前不得提前启动) .filter(|t| { - t.status == TaskStatus::Queued - && (t.protocol != TaskProtocol::BitTorrent || t.bt_metadata_ready) + if t.status != TaskStatus::Queued { + return false; + } + if t.protocol == TaskProtocol::BitTorrent { + return t.bt_metadata_ready; + } + !pending.contains(&t.id) }) .collect(); queued.sort_by_key(|t| t.created_at); @@ -1473,6 +1632,8 @@ impl DownloadEngine { let join = tauri::async_runtime::spawn(async move { let my_gen = gen; + // 下载前应用当前 mihomo 代理(engine 与 http 共享同一 mihomo 客户端状态) + engine.ensure_mihomo_proxy().await; let result = http .download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter, use_proxy) .await; diff --git a/src-tauri/src/download_engine/http_dl.rs b/src-tauri/src/download_engine/http_dl.rs index c86e717..a993a7a 100644 --- a/src-tauri/src/download_engine/http_dl.rs +++ b/src-tauri/src/download_engine/http_dl.rs @@ -1,11 +1,12 @@ use futures_util::StreamExt; +use reqwest::Proxy; use reqwest::Client; use std::collections::HashMap; use std::fs::OpenOptions; use std::io::SeekFrom; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use tokio::task::JoinSet; @@ -21,34 +22,76 @@ const READ_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3 /// HTTP/HTTPS 下载器 #[derive(Clone)] pub struct HttpDownloader { - /// 默认客户端:尊重系统代理(reqwest 默认行为,mihomo 开启系统代理时经其转发) - system_client: Client, - /// 直连客户端:强制禁用系统代理(no_proxy) + /// 直连客户端:强制禁用代理(no_proxy),用于降级重试与控制端探测 direct_client: Client, + /// mihomo 显式代理客户端缓存(与代理地址一一对应):走 http://127.0.0.1:{mixed_port}。 + /// 地址未配置(mihomo 未运行)时为 no_proxy 直连。Arc 共享同一状态,任意 clone 统一生效。 + mihomo: Arc, Client)>>, } impl HttpDownloader { pub fn new() -> Self { - let system_client = Client::builder() - .build() - .unwrap_or_else(|_| Client::new()); let direct_client = Client::builder() - // 强制直连:即使系统代理已开启,下载也不经过代理 + // 强制直连:即使系统代理已开启,下载也不经过系统代理 .no_proxy() .build() .unwrap_or_else(|_| Client::new()); Self { - system_client, - direct_client, + direct_client: direct_client.clone(), + mihomo: Arc::new(Mutex::new((None, direct_client))), } } - /// 根据 use_proxy 选择客户端 - fn client(&self, use_proxy: bool) -> &Client { + /// 配置 mihomo 显式代理客户端。 + /// - `Some("http://127.0.0.1:{port}")`:走本地 mihomo 的 mixed 端口; + /// - `None`:回退直连(不走系统代理)。 + /// 地址未变化时复用缓存,不重复重建,避免丢失连接复用。 + pub fn configure_mihomo_proxy(&self, proxy_url: Option) { + let mut guard = self.mihomo.lock().unwrap_or_else(|e| e.into_inner()); + if guard.0 == proxy_url { + return; + } + let client = match &proxy_url { + Some(url) => { + let mut cb = Client::builder(); + if let Ok(p) = Proxy::all(url.clone()) { + cb = cb.proxy(p); + } + cb.build().unwrap_or_else(|_| Client::new()) + } + None => Client::builder() + .no_proxy() + .build() + .unwrap_or_else(|_| Client::new()), + }; + *guard = (proxy_url, client); + } + + /// 探测 mihomo 外部控制端是否在线(用于判断代理模块是否真正运行)。 + /// 走直连客户端,避免探测本身依赖代理。 + pub async fn probe_controller(&self, controller: &str) -> bool { + let url = format!("http://{}/version", controller); + match tokio::time::timeout( + std::time::Duration::from_secs(3), + self.direct_client.get(&url).send(), + ) + .await + { + Ok(Ok(r)) => r.status().is_success(), + _ => false, + } + } + + /// 根据 use_proxy 选择客户端(克隆句柄,Client 内部共享连接池) + fn client(&self, use_proxy: bool) -> Client { if use_proxy { - &self.system_client + self.mihomo + .lock() + .unwrap_or_else(|e| e.into_inner()) + .1 + .clone() } else { - &self.direct_client + self.direct_client.clone() } } @@ -62,11 +105,11 @@ impl HttpDownloader { use_proxy: bool, ) -> Result { let first = self.client(use_proxy); - match self.probe_with_client(first, url, headers).await { + match self.probe_with_client(&first, url, headers).await { Ok(r) => return Ok(r), Err(e) if use_proxy => { let direct = self.client(false); - self.probe_with_client(direct, url, headers) + self.probe_with_client(&direct, url, headers) .await .map_err(|e2| format!("代理探测失败({}),直连重试也失败({})", e, e2)) } @@ -190,12 +233,12 @@ impl HttpDownloader { // 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退, // 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。 let first = self.client(use_proxy); - match self.download_with_client(first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await { + match self.download_with_client(&first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await { Ok(()) => return Ok(()), Err(e) if use_proxy && e != "已取消" => { // 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端) let direct = self.client(false); - self.download_with_client(direct, url, headers, segments, file_path, cancel, progress, &limiter) + self.download_with_client(&direct, url, headers, segments, file_path, cancel, progress, &limiter) .await .map_err(|e2| format!("代理下载失败({}),直连重试也失败({})", e, e2)) } diff --git a/src-tauri/src/download_engine/server.rs b/src-tauri/src/download_engine/server.rs index 00c77f5..e54a468 100644 --- a/src-tauri/src/download_engine/server.rs +++ b/src-tauri/src/download_engine/server.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use axum::{ extract::{Path, State}, @@ -17,6 +19,17 @@ use super::task::{DownloadTask, TaskStatus}; /// 扩展 HTTP API 服务器 pub struct ExtensionServer; +/// 最近处理过的下载 URL(短窗口去重):记录对应任务 id 与创建时刻, +/// 用于拦截"探测期间并发 POST"与"任务已结束但扩展重试"造成的重复创建 +#[derive(Clone)] +struct RecentEntry { + id: String, + at: Instant, +} + +/// 同 URL 去重窗口:窗口内重复 POST 复用既有任务 id +const DEDUP_WINDOW: Duration = Duration::from_secs(30); + #[derive(Serialize)] struct HealthResponse { ok: bool, @@ -57,6 +70,7 @@ impl ExtensionServer { engine, secret, app_handle, + recent: Arc::new(Mutex::new(HashMap::new())), }); let listener = match tokio::net::TcpListener::bind(&addr).await { @@ -80,6 +94,8 @@ struct AppState { engine: DownloadEngine, secret: String, app_handle: AppHandle, + /// 最近创建的 URL→任务 id(短窗口去重) + recent: Arc>>, } /// 鉴权检查:如果配置了 secret,校验 Bearer token @@ -114,16 +130,40 @@ async fn create_download( return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() }))); } - // 去重:同 URL 已有非终态任务(活跃/排队/暂停)时直接返回既有任务, + // 1. 检查短窗口缓存:同 URL 30s 内已有创建记录,直接返回(拦截浏览器/扩展重试和并发 POST) + { + let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(entry) = recent.get(&req.url) { + if entry.at.elapsed() < DEDUP_WINDOW { + // 30 秒内重复请求 → 返回已创建任务 id,不重复新建 + return Ok(Json(CreateDownloadResponse { id: entry.id.clone() })); + } else { + // 窗口过期 → 删除旧记录继续检查引擎层去重 + recent.remove(&req.url); + } + } + } + + // 2. 引擎层去重:同 URL 已有非终态任务(活跃/排队/暂停)时直接返回既有任务, // 避免浏览器重复转发同一下载造成重复下载 if let Some(existing) = state.engine.get_tasks().into_iter().find(|t| { matches!(t.status, TaskStatus::Active | TaskStatus::Queued | TaskStatus::Paused) && t.url == req.url }) { + // 添加到短窗口缓存以便拦截重试 + let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner()); + recent.insert(req.url.clone(), RecentEntry { id: existing.id.clone(), at: Instant::now() }); return Ok(Json(CreateDownloadResponse { id: existing.id })); } + // add_task 会 move 掉 req 的字段,先取出 url 供去重缓存使用 + let task_url = req.url.clone(); match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await { Ok(id) => { + // 记录到短窗口缓存:拦截后续同一 URL 的重复转发/重试 + let url = task_url.clone(); + let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner()); + recent.insert(url, RecentEntry { id: id.clone(), at: Instant::now() }); + drop(recent); // 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端, // 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面) let _ = state.app_handle.emit( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8c2c3a..29acdd9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,8 +37,9 @@ use mihomo_manager::{ use monitor_kernel::{ monitor_elevate_self, monitor_get_auto_start, monitor_get_elevate_on_launch, monitor_get_hardware_config, monitor_get_snapshot, monitor_get_status, monitor_kernel_info, - monitor_set_auto_start, monitor_set_elevate_on_launch, monitor_set_hardware_config, - monitor_start, monitor_start_elevated, monitor_status, monitor_stop, MonitorKernel, + monitor_repair_pawnio, monitor_set_auto_start, monitor_set_elevate_on_launch, + monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status, monitor_stop, + MonitorKernel, }; use network_monitor::network_status; use osd_window::{ @@ -52,15 +53,16 @@ use process_manager::{ }; use screenshot::commands::{ screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen, - screenshot_compose_copy, screenshot_copy_image, screenshot_crop_copy_stored, - screenshot_crop_stored, screenshot_cursor_pos, screenshot_delete_cache, - screenshot_disable_transitions, screenshot_enum_windows, screenshot_fullscreen_png, - screenshot_get_editor_image, screenshot_get_fullscreen_bmp, screenshot_load_cache, + screenshot_compose_copy, screenshot_compose_png, screenshot_copy_image, + screenshot_crop_copy_stored, screenshot_crop_stored, screenshot_cursor_pos, + screenshot_delete_cache, screenshot_disable_transitions, screenshot_enum_windows, + screenshot_fullscreen_png, screenshot_get_fullscreen_bmp, screenshot_load_cache, screenshot_load_cache_raw, screenshot_pick_list, screenshot_register_pin_shortcut, screenshot_register_shortcut, - screenshot_save_cache, screenshot_save_png, screenshot_set_editor_image, - screenshot_show_overlay, screenshot_unregister_pin_shortcut, - screenshot_unregister_shortcut, + screenshot_save_cache, screenshot_save_png, screenshot_scroll_capture, + screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start, + screenshot_show_overlay, screenshot_take_editor_image_raw, + screenshot_unregister_pin_shortcut, screenshot_unregister_shortcut, }; use clipboard::{ ClipboardManager, @@ -147,14 +149,16 @@ fn export_bindings() { downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task, downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings, downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files, - // screenshot(22,豁免 3:get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request) + // screenshot(22,豁免 3:get_fullscreen_bmp / take_editor_image_raw 返回 ipc::Response、 + // compose_copy / compose_png 接收 ipc::Request) screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut, screenshot_unregister_shortcut, screenshot_register_pin_shortcut, screenshot_unregister_pin_shortcut, screenshot_capture_fullscreen, screenshot_fullscreen_png, screenshot_clear_fullscreen, screenshot_crop_stored, screenshot_crop_copy_stored, screenshot_pick_list, screenshot_cursor_pos, - screenshot_enum_windows, screenshot_capture_window, screenshot_set_editor_image, - screenshot_get_editor_image, screenshot_copy_image, screenshot_save_png, + screenshot_enum_windows, screenshot_capture_window, screenshot_scroll_capture, + screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start, + screenshot_copy_image, screenshot_save_png, screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache, ]) .export(Typescript::default(), "../src/lib/bindings.ts") @@ -231,6 +235,7 @@ pub fn run() { monitor_elevate_self, monitor_stop, monitor_get_status, + monitor_repair_pawnio, monitor_get_snapshot, monitor_get_elevate_on_launch, monitor_set_elevate_on_launch, @@ -335,8 +340,11 @@ pub fn run() { screenshot_cursor_pos, screenshot_enum_windows, screenshot_capture_window, - screenshot_set_editor_image, - screenshot_get_editor_image, + screenshot_scroll_capture, + screenshot_scroll_cancel, + screenshot_scroll_finish, + screenshot_scroll_start, + screenshot_take_editor_image_raw, screenshot_copy_image, screenshot_save_png, screenshot_save_cache, @@ -348,7 +356,8 @@ pub fn run() { screenshot_register_pin_shortcut, screenshot_unregister_pin_shortcut, screenshot_disable_transitions, - screenshot_compose_copy + screenshot_compose_copy, + screenshot_compose_png ]) .setup(setup::init) .on_window_event(|window, event| { diff --git a/src-tauri/src/monitor_kernel.rs b/src-tauri/src/monitor_kernel.rs index e3f70da..947c1fa 100644 --- a/src-tauri/src/monitor_kernel.rs +++ b/src-tauri/src/monitor_kernel.rs @@ -1018,6 +1018,52 @@ pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Resul state.get_status().await } +/// 检测并修复 PawnIO 驱动:确保安装器随内核部署 → 静默安装 → 重启监控内核 +/// (LHM 打开一次后不会重新发现驱动,装完必须重启才能恢复 CPU 温度/功耗读取)。 +/// 返回 { installed, needReboot };installed=false 说明未提权或安装失败,交由内核启动自装。 +#[tauri::command] +pub async fn monitor_repair_pawnio( + state: tauri::State<'_, MonitorKernel>, + pm: tauri::State<'_, ProcessManager>, + app: AppHandle, +) -> Result { + // 1. 确保 PawnIO_setup.exe 随内核部署 + state.prepare_kernel(&app)?; + let setup = state.cores_dir().join("PawnIO_setup.exe"); + if !setup.exists() { + return Err("未找到 PawnIO_setup.exe 安装器(binaries 资源未随包部署),请重新部署监控内核".into()); + } + + // 2. 静默安装驱动(继承当前进程权限;Thing 已提权则直接成功) + let mut cmd = std::process::Command::new(&setup); + cmd.args(["-install", "-silent"]); + crate::process_manager::setup_creation_flags(&mut cmd); + let (installed, need_reboot) = match cmd.status() { + Ok(status) => { + let code = status.code().unwrap_or(-1); + match code { + 3010 => (true, true), // ERROR_SUCCESS_REBOOT_REQUIRED + 0 => (true, false), + _ => (false, false), + } + } + Err(e) => return Err(format!("运行 PawnIO 安装器失败: {}", e)), + }; + + // 3. 重启监控内核,使 LHM 以 PawnIO 重新打开传感器 + state.stop_subscription(&app).await; + if state.is_elevated() && !is_thing_elevated() { + state.shutdown_kernel().await?; + state.elevated.store(false, Ordering::SeqCst); + state.start_elevated(&app).await?; + } else { + pm.stop(PROCESS_ID)?; + state.start_with_subscription(&app).await?; + } + + Ok(serde_json::json!({ "installed": installed, "needReboot": need_reboot })) +} + #[tauri::command] pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result { state.get_snapshot().await diff --git a/src-tauri/src/screenshot/capture.rs b/src-tauri/src/screenshot/capture.rs index c38223d..e65c837 100644 --- a/src-tauri/src/screenshot/capture.rs +++ b/src-tauri/src/screenshot/capture.rs @@ -111,7 +111,7 @@ fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result, String> /// /// 使用 Fast 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比, /// 大幅降低"点击完成 → 关闭窗口"的编码延迟。 -fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> { +pub(crate) fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> { use image::codecs::png::{CompressionType, FilterType, PngEncoder}; use image::ImageEncoder; if width <= 0 || height <= 0 { @@ -138,7 +138,7 @@ fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> } /// 从 HBITMAP 提取 32bpp BGRA top-down 像素 -unsafe fn extract_pixels( +pub(crate) unsafe fn extract_pixels( hdc_mem: isize, hbm: isize, width: i32, @@ -182,7 +182,16 @@ unsafe fn extract_pixels( } /// 捕获整个虚拟屏(所有显示器拼接为一张图) +/// +/// 优先走 BitBlt(SDR 全屏一次捕获、低延迟);检测到任一显示器为 HDR 时改用 WGC +/// (RGBA16F + HDR→sRGB 色调映射,避免 BitBlt 把 scRGB 线性像素当 sRGB 直出导致过曝)。 +/// WGC 失败时回退到 BitBlt。 pub fn capture_virtual_screen() -> Result { + if super::wgc_capture::is_hdr_enabled() { + if let Ok(img) = super::wgc_capture::capture_virtual_screen_wgc() { + return Ok(img); + } + } unsafe { let x = GetSystemMetrics(SM_XVIRTUALSCREEN); let y = GetSystemMetrics(SM_YVIRTUALSCREEN); @@ -232,6 +241,19 @@ pub fn capture_virtual_screen() -> Result { /// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口) pub fn capture_window(hwnd: isize) -> Result { + let img = capture_window_bgra(hwnd)?; + let png = bgra_to_png(&img.bgra, img.width, img.height)?; + Ok(CapturedImage { + width: img.width, + height: img.height, + png, + bgra: img.bgra, + }) +} + +/// 捕获指定窗口的原始 BGRA(不做 PNG 编码)。 +/// 滚动截图每帧只需像素数据做匹配拼接,跳过编码可显著降低单帧耗时。 +pub fn capture_window_bgra(hwnd: isize) -> Result { unsafe { let mut rect: RECT = std::mem::zeroed(); if GetWindowRect(hwnd, &mut rect) == 0 { @@ -259,13 +281,11 @@ pub fn capture_window(hwnd: isize) -> Result { let result = if ok == 0 { Err("PrintWindow 失败(可能窗口无响应或权限不足)".into()) } else { - extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| { - bgra_to_png(&bgra, w, h).map(|png| CapturedImage { - width: w, - height: h, - png, - bgra, - }) + extract_pixels(hdc_mem, hbm, w, h).map(|bgra| CapturedImage { + width: w, + height: h, + png: Vec::new(), + bgra, }) }; @@ -523,7 +543,7 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec { dib } -fn crop_bgra( +pub(crate) fn crop_bgra( src: &[u8], src_width: usize, x: i32, @@ -667,6 +687,20 @@ pub fn compose_copy_rgba(rgba: &[u8], width: i32, height: i32) -> Result Result { + let expected = (width as usize) * (height as usize) * 4; + if rgba.len() < expected { + return Err(format!("像素数据不足: {} < {}", rgba.len(), expected)); + } + rgba_raw_to_png_checked(rgba, width, height) +} + +/// raw RGBA → PNG base64(带长度校验的封装,供 compose_copy/compose_png 共用) +fn rgba_raw_to_png_checked(rgba: &[u8], width: i32, height: i32) -> Result { let png = rgba_raw_to_png(rgba, width, height)?; Ok(CaptureData { png_base64: base64_encode(&png), diff --git a/src-tauri/src/screenshot/commands.rs b/src-tauri/src/screenshot/commands.rs index 3a250fb..8d8f1a5 100644 --- a/src-tauri/src/screenshot/commands.rs +++ b/src-tauri/src/screenshot/commands.rs @@ -10,13 +10,14 @@ //! - screenshot_show_overlay:一次 IPC 完成覆盖层 show + focus(关键路径减少往返) //! - screenshot_enum_windows:枚举可见顶层窗口 //! - screenshot_capture_window:按 hwnd 捕获指定窗口 -//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递 +//! - screenshot_take_editor_image_raw:取出编辑器图片(raw IPC,滚动截图会话直接写入) +//! - screenshot_compose_png / screenshot_compose_copy:raw RGBA → PNG(仅编码 / 剪贴板+编码) //! - screenshot_copy_image:写入剪贴板(CF_DIB) //! - screenshot_save_png:写入文件 //! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画) use super::{CaptureData, CaptureStart, WindowInfo}; -use tauri::{Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager}; /// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 #[tauri::command] @@ -297,19 +298,90 @@ pub async fn screenshot_capture_window(hwnd: isize) -> Result Result<(), String> { - super::set_editor_image(png_base64); - Ok(()) +pub async fn screenshot_scroll_capture( + hwnd: isize, + region: Option, +) -> Result { + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(move || { + super::scroll_capture::scroll_capture(hwnd, region) + }) + .await + .map_err(|e| format!("滚动截图任务失败: {}", e))? + } + #[cfg(not(windows))] + { + let _ = hwnd; + let _ = region; + Err("截图仅支持 Windows".into()) + } } -/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除) +/// 启动滚动截图会话(后台线程持续捕捉拼接,实时推进度事件)。 +/// `auto = true` 为自动滚动(线程主动下滚拼到底部);`false` 为手动(等用户滚动窗口)。 #[tauri::command] #[specta::specta] -pub async fn screenshot_get_editor_image() -> Result, String> { - Ok(super::take_editor_image()) +pub fn screenshot_scroll_start( + app: AppHandle, + hwnd: isize, + region: Option, + auto: bool, +) -> Result<(), String> { + #[cfg(windows)] + { + super::scroll_session::start(app, hwnd, region, auto) + } + #[cfg(not(windows))] + { + let _ = (app, hwnd, region, auto); + Err("截图仅支持 Windows".into()) + } +} + +/// 结束滚动截图会话并导出结果。 +#[tauri::command] +#[specta::specta] +pub fn screenshot_scroll_finish() -> Result<(), String> { + #[cfg(windows)] + { + super::scroll_session::finish() + } + #[cfg(not(windows))] + { + Err("截图仅支持 Windows".into()) + } +} + +/// 取消滚动截图会话(不导出)。 +#[tauri::command] +#[specta::specta] +pub fn screenshot_scroll_cancel() -> Result<(), String> { + #[cfg(windows)] + { + super::scroll_session::cancel_now() + } + #[cfg(not(windows))] + { + Err("截图仅支持 Windows".into()) + } +} + +/// 取出编辑器图片(原始 PNG 字节,raw IPC → 前端 ArrayBuffer → Blob URL,取出即清除) +/// +/// 长图(滚动截图)可达数十 MB:raw IPC 相比 base64 JSON 事件传输省 ~33% 体积, +/// 且避免 JSON 序列化/多次广播。注:返回 ipc::Response,specta 无法生成,豁免标注。 +#[tauri::command] +pub async fn screenshot_take_editor_image_raw() -> Result { + match super::take_editor_image_raw() { + Some(bytes) => Ok(tauri::ipc::Response::new(bytes)), + None => Err("无待编辑的截图".into()), + } } /// 将 PNG base64 写入系统剪贴板(转 CF_DIB) @@ -339,6 +411,39 @@ pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> { #[tauri::command] pub async fn screenshot_compose_copy( request: tauri::ipc::Request<'_>, +) -> Result { + #[cfg(windows)] + { + let body = match request.body() { + tauri::ipc::InvokeBody::Raw(data) => data.clone(), + _ => return Err("需要 raw body(ArrayBuffer)".into()), + }; + if body.len() < 8 { + return Err("数据不足:缺少尺寸头".into()); + } + let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]); + let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]); + let rgba = body[8..].to_vec(); + tauri::async_runtime::spawn_blocking(move || { + super::capture::compose_copy_rgba(&rgba, width, height) + }) + .await + .map_err(|e| format!("合成复制任务失败: {}", e))? + } + #[cfg(not(windows))] + { + let _ = request; + Err("截图仅支持 Windows".into()) + } +} + +/// 有标注导出(仅编码):接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 PNG 编码。 +/// 与 screenshot_compose_copy 的区别:不写剪贴板(编辑器「保存到文件」用)。 +/// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。 +/// 注:参数为 tauri::ipc::Request(原始 body),specta 无法生成,豁免标注。 +#[tauri::command] +pub async fn screenshot_compose_png( + request: tauri::ipc::Request<'_>, ) -> Result { #[cfg(windows)] { @@ -353,10 +458,10 @@ pub async fn screenshot_compose_copy( let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]); let rgba = body[8..].to_vec(); tauri::async_runtime::spawn_blocking(move || { - super::capture::compose_copy_rgba(&rgba, width, height) + super::capture::compose_png_rgba(&rgba, width, height) }) .await - .map_err(|e| format!("合成复制任务失败: {}", e))? + .map_err(|e| format!("合成编码任务失败: {}", e))? } #[cfg(not(windows))] { diff --git a/src-tauri/src/screenshot/mod.rs b/src-tauri/src/screenshot/mod.rs index 3cbb41b..b66bcad 100644 --- a/src-tauri/src/screenshot/mod.rs +++ b/src-tauri/src/screenshot/mod.rs @@ -9,6 +9,12 @@ use specta::Type; #[cfg(windows)] pub mod capture; +#[cfg(windows)] +pub mod wgc_capture; +#[cfg(windows)] +pub mod scroll_capture; +#[cfg(windows)] +pub mod scroll_session; pub mod commands; /// 前端可见的捕获数据 @@ -48,17 +54,28 @@ pub struct ScreenRect { pub height: i32, } -/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出) -static EDITOR_IMAGE: Mutex> = Mutex::new(None); +/// 滚动截图区域(屏幕物理像素坐标,通常为覆盖层框选区平移到屏幕) +#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Type)] +#[serde(rename_all = "camelCase")] +pub struct ScrollRegion { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} -/// 存储编辑器图片(base64 PNG) -pub fn set_editor_image(png_base64: String) { - if let Ok(mut g) = EDITOR_IMAGE.lock() { - *g = Some(png_base64); +/// 编辑器图片静态存储:原始 PNG 字节(滚动截图会话完成后直接写入, +/// 编辑器窗口通过 raw IPC 取出 → Blob URL 显示,全程不经 base64/JSON 事件传输) +static EDITOR_IMAGE_RAW: Mutex>> = Mutex::new(None); + +/// 存储编辑器图片(原始 PNG 字节) +pub fn set_editor_image_raw(png: Vec) { + if let Ok(mut g) = EDITOR_IMAGE_RAW.lock() { + *g = Some(png); } } -/// 取出并清除编辑器图片 -pub fn take_editor_image() -> Option { - EDITOR_IMAGE.lock().ok()?.take() +/// 取出并清除编辑器图片(原始 PNG 字节) +pub fn take_editor_image_raw() -> Option> { + EDITOR_IMAGE_RAW.lock().ok()?.take() } diff --git a/src-tauri/src/screenshot/scroll_capture.rs b/src-tauri/src/screenshot/scroll_capture.rs new file mode 100644 index 0000000..ff75277 --- /dev/null +++ b/src-tauri/src/screenshot/scroll_capture.rs @@ -0,0 +1,469 @@ +//! 滚动截图(垂直长图拼接)底层设施(仅 Windows) +//! +//! ## 原理 +//! 目标窗口的**客户区**内容往往是可分页垂直滚动的。算法: +//! 1. 用 PrintWindow(PW_RENDERFULLCONTENT)抓客户区为 BGRA(不做 PNG 编码); +//! 2. 向窗口发送 `WM_MOUSEWHEEL` 使其向下滚动; +//! 3. 再抓一帧,用**框选带内多列采样行信号**做一维模板匹配,求出两帧间的垂直 +//! 滚动像素偏移 `d`:`cur[y] ≈ prev[y+d]`,故把 `cur` 底部新出现的 `d` 行拼到画布末尾; +//! 4. 重复直到内容不再变化(滚到底部),把各帧拼成一张超高纵轴图像。 +//! +//! 固定表头/粘性头部由拼接语义天然处理——只追加 `cur` 底部的 `d` 行, +//! 表头保留在第一帧中,不会重复。 +//! +//! ## 滚轮投递 +//! `WM_MOUSEWHEEL` 优先发给**框选带中心处的最深子窗口**(如 Chromium 的 +//! RenderWidgetHostHWND):很多程序的顶层窗口过程不转发滚轮消息, +//! 直接发顶层会导致"完全不滚动"。坐标一律用屏幕物理坐标(lParam 语义)。 + +use std::thread::sleep; +use std::time::Duration; + +use windows_sys::Win32::Foundation::{HWND, POINT, RECT}; +use windows_sys::Win32::Graphics::Gdi::{ClientToScreen, ScreenToClient}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + ChildWindowFromPointEx, GetClientRect, GetWindowRect, PostMessageW, SendMessageTimeoutW, + WM_MOUSEWHEEL, WHEEL_DELTA, CWP_SKIPDISABLED, CWP_SKIPINVISIBLE, CWP_SKIPTRANSPARENT, + SMTO_ABORTIFHUNG, +}; + +use super::{CaptureData, ScrollRegion}; +use super::capture::{base64_encode, bgra_to_png, capture_window_bgra, crop_bgra}; + +/// 窗口客户区上下文:客户区尺寸 + 客户区在窗口位图内的偏移。 +pub(crate) struct BandCtx { + pub cw: i32, + pub ch: i32, + pub ox: i32, + pub oy: i32, +} + +/// 解析框选带:把可选区域(屏幕物理坐标)换算成客户区内的裁剪带。 +/// 区域未命中客户区时返回错误;`None` 表示整客户区。 +/// 返回 (ctx, band_x, band_y, band_w, band_h),band_* 为客户区内坐标。 +pub(crate) fn resolve_band( + hwnd: isize, + region: Option, +) -> Result<(BandCtx, i32, i32, i32, i32), String> { + let (cw, ch, ox, oy) = client_info(hwnd) + .ok_or_else(|| "无法获取窗口客户区(窗口可能被最小化或已销毁)".to_string())?; + if cw <= 0 || ch <= 0 { + return Err("窗口客户区尺寸无效".into()); + } + let (wr_x, wr_y) = window_rect_origin(hwnd).ok_or_else(|| "无法获取窗口矩形".to_string())?; + let band = match region { + Some(r) => { + let wbx = r.x - wr_x; + let wby = r.y - wr_y; + let bx0 = wbx.max(ox); + let by0 = wby.max(oy); + let bx1 = (wbx + r.width).min(ox + cw); + let by1 = (wby + r.height).min(oy + ch); + if bx1 <= bx0 || by1 <= by0 { + return Err("选区未命中窗口客户区".into()); + } + (bx0 - ox, by0 - oy, bx1 - bx0, by1 - by0) + } + None => (0, 0, cw, ch), + }; + Ok(( + BandCtx { cw, ch, ox, oy }, + band.0, + band.1, + band.2, + band.3, + )) +} + +/// 客户区内坐标 → 屏幕物理坐标。 +pub(crate) fn client_pt_to_screen(hwnd: isize, cx: i32, cy: i32) -> Option<(i32, i32)> { + unsafe { + let mut pt = POINT { x: cx, y: cy }; + if ClientToScreen(hwnd as HWND, &mut pt) == 0 { + return None; + } + Some((pt.x, pt.y)) + } +} + +/// 递归下钻:找到客户区坐标 (cx, cy) 处最深的子窗口(跳过不可见/禁用/透明子窗口)。 +/// WM_MOUSEWHEEL 优先发给真正处理滚轮的子窗口——顶层窗口过程往往不转发滚轮, +/// 这是"自动滚动一开始就不动"的主因之一。 +pub(crate) fn deep_child_at_point(top: isize, mut cx: i32, mut cy: i32) -> isize { + let mut cur = top; + for _ in 0..16 { + let child = unsafe { + ChildWindowFromPointEx( + cur as HWND, + POINT { x: cx, y: cy }, + CWP_SKIPINVISIBLE | CWP_SKIPDISABLED | CWP_SKIPTRANSPARENT, + ) + }; + if child == 0 || child == cur as HWND { + break; + } + // 坐标换算到子窗口客户区 + let mut pt = POINT { x: cx, y: cy }; + unsafe { + if ClientToScreen(cur as HWND, &mut pt) == 0 { + break; + } + if ScreenToClient(child, &mut pt) == 0 { + break; + } + } + cx = pt.x; + cy = pt.y; + cur = child as isize; + } + cur +} + +/// 构造 WM_MOUSEWHEEL 的 wParam/lParam(lParam 为屏幕坐标)。 +fn wheel_params(sx: i32, sy: i32, delta: i32) -> (usize, isize) { + // wParam 高位字 = 有符号 delta,低位字 = 按键 0 + let wparam = ((delta as u16) as usize) << 16; + // lParam 低 16 位 = x(屏幕),高 16 位 = y(屏幕) + let lparam = ((((sy as u32) & 0xFFFF) << 16) | ((sx as u32) & 0xFFFF)) as isize; + (wparam, lparam) +} + +/// 发送滚轮消息(SendMessageTimeout:目标线程短暂忙/挂起时不至于卡死调用线程)。 +/// delta > 0 向上滚,delta < 0 向下滚。 +pub(crate) fn send_wheel(hwnd: isize, sx: i32, sy: i32, delta: i32) { + let (wparam, lparam) = wheel_params(sx, sy, delta); + let mut result = 0usize; + unsafe { + let _ = SendMessageTimeoutW( + hwnd as HWND, + WM_MOUSEWHEEL, + wparam, + lparam, + SMTO_ABORTIFHUNG, + 80, + &mut result, + ); + } +} + +/// 异步投递滚轮消息(部分程序只处理经消息泵排队的事件)。 +pub(crate) fn post_wheel(hwnd: isize, sx: i32, sy: i32, delta: i32) { + let (wparam, lparam) = wheel_params(sx, sy, delta); + unsafe { + let _ = PostMessageW(hwnd as HWND, WM_MOUSEWHEEL, wparam, lparam); + } +} + +// ===== 单次同步滚动截图(一次调用拼到底)===== + +/// 每次向下滚动的「格数」(WHEEL_DELTA=120/格)。 +const WHEEL_STROKE: i32 = 2; +/// 滚动后等待窗口重绘的时间。 +const SETTLE_MS: u64 = 70; +/// 保护上限:最大迭代轮数。 +const MAX_ITERS: usize = 140; +/// 保护上限:最大拼接段数。 +const MAX_STITCHES: usize = 90; +/// 保护上限:拼接后总高(像素)。 +const MAX_TOTAL_H: u32 = 30000; +/// 连续多少次滚动无位移判定为「已到底部」。 +const NO_CHANGE_STOP: u32 = 2; + +/// 执行滚动截图:把窗口当前滚动位置向下拼接到底部,返回超长 PNG(同步,一次调用)。 +/// +/// - `region` 为 `Some` 时,只在**框选区域**内捕捉(列带 + 该区域的纵向视口), +/// 输出宽度 = 选区宽度;为 `None` 时捕捉整个客户区。 +/// 偏移检测始终优先使用带内信号,保证滚动量在窄带下也能稳健匹配。 +/// - 结束时把窗口滚回起始位置,不打扰用户。 +pub fn scroll_capture(hwnd: isize, region: Option) -> Result { + let (ctx, band_x, band_y, band_w, band_h) = resolve_band(hwnd, region)?; + let (cw, ch) = (ctx.cw, ctx.ch); + let (client_ox, client_oy) = (ctx.ox, ctx.oy); + + // 滚轮目标:带中心的最深子窗口 + 屏幕坐标 + let bcx = band_x + band_w / 2; + let bcy = band_y + band_h / 2; + let wheel_hwnd = deep_child_at_point(hwnd, bcx, bcy); + let (sx, sy) = client_pt_to_screen(hwnd, bcx, bcy) + .ok_or_else(|| "无法换算屏幕坐标".to_string())?; + + // 第一帧(全客户区)→ 画布(裁剪到框选带) + let first = capture_client_bgra(hwnd, client_ox, client_oy, cw, ch)?; + let mut canvas = crop_bgra(&first, cw as usize, band_x, band_y, band_w, band_h)?; + let mut prev = first; + let mut total_h = band_h as u32; + let mut total_stitches = 0usize; + let mut no_change = 0u32; + let mut moved: u32 = 0; + let mut iters = 0usize; + + while iters < MAX_ITERS && total_stitches < MAX_STITCHES && total_h < MAX_TOTAL_H { + iters += 1; + + send_wheel(wheel_hwnd, sx, sy, -(WHEEL_DELTA as i32) * WHEEL_STROKE); + sleep(Duration::from_millis(SETTLE_MS)); + + let cur = match capture_client_bgra(hwnd, client_ox, client_oy, cw, ch) { + Ok(f) => f, + Err(_) => break, // 窗口中途被关闭/失去客户区 + }; + + match detect_vscroll_offset( + &prev, + &cur, + cw as usize, + ch as usize, + band_x as usize, + band_y as usize, + band_w as usize, + band_h as usize, + ) { + Some(0) => { + no_change += 1; + if no_change >= NO_CHANGE_STOP { + break; // 已到底部 + } + prev = cur; + } + Some(d) => { + no_change = 0; + total_stitches += 1; + moved += 1; + // 追加框选带底部新出现的 d 行(在客户区帧内的带区间 [band_y, band_y+band_h)) + let new_rows = (d as i32).min(band_h) as usize; + let new_y = band_y + band_h - new_rows as i32; + let rows = crop_bgra(&cur, cw as usize, band_x, new_y, band_w, new_rows as i32)?; + canvas.extend_from_slice(&rows); + total_h += new_rows as u32; + prev = cur; + } + None => { + // 匹配失败(内容大幅变化/动画等):不拼接、不累计 no_change,下一轮继续 + prev = cur; + } + } + } + + // 结束回滚:上滚与下滚相同数量的手势 + for _ in 0..moved { + send_wheel(wheel_hwnd, sx, sy, WHEEL_DELTA as i32 * WHEEL_STROKE); + sleep(Duration::from_millis(20)); + } + + if total_h <= band_h as u32 { + return Err("区域内容未能滚动(可能不支持鼠标滚轮或已到底部)".into()); + } + + let png = bgra_to_png(&canvas, band_w as i32, total_h as i32)?; + Ok(CaptureData { + png_base64: base64_encode(&png), + width: band_w as i32, + height: total_h as i32, + }) +} + +/// 窗口矩形左上角的屏幕坐标(用于把屏幕区域换算成窗口位图内的裁剪坐标)。 +fn window_rect_origin(hwnd: isize) -> Option<(i32, i32)> { + unsafe { + let mut wr: RECT = std::mem::zeroed(); + if GetWindowRect(hwnd as HWND, &mut wr) == 0 { + return None; + } + Some((wr.left, wr.top)) + } +} + +/// 获取窗口客户区尺寸与客户区左上角屏幕坐标。 +fn client_info(hwnd: isize) -> Option<(i32, i32, i32, i32)> { + unsafe { + let h = hwnd as HWND; + let mut cr: RECT = std::mem::zeroed(); + if GetClientRect(h, &mut cr) == 0 { + return None; + } + let (cw, ch) = (cr.right - cr.left, cr.bottom - cr.top); + let mut pt: POINT = std::mem::zeroed(); + if ClientToScreen(h, &mut pt) == 0 { + return None; + } + let mut wr: RECT = std::mem::zeroed(); + if GetWindowRect(h, &mut wr) == 0 { + return None; + } + Some((cw, ch, pt.x - wr.left, pt.y - wr.top)) + } +} + +/// 抓取窗口客户区内容(PrintWindow + 裁剪,不做 PNG 编码)。 +pub(crate) fn capture_client_bgra( + hwnd: isize, + client_ox: i32, + client_oy: i32, + cw: i32, + ch: i32, +) -> Result, String> { + let img = capture_window_bgra(hwnd)?; + // 客户区在窗口位图内的偏移(PrintWindow 从窗口左上角绘制) + crop_bgra( + &img.bgra, + img.width as usize, + client_ox, + client_oy, + cw.min(img.width), + ch.min(img.height), + ) +} + +// ===== 帧间垂直偏移检测 ===== + +/// 行信号采样列数:每行采 16 列亮度(B+G+R),保留横向细节(文字边缘、分隔线)。 +/// 相比旧的"整行平均",多列采样大幅降低重复纹理/大面积纯色区的误匹配率。 +const SIG_COLS: usize = 16; +/// 单周期可检测的最大偏移(像素)。超过即匹配失败(调用方自适应降速)。 +const MAX_OFFSET: usize = 512; + +/// 检测两帧之间的垂直滚动像素偏移 `d`,使 `cur[y] ≈ prev[y+d]`。 +/// +/// 匹配范围优先限定在**框选带**(行=带内行、列=带内列):带外内容(工具栏、 +/// 状态栏、不随滚动的区域)不参与匹配,避免污染信号。带太小/带内匹配失败时 +/// 逐级回退(全行+带列 → 全行+全列)。 +/// +/// 返回: +/// - `Some(0)`:两帧实质相同(未滚动 / 已到底部) +/// - `Some(d)` d>0:检测到向下滚动了 d 像素 +/// - `None`:无法可靠匹配(内容动画 / 位移超上限等),调用方跳过本轮 +pub(crate) fn detect_vscroll_offset( + prev: &[u8], + cur: &[u8], + w: usize, + h: usize, + band_l: usize, + band_t: usize, + band_w: usize, + band_h: usize, +) -> Option { + if w < 4 || h < 16 { + return None; + } + let band_ok = band_w >= 8 && band_h >= 24 && band_t + band_h <= h && band_l + band_w <= w; + if band_ok { + // 主匹配:行、列都限定在带内(带外内容不随滚动变化,会污染匹配信号) + let ps = row_signals(prev, w, band_t, band_h, band_l, band_w); + let cs = row_signals(cur, w, band_t, band_h, band_l, band_w); + if let Some(r) = match_signals(&ps, &cs) { + return Some(r); + } + // 回退(带内匹配失败:快速滚动位移超上限 / 带内大面积动画): + // 全客户区行只用于**找位移**,不判"无变化"——带外静止内容会把误差拉低, + // 误报 Some(0) 造成假"到底" + if band_l + band_w <= w { + let ps = row_signals(prev, w, 0, h, band_l, band_w); + let cs = row_signals(cur, w, 0, h, band_l, band_w); + if let Some(d) = match_signals(&ps, &cs).filter(|d| *d > 0) { + return Some(d); + } + } + let ps = row_signals(prev, w, 0, h, 0, w); + let cs = row_signals(cur, w, 0, h, 0, w); + return match_signals(&ps, &cs).filter(|d| *d > 0); + } + // 带太小:全客户区回退(此时允许 Some(0),否则永远判不了"到底") + if band_w >= 8 && band_l + band_w <= w { + let ps = row_signals(prev, w, 0, h, band_l, band_w); + let cs = row_signals(cur, w, 0, h, band_l, band_w); + if let Some(r) = match_signals(&ps, &cs) { + return Some(r); + } + } + let ps = row_signals(prev, w, 0, h, 0, w); + let cs = row_signals(cur, w, 0, h, 0, w); + match_signals(&ps, &cs) +} + +/// 每行采样 SIG_COLS 列的亮度(B+G+R,0..765),返回 rows × SIG_COLS 的信号矩阵。 +fn row_signals(bgra: &[u8], w: usize, y0: usize, rows: usize, l: usize, bw: usize) -> Vec { + // 均匀采样列(含两端) + let mut cols = [0usize; SIG_COLS]; + for i in 0..SIG_COLS { + cols[i] = l + (i * (bw - 1)) / (SIG_COLS - 1).max(1); + } + let mut out = Vec::with_capacity(rows * SIG_COLS); + for y in y0..y0 + rows { + let base = y * w * 4; + for &c in &cols { + let i = base + c * 4; + // bgra: B,G,R(alpha 通常为 0,不计入亮度) + out.push(bgra[i] as i32 + bgra[i + 1] as i32 + bgra[i + 2] as i32); + } + } + out +} + +/// 信号模板匹配:求 d 使 `cs[y] ≈ ps[y+d]`,取平均绝对误差最小者。 +fn match_signals(ps: &[i32], cs: &[i32]) -> Option { + let n = ps.len() / SIG_COLS; + if n < 16 { + return None; + } + let step = 2; // 隔行采样加速 + let max_d = MAX_OFFSET.min(n * 3 / 4); + if max_d < 1 { + return None; + } + + // d=0 基准误差:两帧几乎一致 → 未滚动(到底部) + let err0 = { + let (mut e, mut c) = (0i64, 0i64); + let mut y = 0; + while y < n { + for k in 0..SIG_COLS { + e += (cs[y * SIG_COLS + k] - ps[y * SIG_COLS + k]).abs() as i64; + } + c += SIG_COLS as i64; + y += step; + } + if c == 0 { + return None; + } + e as f64 / c as f64 + }; + if err0 < 4.0 { + return Some(0); + } + + // 找最小平均误差的 d + let mut best_d = 0usize; + let mut best_err = f64::MAX; + for d in 1..=max_d { + let (mut e, mut c) = (0i64, 0i64); + let mut y = 0; + while y + d < n { + for k in 0..SIG_COLS { + e += (cs[y * SIG_COLS + k] - ps[(y + d) * SIG_COLS + k]).abs() as i64; + } + c += SIG_COLS as i64; + y += step; + // 早停:明显劣于当前最优则放弃该候选 + if c >= 64 && e as f64 / c as f64 > best_err * 1.8 + 24.0 { + break; + } + } + if c >= 64 { + let ae = e as f64 / c as f64; + if ae < best_err { + best_err = ae; + best_d = d; + } + } + } + + if best_d == 0 { + return None; + } + // 阈值按 3 通道和(0..765)标定:亚像素滚动会有轻微重采样模糊,阈值不宜过紧 + if best_err < 30.0 && best_err * 2.0 < err0 { + Some(best_d) + } else { + None + } +} diff --git a/src-tauri/src/screenshot/scroll_session.rs b/src-tauri/src/screenshot/scroll_session.rs new file mode 100644 index 0000000..76a04fc --- /dev/null +++ b/src-tauri/src/screenshot/scroll_session.rs @@ -0,0 +1,647 @@ +//! 滚动截图会话(仅 Windows):后台线程捕捉拼接 + 独立滚轮线程平滑滚动。 +//! +//! 与 [super::scroll_capture::scroll_capture](同步一次调用)不同,本模块把捕获循环 +//! 放进后台线程,通过事件把**实时进度**推给前端。 +//! +//! ## 自动模式架构 +//! - **run_loop(会话线程)**:按节奏捕获客户区帧,与上一帧做带内偏移匹配,拼接新行; +//! - **ticker(滚轮线程)**:以 ~16ms 间隔发送小步长 `WM_MOUSEWHEEL`,由目标程序自身的 +//! 平滑滚动动画呈现连续滚动(替代旧版"每 70ms 一整格"的跳变式滚动); +//! - **目标策略升级**:带中心最深子窗口(小步长 Send)→ 子窗口整格 → 顶层整格 Send → +//! 顶层整格 Post。解决"一开始就不滚动"(顶层不转发滚轮 / 程序忽略非整格消息); +//! - **自适应速度**:按每周期实测位移调滚轮步长 / 捕获周期,逼近"带高 1/4"的理想重叠; +//! - **停止补帧(settle)**:用户停止后等目标窗口滚动动画静止再补拼最后一帧, +//! 保证最终图片结尾 = 用户停止时窗口实际停留的位置; +//! - **精准回滚**:先按累计像素粗估上滚,再用帧匹配对初始帧校准回到起始位置。 +//! +//! 事件(广播到所有窗口): +//! - `SCROLL_PROGRESS`:`{ width, height, auto }` 当前已拼接高度 +//! - `SCROLL_COMPLETE`:`{ width, height }` 完成导出(PNG 原始字节已存入编辑器图片槽, +//! 由前端常驻编辑器窗口通过 raw IPC 取出,事件本身不携带图片数据) +//! - `SCROLL_CANCELLED`:`{}` 取消(丢弃画布) + +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, Ordering}; +use std::sync::Mutex; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +use tauri::{AppHandle, Emitter}; +use windows_sys::Win32::UI::WindowsAndMessaging::WHEEL_DELTA; + +use crate::constants::events as evts; +use crate::logger::{log_error, log_info}; +use super::capture::{bgra_to_png, crop_bgra}; +use super::scroll_capture::{ + capture_client_bgra, client_pt_to_screen, deep_child_at_point, detect_vscroll_offset, + post_wheel, resolve_band, send_wheel, +}; +use super::ScrollRegion; + +// ===== 可调参数 ===== + +/// 滚轮线程发送间隔(毫秒)。小步长 + 高频 → 目标程序的平滑滚动动画连贯不断帧。 +const TICK_MS: u64 = 16; +/// 捕获周期基准(毫秒)。与滚轮解耦:滚动不因捕获/匹配而停顿。 +const CYCLE_MS: u64 = 130; +/// 捕获周期上限(整格模式下按实测位移自适应放大)。 +const CYCLE_MS_MAX: u64 = 360; +/// 每周期理想位移 = clamp(band_h / 4, TARGET_D_MIN, TARGET_D_MAX)。 +/// 太小 → 慢;太大 → 带内重叠不足、匹配易失败。 +const TARGET_D_MIN: usize = 32; +const TARGET_D_MAX: usize = 180; +/// 小步长模式:起始 / 上下限步长(WHEEL_DELTA=120 为一整格)。 +const DELTA_START: i32 = 20; +const DELTA_MIN: i32 = 4; +const DELTA_MAX: i32 = 72; +/// 整格模式步长(部分程序忽略非整格滚轮消息,累不进小步长)。 +const DELTA_NOTCH: i32 = 120; +/// 策略未锁定时:连续多少周期无位移 → 升级投递策略。 +const ESCALATE_CYCLES: u32 = 3; +/// 策略已锁定时:连续多少周期无位移 → 判定到底。 +const NO_CHANGE_STOP: u32 = 3; +/// 上限:拼接后总高(像素),超出即自动结束。 +const MAX_TOTAL_H: u32 = 30000; +/// 进度事件节流。 +const PROGRESS_THROTTLE_MS: u64 = 120; +/// 停止后等待滚动动画静止的超时(毫秒)。 +const SETTLE_TIMEOUT_MS: u64 = 900; +/// 回滚粗估:每整格对应的滚动像素。 +const ROLLBACK_PX_PER_NOTCH: f64 = 55.0; + +// ===== 会话状态 ===== + +/// 活动会话(会话线程独占读写;命令只改 STOP/CANCEL 原子标志) +struct Session { + hwnd: isize, + /// 客户区尺寸与客户区在窗口位图内偏移 + cw: i32, + ch: i32, + ox: i32, + oy: i32, + band_x: i32, + band_y: i32, + band_w: i32, + band_h: i32, + /// 已拼接像素(带宽 × total_h) + canvas: Vec, + total_h: u32, + /// 累计检测到的滚动像素 + ttl_px: u32, + /// 上一帧(全客户区,匹配基准) + prev: Vec, + /// 初始帧(回滚校准用) + first: Vec, + auto_scroll: bool, + active: bool, +} + +static SESSION: Mutex> = Mutex::new(None); +/// 请求线程结束(完成/取消共用) +static STOP: AtomicBool = AtomicBool::new(false); +/// 结束方式:true = 取消(不导出),false = 完成(导出) +static CANCEL: AtomicBool = AtomicBool::new(false); + +// ===== 滚轮线程共享控制 ===== + +static TICKER_STOP: AtomicBool = AtomicBool::new(false); +static WHEEL_TARGET: AtomicIsize = AtomicIsize::new(0); +/// 滚轮步长(有符号 delta,正值;发送时取负 = 向下滚) +static WHEEL_STEP: AtomicI32 = AtomicI32::new(0); +static WHEEL_POST: AtomicBool = AtomicBool::new(false); +static WHEEL_SX: AtomicI32 = AtomicI32::new(0); +static WHEEL_SY: AtomicI32 = AtomicI32::new(0); + +/// 会话线程每轮用的只读参数(启动时一次性读出)。 +struct Params { + hwnd: isize, + cw: i32, + ch: i32, + ox: i32, + oy: i32, + band_x: i32, + band_y: i32, + band_w: i32, + band_h: i32, + auto_scroll: bool, +} + +fn read_params() -> Option { + let g = SESSION.lock().ok()?; + let s = g.as_ref().filter(|s| s.active)?; + Some(Params { + hwnd: s.hwnd, + cw: s.cw, + ch: s.ch, + ox: s.ox, + oy: s.oy, + band_x: s.band_x, + band_y: s.band_y, + band_w: s.band_w, + band_h: s.band_h, + auto_scroll: s.auto_scroll, + }) +} + +/// 会话是否仍活动(run_loop 每周期自检,异常路径提前退出) +fn is_active() -> bool { + SESSION + .lock() + .map(|g| g.as_ref().map(|s| s.active).unwrap_or(false)) + .unwrap_or(false) +} + +/// 启动滚动截图会话。`auto = true` 时线程自动下滚拼到底部;否则等用户手动滚动。 +pub fn start(app: AppHandle, hwnd: isize, region: Option, auto: bool) -> Result<(), String> { + { + let g = SESSION.lock().map_err(|e| e.to_string())?; + if g.as_ref().map(|s| s.active).unwrap_or(false) { + return Err("已有滚动截图进行中".into()); + } + } + let (ctx, bx, by, bw, bh) = resolve_band(hwnd, region)?; + let first = capture_client_bgra(hwnd, ctx.ox, ctx.oy, ctx.cw, ctx.ch)?; + let canvas = crop_bgra(&first, ctx.cw as usize, bx, by, bw, bh)?; + + { + let mut g = SESSION.lock().map_err(|e| e.to_string())?; + *g = Some(Session { + hwnd, + cw: ctx.cw, + ch: ctx.ch, + ox: ctx.ox, + oy: ctx.oy, + band_x: bx, + band_y: by, + band_w: bw, + band_h: bh, + canvas, + total_h: bh as u32, + ttl_px: 0, + first: first.clone(), + prev: first, + auto_scroll: auto, + active: true, + }); + } + STOP.store(false, Ordering::Release); + CANCEL.store(false, Ordering::Release); + + log_info("scroll-session", &format!( + "启动滚动截图 hwnd={} band={}x{} auto={}", + hwnd, bw, bh, auto + )); + std::thread::spawn(move || run_loop(app)); + Ok(()) +} + +/// 请求结束会话并导出(完成)。立即返回,线程在下一轮感知并完成。 +pub fn finish() -> Result<(), String> { + if !running() { + return Err("没有进行中的滚动截图".into()); + } + STOP.store(true, Ordering::Release); + CANCEL.store(false, Ordering::Release); + Ok(()) +} + +/// 请求取消会话(不导出)。立即返回,线程在下一轮感知并回滚终止。 +pub fn cancel_now() -> Result<(), String> { + if !running() { + return Err("没有进行中的滚动截图".into()); + } + STOP.store(true, Ordering::Release); + CANCEL.store(true, Ordering::Release); + Ok(()) +} + +fn running() -> bool { + SESSION.lock().map(|g| g.as_ref().map(|s| s.active).unwrap_or(false)).unwrap_or(false) +} + +/// 滚轮线程:高频小步长发送,滚动手感交给目标程序的平滑滚动动画。 +fn ticker_loop() { + loop { + if TICKER_STOP.load(Ordering::Acquire) { + break; + } + let target = WHEEL_TARGET.load(Ordering::Acquire); + let delta = WHEEL_STEP.load(Ordering::Acquire); + let sx = WHEEL_SX.load(Ordering::Acquire); + let sy = WHEEL_SY.load(Ordering::Acquire); + if target != 0 && delta != 0 { + if WHEEL_POST.load(Ordering::Acquire) { + post_wheel(target, sx, sy, -delta); + } else { + send_wheel(target, sx, sy, -delta); + } + } + sleep(Duration::from_millis(TICK_MS)); + } +} + +/// 应用滚轮投递策略(未检测到位移时逐级升级,直到找到能滚动的通道)。 +fn apply_strategy(strategy: u8, p: &Params, bcx: i32, bcy: i32) { + match strategy { + 0 => { + // 子窗口 + 小步长(Chromium/WinUI 等会累加小步长并平滑滚动) + WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release); + WHEEL_STEP.store(DELTA_START, Ordering::Release); + WHEEL_POST.store(false, Ordering::Release); + } + 1 => { + // 子窗口 + 整格(经典 Win32 控件只认整格消息) + WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release); + WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release); + WHEEL_POST.store(false, Ordering::Release); + } + 2 => { + // 顶层 + 整格 Send(部分程序由顶层统一处理滚轮) + WHEEL_TARGET.store(p.hwnd, Ordering::Release); + WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release); + WHEEL_POST.store(false, Ordering::Release); + } + _ => { + // 顶层 + 整格 Post(只处理消息泵排队事件的程序) + WHEEL_TARGET.store(p.hwnd, Ordering::Release); + WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release); + WHEEL_POST.store(true, Ordering::Release); + } + } + log_info("scroll-session", &format!("滚轮策略升级为 {}", strategy)); +} + +/// 单周期匹配结果 +enum CycleResult { + /// 两帧一致(未滚动) + NoChange, + /// 向下滚了 d 像素(已拼接) + Moved(usize), + /// 匹配失败(动画 / 位移超上限) + MatchFail, +} + +/// 一轮捕获-匹配-拼接。返回 (band_w, total_h, CycleResult)。 +fn stitch_cycle(p: &Params, cur: &[u8]) -> (i32, u32, CycleResult) { + let mut g = match SESSION.lock() { + Ok(g) => g, + Err(e) => { + log_error("scroll-session", &format!("会话锁异常: {}", e)); + return (p.band_w, 0, CycleResult::MatchFail); + } + }; + let s = match g.as_mut() { + Some(s) => s, + None => return (p.band_w, 0, CycleResult::MatchFail), + }; + let prev = std::mem::take(&mut s.prev); + let (bx, by, bw, bh) = (s.band_x, s.band_y, s.band_w, s.band_h); + let off = detect_vscroll_offset( + &prev, + cur, + p.cw as usize, + p.ch as usize, + bx as usize, + by as usize, + bw as usize, + bh as usize, + ); + match off { + Some(0) => { + s.prev = cur.to_vec(); + (bw, s.total_h, CycleResult::NoChange) + } + Some(d) => { + // 一次最多拼接带的整高:单步滚动量超过带高时无法恢复中间内容, + // 以整带兜底(自适应速度会把位移压回安全区间) + let new_rows = (d as i32).min(bh) as usize; + let new_y = bh - new_rows as i32; // 带在客户区内的底对齐 + match crop_bgra(cur, p.cw as usize, bx, s.band_y + new_y, bw, new_rows as i32) { + Ok(rows) => { + s.canvas.extend_from_slice(&rows); + s.total_h += new_rows as u32; + s.ttl_px += d as u32; + s.prev = cur.to_vec(); + (bw, s.total_h, CycleResult::Moved(d)) + } + Err(_) => { + s.prev = cur.to_vec(); + (bw, s.total_h, CycleResult::MatchFail) + } + } + } + None => { + // 匹配失败:保留旧基准帧(下一帧与更早的稳定帧比对,累积位移仍能对上) + s.prev = prev; + (bw, s.total_h, CycleResult::MatchFail) + } + } +} + +fn run_loop(app: AppHandle) { + let p = match read_params() { + Some(p) => p, + None => return, + }; + + // 滚轮目标初始参数:带中心(客户区坐标)→ 屏幕坐标 + let bcx = p.band_x + p.band_w / 2; + let bcy = p.band_y + p.band_h / 2; + let (bsx, bsy) = client_pt_to_screen(p.hwnd, bcx, bcy).unwrap_or((0, 0)); + WHEEL_SX.store(bsx, Ordering::Release); + WHEEL_SY.store(bsy, Ordering::Release); + + // 自动模式:启动滚轮线程(策略 0 起步) + let mut strategy: u8 = 0; + let mut ticker = None; + if p.auto_scroll { + apply_strategy(0, &p, bcx, bcy); + TICKER_STOP.store(false, Ordering::Release); + ticker = Some(std::thread::spawn(ticker_loop)); + } + + let mut no_change: u32 = 0; // 锁定后:连续无位移(到底判定) + let mut no_move: u32 = 0; // 未锁定:连续无位移(策略升级判定) + let mut locked = false; // 是否已确认当前策略能滚动 + let mut gave_up = false; // 所有策略都滚不动 + let mut cycle_ms = CYCLE_MS; + let mut capture_errors: u32 = 0; + let mut last_emit = Instant::now(); + + loop { + if STOP.load(Ordering::Acquire) || !is_active() { + break; + } + // 周期节拍:捕获+匹配耗时计入周期 + let t0 = Instant::now(); + + let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) { + Ok(f) => { + capture_errors = 0; + f + } + Err(e) => { + // 瞬时失败先重试,连续多次失败才视为窗口关闭/失去客户区 + capture_errors += 1; + log_error("scroll-session", &format!("捕获失败({}/3): {}", capture_errors, e)); + if capture_errors >= 3 { + break; + } + sleep(Duration::from_millis(100)); + continue; + } + }; + + let (band_w, total_h, result) = stitch_cycle(&p, &cur); + + // 目标位移:带高 1/4,夹在安全区间 + let target_d = (p.band_h as usize / 4).clamp(TARGET_D_MIN, TARGET_D_MAX); + + match result { + CycleResult::Moved(d) => { + if !locked { + locked = true; + log_info("scroll-session", &format!("策略 {} 生效,锁定", strategy)); + } + no_change = 0; + no_move = 0; + if p.auto_scroll { + if strategy == 0 { + // 小步长模式:按实测位移调步长(sqrt 阻尼防过冲) + let delta = WHEEL_STEP.load(Ordering::Acquire) as f64; + let factor = target_d as f64 / d.max(6) as f64; + let nd = (delta * factor.sqrt()).clamp(DELTA_MIN as f64, DELTA_MAX as f64); + WHEEL_STEP.store(nd as i32, Ordering::Release); + } else { + // 整格模式:步长固定,按实测位移调捕获周期 + if d as f64 > p.band_h as f64 * 0.55 { + cycle_ms = ((cycle_ms as f64) * 1.25).min(CYCLE_MS_MAX as f64) as u64; + } else if d < target_d / 2 && cycle_ms > CYCLE_MS { + cycle_ms = ((cycle_ms as f64) * 0.8).max(CYCLE_MS as f64) as u64; + } + } + } + } + CycleResult::NoChange => { + if locked { + no_change += 1; + if no_change >= NO_CHANGE_STOP && p.auto_scroll { + break; // 已到底部 + } + // 手动模式由用户控制,不自动结束 + } else if p.auto_scroll { + no_move += 1; + if no_move >= ESCALATE_CYCLES { + no_move = 0; + strategy += 1; + if strategy > 3 { + gave_up = true; // 所有通道都滚不动 → 结束(前端提示) + break; + } + apply_strategy(strategy, &p, bcx, bcy); + } + } + } + CycleResult::MatchFail => { + // 内容大幅变化/动画/位移超上限:若持续失败则降速, + // 避免越滚越快导致匹配一直失败(表现即"滚动中断") + if p.auto_scroll { + if locked && strategy == 0 { + let delta = WHEEL_STEP.load(Ordering::Acquire) as f64; + let nd = (delta * 0.75).max(DELTA_MIN as f64); + WHEEL_STEP.store(nd as i32, Ordering::Release); + } else if !locked { + // 未锁定:匹配失败也算"无位移"参与策略升级, + // 防止动画页面导致升级判定永不触发 + no_move += 1; + if no_move >= ESCALATE_CYCLES { + no_move = 0; + strategy += 1; + if strategy > 3 { + gave_up = true; + break; + } + apply_strategy(strategy, &p, bcx, bcy); + } + } + } + } + } + + // 进度事件(节流) + if matches!(result, CycleResult::Moved(_)) + && last_emit.elapsed().as_millis() as u64 >= PROGRESS_THROTTLE_MS + { + last_emit = Instant::now(); + emit_progress(&app, band_w, total_h, p.auto_scroll); + } + if total_h >= MAX_TOTAL_H { + break; + } + + // 补足周期剩余时间 + let el = t0.elapsed().as_millis() as u64; + if el < cycle_ms { + sleep(Duration::from_millis(cycle_ms - el)); + } + } + + // 停滚轮线程(join 等它退出,最多再发一拍) + if let Some(t) = ticker.take() { + TICKER_STOP.store(true, Ordering::Release); + let _ = t.join(); + } + + let cancel = CANCEL.load(Ordering::Acquire); + // 完成(非取消、非放弃)时:等滚动动画静止后补拼最后一帧, + // 保证图片结尾 = 用户停止时窗口实际停留的位置 + if !cancel && !gave_up { + settle_and_stitch(&p); + } + + // 取出会话数据 + let mut s = match SESSION.lock().ok().and_then(|mut g| g.take()) { + Some(s) => s, + None => { + log_info("scroll-session", "会话已不存在(未导出)"); + let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({})); + return; + } + }; + + // 编码与回滚并行:PNG 编码只依赖画布(已定格),回滚不依赖画布, + // 两者无数据依赖,并行可显著缩短「停止 → 打开编辑器」的等待 + let (band_w, total_h) = (s.band_w, s.total_h); + let encode = if cancel { + None + } else { + let canvas = std::mem::take(&mut s.canvas); + Some(std::thread::spawn(move || { + bgra_to_png(&canvas, band_w, total_h as i32) + })) + }; + + // 回滚到起始位置(与编码并行):先按累计像素粗估上滚,再帧匹配校准 + if s.ttl_px > 0 { + rollback(&s); + } + + STOP.store(false, Ordering::Release); + CANCEL.store(false, Ordering::Release); + + // 等编码完成:PNG 原始字节直接存入编辑器图片槽(不经 base64 / JSON 事件传输) + let png = encode.and_then(|h| h.join().ok()).and_then(|r| r.ok()); + match png { + Some(png) => { + log_info("scroll-session", &format!( + "完成导出 width={} height={}", + band_w, total_h + )); + super::set_editor_image_raw(png); + let _ = app.emit( + evts::SCROLL_COMPLETE, + serde_json::json!({ "width": band_w, "height": total_h }), + ); + } + None => { + log_info("scroll-session", "会话已取消(未导出)"); + let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({})); + } + } +} + +/// 停止后等待目标窗口滚动动画静止,并把静止帧补拼进画布(结尾对齐停止位置)。 +fn settle_and_stitch(p: &Params) { + let deadline = Instant::now() + Duration::from_millis(SETTLE_TIMEOUT_MS); + loop { + sleep(Duration::from_millis(70)); + if Instant::now() >= deadline || !is_active() { + return; + } + let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) { + Ok(f) => f, + Err(_) => return, + }; + match stitch_cycle(p, &cur).2 { + CycleResult::NoChange => return, // 已静止且无新内容 + CycleResult::Moved(_) | CycleResult::MatchFail => { + // 动画仍在进行(Moved 已拼接;MatchFail 继续等) + if Instant::now() >= deadline { + return; + } + } + } + } +} + +/// 回滚:粗估整格数上滚 → 与初始帧逐次比对校准,直到回到起始位置。 +/// 帧匹配校准消除"每格滚动像素因程序而异"带来的累计误差。 +fn rollback(s: &Session) { + let target = { + let t = WHEEL_TARGET.load(Ordering::Acquire); + if t != 0 { t } else { s.hwnd } + }; + let mut sx = WHEEL_SX.load(Ordering::Acquire); + let mut sy = WHEEL_SY.load(Ordering::Acquire); + if sx == 0 && sy == 0 { + if let Some((x, y)) = client_pt_to_screen( + s.hwnd, + s.band_x + s.band_w / 2, + s.band_y + s.band_h / 2, + ) { + sx = x; + sy = y; + } + } + + // 粗估上滚(宁可略少,剩余交给校准补齐)。 + // 上限按累计像素推算:长图捕获(累计数万 px)也要能回滚到位, + // 固定小上限会导致超长捕获结束后窗口停在半途。 + let notches = (((s.ttl_px as f64) / ROLLBACK_PX_PER_NOTCH).floor() as i32).clamp(1, 800); + for _ in 0..notches { + send_wheel(target, sx, sy, WHEEL_DELTA as i32); + sleep(Duration::from_millis(3)); + } + sleep(Duration::from_millis(80)); + + // 帧匹配校准:d = 当前仍相对起始位置向下滚动的像素 + let mut last_d = usize::MAX; + for _ in 0..12 { + let cur = match capture_client_bgra(s.hwnd, s.ox, s.oy, s.cw, s.ch) { + Ok(f) => f, + Err(_) => return, + }; + let d = detect_vscroll_offset( + &s.first, + &cur, + s.cw as usize, + s.ch as usize, + s.band_x as usize, + s.band_y as usize, + s.band_w as usize, + s.band_h as usize, + ); + match d { + Some(0) | None => return, // 已回到起始位置 / 无法匹配(视为完成) + Some(d) if d < 10 => return, // 误差 10px 内视为到位 + Some(d) => { + if d >= last_d { + return; // 不再下降:窗口可能不支持向上滚(虚拟化列表),放弃 + } + last_d = d; + let more = (((d as f64) / ROLLBACK_PX_PER_NOTCH).ceil() as i32).clamp(1, 60); + for _ in 0..more { + send_wheel(target, sx, sy, WHEEL_DELTA as i32); + sleep(Duration::from_millis(3)); + } + sleep(Duration::from_millis(60)); + } + } + } +} + +fn emit_progress(app: &AppHandle, width: i32, height: u32, auto: bool) { + let _ = app.emit( + evts::SCROLL_PROGRESS, + serde_json::json!({ "width": width, "height": height, "auto": auto }), + ); +} diff --git a/src-tauri/src/screenshot/wgc_capture.rs b/src-tauri/src/screenshot/wgc_capture.rs new file mode 100644 index 0000000..de3f277 --- /dev/null +++ b/src-tauri/src/screenshot/wgc_capture.rs @@ -0,0 +1,363 @@ +//! HDR 全屏捕获实现(Windows Graphics Capture + HDR→sRGB 色调映射) +//! +//! ## 为什么需要它 +//! Windows 高级色彩(HDR)开启时,桌面由 DWM 用**线性 scRGB(RGBA16F)**合成。 +//! [crate::screenshot::capture] 的 `BitBlt` 从屏幕 DC 直接取出 8bit 像素, +//! 把这些线性/scRGB 高亮值当 sRGB 编码,会导致亮部越界被裁、局部过曝失真。 +//! +//! ## 方案 +//! 检测到任意显示器为 HDR 输出时,改用 WGC: +//! 1. 以 `RGBA16F`(scRGB 线性)捕获每个显示器; +//! 2. 按 SDR 参考白缩放(`÷ (sdr_white_nits / 80)`)后钳制到 [0,1]; +//! 3. 做 sRGB 伽马编码,得到与屏幕观感一致的 BGRA8; +//! 4. 按各显示器在虚拟屏上的偏移拼接到一整张虚拟屏图像(与 BitBlt 输出同构)。 +//! +//! 仅当 HDR 时才走此路径;SDR 显示器仍用快速的 BitBlt。 + +use std::ffi::c_void; +use std::sync::mpsc; +use std::time::Duration; + +use windows_capture::capture::{Context, GraphicsCaptureApiHandler}; +use windows_capture::frame::Frame; +use windows_capture::graphics_capture_api::InternalCaptureControl; +use windows_capture::monitor::Monitor; +use windows_capture::settings::{ + ColorFormat, CursorCaptureSettings, DirtyRegionSettings, DrawBorderSettings, + MinimumUpdateIntervalSettings, SecondaryWindowSettings, Settings, +}; + +use super::capture::CapturedImage; + +// ===== 虚拟屏尺寸(与 capture.rs 一致)===== +const SM_XVIRTUALSCREEN: i32 = 76; +const SM_YVIRTUALSCREEN: i32 = 77; +const SM_CXVIRTUALSCREEN: i32 = 78; +const SM_CYVIRTUALSCREEN: i32 = 79; + +/// Windows「SDR 内容亮度」的默认参考白(nits)。未自定义时为 203。 +/// 严格值可用 DISPLAYCONFIG_SDR_WHITE_LEVEL 查询;此处取系统默认,覆盖绝大多数情况。 +const DEFAULT_SDR_WHITE_NITS: f32 = 203.0; + +/// 单帧捕获参数(作为 WGC handler 的 Flags 传入,把抓到的帧回传给调用线程) +#[derive(Clone)] +struct CaptureFlags { + tx: mpsc::SyncSender>, +} + +/// 一帧 Rgba16F 像素(已去掉行尾 padding,top-down) +struct FramePixels { + width: u32, + height: u32, + raw: Vec, +} + +/// 一次性截图 handler:拿到第一帧即回传并停止捕获 +struct OneShot { + flags: CaptureFlags, +} + +impl GraphicsCaptureApiHandler for OneShot { + type Flags = CaptureFlags; + type Error = String; + + fn new(ctx: Context) -> Result { + Ok(Self { flags: ctx.flags }) + } + + fn on_frame_arrived( + &mut self, + frame: &mut Frame, + capture_control: InternalCaptureControl, + ) -> Result<(), Self::Error> { + let mut buffer = frame.buffer().map_err(|e| e.to_string())?; + let width = buffer.width(); + let height = buffer.height(); + let row_pitch = buffer.row_pitch() as usize; + let raw = buffer.as_raw_buffer(); + // 拷贝并去掉行 padding(Rgba16F = 每像素 8 字节) + let row_bytes = (width as usize) * 8; + let mut packed = vec![0u8; row_bytes * (height as usize)]; + for y in 0..(height as usize) { + let src = y * row_pitch; + let dst = y * row_bytes; + packed[dst..dst + row_bytes].copy_from_slice(&raw[src..src + row_bytes]); + } + let _ = self.flags.tx.send(Ok(FramePixels { + width, + height, + raw: packed, + })); + // 单帧足够,立即结束捕获(internal stop → 捕获线程退出) + capture_control.stop(); + Ok(()) + } +} + +/// 捕获单个显示器(按 hmonitor),返回 tonemap 后的 BGRA8(top-down) +fn capture_monitor_bgra( + hmonitor: *mut c_void, + sdr_white: f32, +) -> Result<(Vec, u32, u32), String> { + let monitor = Monitor::from_raw_hmonitor(hmonitor); + let (tx, rx) = mpsc::sync_channel::>(1); + let flags = CaptureFlags { tx }; + let settings = Settings::new( + monitor, + CursorCaptureSettings::WithCursor, + DrawBorderSettings::WithoutBorder, + SecondaryWindowSettings::Default, + MinimumUpdateIntervalSettings::Custom(Duration::from_millis(32)), + DirtyRegionSettings::Default, + ColorFormat::Rgba16F, + flags, + ); + + let control = OneShot::start_free_threaded(settings) + .map_err(|e| format!("WGC 捕获启动失败: {}", e))?; + + let frame = match rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(f)) => f, + Ok(Err(e)) => { + let _ = control.stop(); + return Err(e); + } + Err(e) => { + let _ = control.stop(); + return Err(format!("等待 WGC 帧超时: {}", e)); + } + }; + let _ = control.stop(); + + let bgra = tonemap_rgba16f_to_bgra(&frame.raw, frame.width, frame.height, sdr_white); + Ok((bgra, frame.width, frame.height)) +} + +/// 捕获整个虚拟屏(多显示器拼接),返回与 BitBlt 同构的 `CapturedImage`(BGRA top-down)。 +/// 仅当检测到 HDR 时由 [capture] 调用。 +pub fn capture_virtual_screen_wgc() -> Result { + // 用 QueryDisplayConfig 读系统实际 SDR 白电平(nits),取不到才回退系统默认 203 + let sdr_white = query_sdr_white_level().unwrap_or(DEFAULT_SDR_WHITE_NITS); + let monitors = enum_monitors(); + if monitors.is_empty() { + return Err("未检测到显示器".into()); + } + use windows_sys::Win32::UI::WindowsAndMessaging::GetSystemMetrics as gsm; + let vx = unsafe { gsm(SM_XVIRTUALSCREEN) }; + let vy = unsafe { gsm(SM_YVIRTUALSCREEN) }; + let vw = unsafe { gsm(SM_CXVIRTUALSCREEN) }; + let vh = unsafe { gsm(SM_CYVIRTUALSCREEN) }; + if vw <= 0 || vh <= 0 { + return Err("无法获取虚拟屏尺寸".into()); + } + let mut canvas = vec![0u8; (vw as usize) * (vh as usize) * 4]; // 透明区以黑填充 + for m in &monitors { + let (bgra, mw, mh) = capture_monitor_bgra(m.handle as *mut c_void, sdr_white)?; + let ow = m.x - vx; + let oh = m.y - vy; + if mw as i32 != m.w || mh as i32 != m.h { + // 尺寸失配(极少见,如缩放中途)— 中止避免错位拼接 + return Err(format!( + "显示器捕获尺寸不符: 枚举 {}x{} vs WGC {}x{}", + m.w, m.h, mw, mh + )); + } + let mw = mw as usize; + for row in 0..mh { + let src = (row as usize) * mw * 4; + let dst = ((oh + row as i32) as usize) * (vw as usize) * 4 + (ow as usize) * 4; + let len = mw * 4; + canvas[dst..dst + len].copy_from_slice(&bgra[src..src + len]); + } + } + Ok(CapturedImage { + width: vw, + height: vh, + png: Vec::new(), + bgra: canvas, + }) +} + +// ===== 显示器枚举与 HDR 检测 ===== + +struct MonInfo { + handle: usize, + x: i32, + y: i32, + w: i32, + h: i32, +} + +fn enum_monitors() -> Vec { + let mut out: Vec = Vec::new(); + unsafe extern "system" fn cb( + hmon: isize, + _hdc: isize, + rect: *mut windows_sys::Win32::Foundation::RECT, + lparam: isize, + ) -> windows_sys::Win32::Foundation::BOOL { + let v = &mut *(lparam as *mut Vec); + let r = *rect; + v.push(MonInfo { + handle: hmon as usize, + x: r.left, + y: r.top, + w: r.right - r.left, + h: r.bottom - r.top, + }); + 1 + } + unsafe { + windows_sys::Win32::Graphics::Gdi::EnumDisplayMonitors( + 0, + std::ptr::null(), + Some(cb), + &mut out as *mut _ as isize, + ); + } + out +} + +/// 枚举 DXGI 输出,任一显示器为 HDR(advanced color / PQ / HLG)输出则返回 true。 +/// 任何错误一律视为非 HDR(回退 BitBlt),保证普通 SDR 环境不受影响。 +pub fn is_hdr_enabled() -> bool { + #[allow(unused_imports)] + use windows::Win32::Graphics::Dxgi::Common::{ + DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, + DXGI_COLOR_SPACE_TYPE, + }; + + fn is_hdr_space(space: DXGI_COLOR_SPACE_TYPE) -> bool { + // HDR10(PQ,BT.2020 主色)与 scRGB 高级色彩工作空间即视为 HDR + matches!( + space, + DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 | DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709 + ) + } + + use windows::Win32::Graphics::Dxgi::IDXGIFactory1; + let Ok(factory) = (unsafe { + windows::Win32::Graphics::Dxgi::CreateDXGIFactory1::() + }) else { + return false; + }; + use windows::core::ComInterface; + unsafe { + let mut ai = 0u32; + while let Ok(adapter) = factory.EnumAdapters1(ai) { + ai += 1; + let mut oi = 0u32; + while let Ok(output) = adapter.EnumOutputs(oi) { + oi += 1; + if let Ok(out6) = + output.cast::() + { + let mut desc: windows::Win32::Graphics::Dxgi::DXGI_OUTPUT_DESC1 = + std::mem::zeroed(); + if out6.GetDesc1(&mut desc).is_ok() && is_hdr_space(desc.ColorSpace) { + return true; + } + } + } + } + } + false +} + +/// 通过 QueryDisplayConfig 读取 Windows 实际的 SDR 参考白电平(nits)。 +/// +/// SDR 白电平并非 union 的声明成员,而是系统/驱动写入在 DISPLAYCONFIG_MODE_INFO +/// 的 union 尾部(desktopImageInfo 之后)的 4 字节(DISPLAYCONFIG_SDR_WHITE_LEVEL)。 +/// 取不到(非 HDR / 查询失败)时返回 None,由调用方回退系统默认 203。 +fn query_sdr_white_level() -> Option { + use windows_sys::Win32::Devices::Display::{ + GetDisplayConfigBufferSizes, QueryDisplayConfig, DISPLAYCONFIG_MODE_INFO, + DISPLAYCONFIG_MODE_INFO_TYPE_DESKTOP_IMAGE, DISPLAYCONFIG_PATH_INFO, + DISPLAYCONFIG_SDR_WHITE_LEVEL, QDC_ONLY_ACTIVE_PATHS, + }; + unsafe { + let mut num_paths = 0u32; + let mut num_modes = 0u32; + if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut num_paths, &mut num_modes) != 0 + { + return None; + } + let mut paths = vec![std::mem::zeroed::(); num_paths as usize]; + let mut modes = vec![std::mem::zeroed::(); num_modes as usize]; + if QueryDisplayConfig( + QDC_ONLY_ACTIVE_PATHS, + &mut num_paths, + paths.as_mut_ptr(), + &mut num_modes, + modes.as_mut_ptr(), + std::ptr::null_mut(), + ) != 0 + { + return None; + } + for mode in modes.iter().take(num_modes as usize) { + if mode.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_DESKTOP_IMAGE { + let base = mode as *const DISPLAYCONFIG_MODE_INFO as *const u8; + let off = std::mem::size_of::() + - std::mem::size_of::(); + let white = std::ptr::read_unaligned::(base.add(off) as *const u32); + if white > 0 { + return Some(white as f32); + } + } + } + } + None +} + +// ===== HDR→sRGB 色调映射 ===== + +/// IEEE754 半精度 float → f32 +fn half_to_f32(h: u16) -> f32 { + let sign = (h & 0x8000) != 0; + let exp = (h >> 10) & 0x1f; + let man = (h & 0x3ff) as f32; + let v = match exp { + 0 => man / 1024.0 * 2.0f32.powi(-14), // 次正规 + 0x1f => f32::NAN, // Inf/NaN,按 NaN 处理(后续 clamp 为 0~255 安全) + _ => (1.0 + man / 1024.0) * 2.0f32.powi(exp as i32 - 15), + }; + if sign { + -v + } else { + v + } +} + +/// 线性 scRGB 值 → sRGB 8bit(标准 sRGB OETF) +fn srgb_encode(linear: f32) -> u8 { + let l = linear.clamp(0.0, 1.0); + let e = if l <= 0.003_130_8 { + 12.92 * l + } else { + 1.055 * l.powf(1.0 / 2.4) - 0.055 + }; + (e * 255.0 + 0.5) as u8 +} + +/// Rgba16F(scRGB 线性,top-down)→ BGRA8(sRGB),按 SDR 参考白缩放。 +fn tonemap_rgba16f_to_bgra(raw: &[u8], width: u32, height: u32, sdr_white: f32) -> Vec { + let inv_scale = 80.0 / sdr_white.max(1.0); + let n = (width as usize) * (height as usize); + let mut out = vec![0u8; n * 4]; + for i in 0..n { + let pi = i * 8; + let r16 = u16::from_le_bytes([raw[pi], raw[pi + 1]]); + let g16 = u16::from_le_bytes([raw[pi + 2], raw[pi + 3]]); + let b16 = u16::from_le_bytes([raw[pi + 4], raw[pi + 5]]); + let b = srgb_encode(half_to_f32(b16) * inv_scale); + let g = srgb_encode(half_to_f32(g16) * inv_scale); + let r = srgb_encode(half_to_f32(r16) * inv_scale); + let oi = i * 4; + out[oi] = b; + out[oi + 1] = g; + out[oi + 2] = r; + out[oi + 3] = 255; // 强制不透明(与 BitBlt 路径一致) + } + out +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 03d0433..029552f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "thing", - "version": "26.8.4", + "version": "26.8.5", "identifier": "thing.lfeng.me", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/App.vue b/src/App.vue index 34dbf13..0b09f67 100644 --- a/src/App.vue +++ b/src/App.vue @@ -263,6 +263,8 @@ onMounted(async () => { ) // 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史) screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e)) + // 滚动截图完成 → 进编辑器(应用级常驻) + screenshotStore.initScrollEditorListener().catch(e => console.error('[screenshot] 滚动进编辑器监听初始化失败:', e)) }) const trayUnlisteners: UnlistenFn[] = [] diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index d360e57..c01469e 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -280,10 +280,31 @@ export const commands = { screenshotEnumWindows: () => __TAURI_INVOKE("screenshot_enum_windows"), /** 按 hwnd 捕获指定窗口 */ screenshotCaptureWindow: (hwnd: number) => __TAURI_INVOKE("screenshot_capture_window", { hwnd }), - /** 存入编辑器图片(base64 PNG) */ - screenshotSetEditorImage: (pngBase64: string) => __TAURI_INVOKE("screenshot_set_editor_image", { pngBase64 }), - /** 取出编辑器图片(编辑器窗口加载时调用,取出即清除) */ - screenshotGetEditorImage: () => __TAURI_INVOKE("screenshot_get_editor_image"), + /** + * 滚动截图:从窗口当前滚动位置向下拼接到底部,返回超长 PNG。 + * `region` 为 Some 时仅在框选区域(屏幕物理坐标)内捕捉,宽 = 选区宽; + * 为 None 时捕捉整个客户区。结束后会把窗口滚回起始位置,不打扰用户。 + */ + screenshotScrollCapture: (hwnd: number, region: { + x: number, + y: number, + width: number, + height: number, +} | null) => __TAURI_INVOKE("screenshot_scroll_capture", { hwnd, region }), + /** 取消滚动截图会话(不导出)。 */ + screenshotScrollCancel: () => __TAURI_INVOKE("screenshot_scroll_cancel"), + /** 结束滚动截图会话并导出结果。 */ + screenshotScrollFinish: () => __TAURI_INVOKE("screenshot_scroll_finish"), + /** + * 启动滚动截图会话(后台线程持续捕捉拼接,实时推进度事件)。 + * `auto = true` 为自动滚动(线程主动下滚拼到底部);`false` 为手动(等用户滚动窗口)。 + */ + screenshotScrollStart: (hwnd: number, region: { + x: number, + y: number, + width: number, + height: number, +} | null, auto: boolean) => __TAURI_INVOKE("screenshot_scroll_start", { hwnd, region, auto }), /** 将 PNG base64 写入系统剪贴板(转 CF_DIB) */ screenshotCopyImage: (pngBase64: string) => __TAURI_INVOKE("screenshot_copy_image", { pngBase64 }), /** 将 PNG base64 写入文件 */ @@ -634,6 +655,14 @@ export type ScreenRect = { height: number, }; +/** 滚动截图区域(屏幕物理像素坐标,通常为覆盖层框选区平移到屏幕) */ +export type ScrollRegion = { + x: number, + y: number, + width: number, + height: number, +}; + /** 下载分段(多线程 Range 下载 / 断点续传用) */ export type Segment = { /** 分段索引 */ diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6d15aa3..6ea7cb4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -10,6 +10,10 @@ export const WINDOWS = { osdOverlay: 'osd-overlay', screenshotOverlay: 'screenshot-overlay', screenshotPin: 'screenshot-pin', + /** 常驻截图编辑器窗口(隐藏复用,打开只 show 不重建 WebView) */ + screenshotEditor: 'screenshot-editor', + /** 滚动截图控制窗 */ + screenshotScroll: 'screenshot-scroll', /** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-` */ downloadWindow: 'download-window', } as const @@ -46,6 +50,18 @@ export const EVENTS = { screenshotPinReady: 'screenshot-pin-ready', screenshotPinShow: 'screenshot-pin-show', screenshotExported: 'screenshot-exported', + /** 滚动截图会话:实时进度 { width, height, auto } */ + scrollProgress: 'screenshot-scroll-progress', + /** 滚动截图会话:完成并导出(Rust 已把 PNG 原始字节写入编辑器图片槽,事件只带 { width, height }) */ + scrollComplete: 'screenshot-scroll-complete', + /** 滚动截图会话:已取消 */ + scrollCancelled: 'screenshot-scroll-cancelled', + /** 滚动截图完成:打开截图编辑器(编辑/裁剪后保存才进历史) */ + scrollToEditor: 'screenshot-scroll-to-editor', + /** 常驻编辑器窗口挂载完成(store 等待后下发加载事件) */ + screenshotEditorReady: 'screenshot-editor-ready', + /** 通知常驻编辑器窗口加载编辑器图片槽中的新图(raw IPC 取出) */ + screenshotEditorLoad: 'screenshot-editor-load', // 内核安装进度 kernelInstallProgress: 'kernel-install-progress', // 后端自动切换节点完成(后台执行,刷新节点列表并提示) diff --git a/src/main.ts b/src/main.ts index 4f04fd9..c22758b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,7 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr ['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')], ['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')], ['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')], + ['#screenshot-scroll', '滚动截图', () => import('./modules/screenshot/ScrollControl.vue')], ['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')], ] @@ -44,7 +45,12 @@ const winHash = window.location.hash // #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配 const matched = standaloneWindowApps.find(([hash]) => { - if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash) + if ( + hash === '#screenshot-overlay' || + hash === '#screenshot-scroll' || + hash === '#download-window' + ) + return winHash.startsWith(hash) return winHash === hash }) diff --git a/src/modules/clipboard/ClipboardModule.vue b/src/modules/clipboard/ClipboardModule.vue index b51a861..d2b7932 100644 --- a/src/modules/clipboard/ClipboardModule.vue +++ b/src/modules/clipboard/ClipboardModule.vue @@ -7,7 +7,6 @@ import { import { toast } from 'vue-sonner' import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore' import { useModuleTabs } from '@/lib/use-module-tabs' -import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { ScrollArea } from '@/components/ui/scroll-area' import { Card, CardContent } from '@/components/ui/card' @@ -34,7 +33,6 @@ import { const store = useClipboardStore() const activeTab = ref('history') -const tabsStore = useModuleTabsStore() const tabsListRef = useModuleTabs('clipboard', activeTab, [ { value: 'history', label: '历史' }, { value: 'pinned', label: '固定' }, @@ -115,29 +113,35 @@ const handleEnabledToggle = async (val: boolean) => { form.value.enabled = val try { await store.saveSettings({ ...form.value }) - toast.success(val ? '已开启剪贴板监听' : '已停止剪贴板监听') } catch { toast.error('切换监听失败') } } -const handleSaveSettings = async () => { +// 即改即生效:其余设置改动防抖自动保存(成功不提示,失败才提示)。 +// 用值与生效值是否相同来短路,避免 store round-trip 触发同步 watch 造成无限循环。 +let saveFormTimer = 0 +const persistSettingsForm = async () => { + if (JSON.stringify(form.value) === JSON.stringify(store.settings)) return try { await store.saveSettings({ ...form.value }) - toast.success('设置已保存') } catch { toast.error('保存设置失败') } } - -// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用) -tabsStore.registerSave(handleSaveSettings) +watch( + form, + () => { + window.clearTimeout(saveFormTimer) + saveFormTimer = window.setTimeout(persistSettingsForm, 400) + }, + { deep: true } +) const handleShortcutChange = async () => { - // 快捷键变化立即保存并注册(不等点击"保存设置") + // 快捷键变化立即保存并注册(快捷键显示本身已反映新值) try { await store.saveSettings({ ...form.value }) - toast.success(`快捷键已更新为 ${form.value.shortcut || '(已禁用)'}`) } catch { toast.error('快捷键注册失败,可能被其他程序占用') } diff --git a/src/modules/downloader/DownloaderModule.vue b/src/modules/downloader/DownloaderModule.vue index 76b63f0..c5630e6 100644 --- a/src/modules/downloader/DownloaderModule.vue +++ b/src/modules/downloader/DownloaderModule.vue @@ -14,7 +14,6 @@ import { open as openDialog } from '@tauri-apps/plugin-dialog' import { commands } from '@/lib/bindings' import { useDownloaderStore, type DownloadTask, type DownloaderSettings, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore' import { useModuleTabs } from '@/lib/use-module-tabs' -import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { createLogger } from '@/lib/logger' import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -44,7 +43,7 @@ const logger = createLogger('downloader') // ===== 主 Tab 状态 ===== const activeTab = ref('tasks') -const tabsStore = useModuleTabsStore() + const tabsListRef = useModuleTabs('downloader', activeTab, [ { value: 'tasks', label: '下载任务' }, { value: 'settings', label: '设置' }, @@ -175,9 +174,9 @@ const showSecret = ref(false) // 下载设置快捷弹窗(任务页工具栏入口) const downloadSettingsOpen = ref(false) -// ===== 设置草案 ===== -// 设置页/快捷弹窗编辑的是本地草案,点击"保存"才写入 store.settings(生效值)并持久化; -// 避免未保存的编辑污染全应用读取的生效值(主页代理开关、添加任务默认值等) +// ===== 设置草案(即改即生效) ===== +// 设置页/快捷弹窗编辑的是本地草案,任一处改动后防抖自动保存,无需手动"保存"; +// store.settings 为全应用读取的生效值(主页代理开关、添加任务默认值等) const settingsDraft = ref(null) watch( () => store.settings, @@ -187,6 +186,27 @@ watch( { immediate: true, deep: true } ) +// 防抖自动保存:改动后延迟写入 store 并持久化;成功不提示,失败才 toast。 +// 用值与生效值是否相同来短路,避免 store round-trip 触发上面的同步 watch 造成无限循环。 +let saveDraftTimer = 0 +const persistSettingsDraft = async () => { + if (!settingsDraft.value || !store.settings) return + if (JSON.stringify(settingsDraft.value) === JSON.stringify(store.settings)) return + try { + await store.saveSettings({ ...settingsDraft.value }) + } catch (e) { + toast.error('保存设置失败', { description: String(e) }) + } +} +watch( + settingsDraft, + () => { + window.clearTimeout(saveDraftTimer) + saveDraftTimer = window.setTimeout(persistSettingsDraft, 400) + }, + { deep: true } +) + // ===== 任务列表:筛选 / 排序 / 搜索 / 分页 ===== type StatusFilter = 'all' | TaskStatus type SortField = 'id' | 'name' | 'size' @@ -486,24 +506,19 @@ const handleAddDownload = async () => { const dir = addDir.value.trim() addingTask.value = true try { - // HTTP 链接走原流程(含重复检查) + // HTTP 链接:立即添加(后端先建占位任务返回,探测/分段/调度在后台进行, + // 不会卡在慢速探测上;重复 URL 由后端去重,多次点击也不会堆积重复任务) if (httpUris.length > 0) { - const checkEnabled = store.settings?.checkDuplicate ?? true - if (checkEnabled) { - duplicateSuccessCount.value = 0 - processUrlsWithCheck(httpUris, dir || undefined) - } else { - let successCount = 0 - for (const uri of httpUris) { - try { - await store.addTask(uri, undefined, dir || undefined, undefined, true) - successCount++ - } catch (e) { - logger.error(`添加 ${uri} 失败: ` + e) - } + let successCount = 0 + for (const uri of httpUris) { + try { + await store.addTask(uri, undefined, dir || undefined, undefined, true) + successCount++ + } catch (e) { + logger.error(`添加 ${uri} 失败: ` + e) } - if (successCount > 0) finishAdd(successCount) } + if (successCount > 0) finishAdd(successCount) } // BT 链接:异步添加(立即返回,不卡在元数据解析)。后台解析,成功后弹文件勾选。 @@ -711,48 +726,26 @@ const handleSelectSettingsDir = async () => { try { const selected = await openDialog({ directory: true, multiple: false }) if (typeof selected === 'string' && settingsDraft.value) { - await store.saveSettings({ ...settingsDraft.value, downloadDir: selected }) - toast.success('下载目录已更新') + settingsDraft.value.downloadDir = selected } } catch (e) { toast.error('选择目录失败: ' + e) } } -// ===== 设置保存 ===== -const handleSaveSettings = async (): Promise => { - if (!settingsDraft.value) return false - try { - await store.saveSettings({ ...settingsDraft.value }) - toast.success('设置已保存') - return true - } catch (e) { - toast.error('保存失败: ' + e) - return false - } -} - /** 切换代理开关并立即保存(新任务/探测立即生效,正在下载的任务不受影响) */ const toggleProxy = async () => { if (!store.settings) return const next = !store.settings.useProxy try { await store.saveSettings({ ...store.settings, useProxy: next }) - toast.success(next ? '已启用系统代理下载' : '已切换为直连下载') + // 同步草稿,使工具栏与设置页开关状态一致 + if (settingsDraft.value) settingsDraft.value.useProxy = next } catch (e) { toast.error('切换失败: ' + e) } } -// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用) -tabsStore.registerSave(handleSaveSettings) - -// Dialog 保存:成功后关闭弹窗 -const handleDialogSave = async () => { - const ok = await handleSaveSettings() - if (ok) downloadSettingsOpen.value = false -} - // ===== 扩展 ===== // Thing Extension 已发布到 Microsoft Edge 加载项商店,暂定优先 Edge;后续如需支持 Chrome 商店再补充入口 const EXTENSION_STORE_URL = 'https://microsoftedge.microsoft.com/addons/detail/thing-extension/fpcffdkeemoibkhldghjbkgbgbdpofbo' @@ -1045,7 +1038,7 @@ const toggleSortOrder = () => { - {{ store.settings?.useProxy ? '使用系统代理(mihomo 开启系统代理时经其转发)' : '直连模式' }} + {{ store.settings?.useProxy ? '使用 mihomo 代理(本地端口)' : '直连模式' }} @@ -1392,13 +1385,13 @@ const toggleSortOrder = () => {
- - 下载时尊重系统代理设置(mihomo 开启系统代理时经其转发),关闭则强制直连 + + 下载时经本地 mihomo 代理(127.0.0.1:端口)转发,mihomo 未运行时自动直连;关闭则强制直连
@@ -1435,14 +1428,9 @@ const toggleSortOrder = () => {
- - 开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时自动降级为直连 + + BT 下载是否走代理跟随上方"使用 mihomo 代理"开关:开启时自动使用 mihomo 的 SOCKS5 端口,代理不可用时自动降级为直连
-
@@ -1718,16 +1706,16 @@ const toggleSortOrder = () => { />
- +
- + 关闭则强制直连
@@ -1747,12 +1735,8 @@ const toggleSortOrder = () => { - + - diff --git a/src/modules/monitor/MonitorModule.vue b/src/modules/monitor/MonitorModule.vue index 756682b..c2ba74d 100644 --- a/src/modules/monitor/MonitorModule.vue +++ b/src/modules/monitor/MonitorModule.vue @@ -14,6 +14,7 @@ import { toast } from 'vue-sonner' import { VueDraggable } from 'vue-draggable-plus' import { appDataDir } from '@tauri-apps/api/path' import { revealItemInDir } from '@tauri-apps/plugin-opener' +import { invoke } from '@tauri-apps/api/core' import { useMonitorStore, type SensorEntry, @@ -436,6 +437,31 @@ async function handleRefresh() { } } +/** 检测并修复 PawnIO 驱动:安装缺失驱动并重启监控内核 */ +const repairingPawnio = ref(false) +async function handleRepairPawnio() { + repairingPawnio.value = true + try { + const r = await invoke<{ installed: boolean; needReboot: boolean }>('monitor_repair_pawnio') + if (r.needReboot) { + toast.info('PawnIO 驱动已安装,需重启电脑后生效') + } else if (r.installed) { + toast.success('PawnIO 驱动已修复,监控内核已重启') + } else { + toast.error('PawnIO 驱动安装未完成', { + description: '请以管理员/提权方式运行 Thing 后重试', + }) + } + store.pawnIoMissing = false + await store.refreshStatus() + await store.fetchSnapshot() + } catch (e) { + toast.error('修复 PawnIO 失败', { description: String(e) }) + } finally { + repairingPawnio.value = false + } +} + /** 提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。 * Thing 以管理员权限运行时,ThingHK 子进程继承权限,ProcessManager 可直接管控, * ThingHK 崩溃会自动重启,避免数据停止后 Thing 不感知。 @@ -535,8 +561,6 @@ async function handleSaveConfig() { description: '主板/存储等硬件需提权才能读取完整数据,建议提权', duration: 6000, }) - } else { - toast.success('配置已保存', { description: '正在重启 Kernel...' }) } // 自动重启 Kernel 以应用硬件开关变更 @@ -554,7 +578,6 @@ async function handleSaveConfig() { } } } else { - toast.success('配置已保存', { description: '传感器类型过滤已热生效' }) configDialogOpen.value = false // 热更新后立即拉取新快照以反映传感器类型过滤变化 await store.fetchSnapshot() @@ -1091,7 +1114,6 @@ function confirmOsdPick() { osdConfig.value.overlayItems = newItems saveOsdConfig(osdConfig.value) osdPickDialogOpen.value = false - toast.success('悬浮窗显示项已更新') } /** 拖动排序结束回调 */ @@ -1230,7 +1252,6 @@ function saveColorTheme() { osdConfig.value.colorTheme = editingColorTheme.value saveOsdConfig(osdConfig.value) colorThemeDialogOpen.value = false - toast.success('颜色主题已保存') } function resetColorTheme() { @@ -1432,6 +1453,19 @@ watch(() => store.status?.ready, (ready, prev) => { + + + + + 检测到 PawnIO 驱动未安装,CPU 温度/功耗可能无法读取(误卸载过 PawnIO 等情况会触发)。点击下方按钮自动安装并重启监控内核。 + + + + { const activeTab = ref('overview') // 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示 -const tabsStore = useModuleTabsStore() const tabsListRef = useModuleTabs('proxy', activeTab, [ { value: 'overview', label: '概览' }, { value: 'connections', label: '连接' }, @@ -593,7 +591,6 @@ const handleStart = async () => { toast.error('mihomo 启动超时,API 无响应') return } - toast.success('mihomo 已启动') await store.refreshVersion() await loadProxiesWithError() } catch (e) { @@ -607,7 +604,6 @@ const handleStop = async () => { stopping.value = true try { await store.stop() - toast.success('mihomo 已停止') } catch (e) { toast.error('停止失败', { description: String(e) }) } finally { @@ -624,7 +620,6 @@ const handleRestart = async () => { toast.error('mihomo 重启超时,API 无响应') return } - toast.success('mihomo 已重启') await store.refreshVersion() await loadProxiesWithError() } catch (e) { @@ -645,7 +640,6 @@ const onToggleSystemProxy = async (on: boolean) => { sysProxyLoading.value = true try { await store.toggleSystemProxy(on) - toast.success(on ? '系统代理已开启' : '系统代理已关闭') } catch (e) { toast.error('操作失败', { description: String(e) }) } finally { @@ -666,7 +660,6 @@ const changeMode = async (mode: string) => { if (running.value) { await invokePatchConfigs({ mode }) } - toast.success(`已切换为${modeOptions.find(m => m.value === mode)?.label}模式`) } catch (e) { if (store.settings) store.settings.mode = prev toast.error('模式切换失败', { description: String(e) }) @@ -678,7 +671,6 @@ const quickSwitchNode = async (name: string) => { if (!mainGroupName.value) return try { await store.selectProxy(mainGroupName.value, name) - toast.success('节点已切换', { description: name }) // 测速新节点:用 testDelayBatch 以更新 history,保证节点 Badge 显示与结果一致 store.testDelayBatch([name]).then(() => { const delay = store.proxies[name]?.history?.[0]?.delay @@ -694,13 +686,6 @@ const quickSwitchNode = async (name: string) => { // ===== 自动切换节点(执行由后端调度,前端仅负责维护设置并刷新/提示) ===== const onToggleAutoSwitch = (on: boolean) => { autoSwitchEnabled.value = on - if (on) { - toast.success('自动切换已开启', { - description: `每 ${autoSwitchInterval.value} 分钟测试并切换至最优节点` - }) - } else { - toast.info('自动切换已关闭') - } saveAutoSwitchSettings() } @@ -1117,8 +1102,6 @@ const saveSettingsForm = async () => { localSettings.value.secret !== prev.secret if (networkChanged && running.value) { toast.warning('端口/接口/密钥已保存,重启 mihomo 后生效(期间代理 API 使用新地址可能暂时不可用)') - } else { - toast.success('设置已保存') } // 纯模式变更(网络字段未变)在运行中即时生效,与概览页行为一致, @@ -1136,8 +1119,32 @@ const saveSettingsForm = async () => { } } -// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用) -tabsStore.registerSave(saveSettingsForm) +// 即改即生效:设置改动防抖自动保存(成功不提示,失败才提示)。 +// 用局部字段投影对比,避免 store round-trip 触发 syncLocalSettings 造成循环保存。 +const localMatchesStore = () => { + const s = store.settings + if (!s) return false + return JSON.stringify(localSettings.value) === JSON.stringify({ + mixedPort: s.mixedPort, + externalController: s.externalController, + secret: s.secret, + mode: s.mode, + logLevel: s.logLevel, + allowLan: s.allowLan, + autoStart: s.autoStart, + autoSystemProxy: s.autoSystemProxy, + }) +} +let saveLocalTimer = 0 +watch( + localSettings, + () => { + if (localMatchesStore()) return + window.clearTimeout(saveLocalTimer) + saveLocalTimer = window.setTimeout(() => void saveSettingsForm(), 400) + }, + { deep: true } +)