Skip to content

Commit df5f165

Browse files
committed
fix(hub): Windows 打开压缩包尊重第三方关联而非弹出压缩文件夹
explorer.exe 收到 .zip/.rar 等路径会直接以内建压缩文件夹浏览,无视 7-Zip/WinRAR/Bandizip 等用户注册的关联。改为按 UserChoice/HKCR 解析出第三方 handler 时调用 ShellExecuteW(与 Telegram/Chromium/Qt 同款实现)打开,未检测到第三方关联时保持原 explorer.exe 行为。
1 parent 774f0a8 commit df5f165

2 files changed

Lines changed: 127 additions & 2 deletions

File tree

native/hub/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ reqwest = { version = "0.12", features = ["stream", "native-tls", "cookies", "js
6666
librqbit = { version = "8", default-features = false, features = ["default-tls"] }
6767
# 系统调用:SetFileInformationByHandle(文件预分配真正保留 NTFS 物理簇)
6868
# 固定 0.59:0.61 引入 windows-link(raw-dylib),与 cdylib 构建在 CI 上不兼容
69-
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
69+
# Win32_UI_Shell + WindowsAndMessaging:ShellExecuteW(打开文件/目录走系统关联,等价双击,见 reveal_file.rs)
70+
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
7071
# ZIP 解析:用于 bootstrap fluxdown_updater.exe(从下载的 ZIP 中提取,兼容从旧版升级的用户)
7172
zip = { version = "2", default-features = false, features = ["deflate"] }
7273

native/hub/src/reveal_file.rs

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,32 @@ pub fn reveal(path: &str, tpl: &str) {
6868
/// 委托给已运行的用户态 shell(中完整性级别),因此即便 App 以管理员身份运行也
6969
/// 能激活 UWP 关联应用(提权进程直接激活 UWP 会被系统拒绝),且无 cmd 黑框闪
7070
/// 烁。文件场景不存在“强制用 Explorer 当文件管理器”的顾虑(那是打开目录才有)。
71+
/// - **压缩包例外**:explorer.exe 收到 .zip/.rar 等路径会直接以“压缩文件夹”
72+
/// 浏览进去,无视第三方压缩软件的关联。检测到第三方默认 handler 时直接调
73+
/// `ShellExecuteW`(详见 `shell_execute_open` / `archive_handler_is_third_party`)。
7174
///
7275
/// | 平台 | 命令 |
7376
/// |---------|---------------------|
74-
/// | Windows | `explorer.exe path` |
77+
/// | Windows | `explorer.exe path`(第三方关联的压缩包 → `ShellExecuteW`) |
7578
/// | macOS | `open path` |
7679
/// | Linux | `xdg-open path` |
7780
pub fn open_file(path: &str) {
7881
#[cfg(target_os = "windows")]
7982
{
83+
// 压缩包特例:explorer.exe 收到 .zip/.rar 等路径会直接以"压缩文件夹"
84+
// 浏览进去,无视用户注册的第三方压缩软件关联。若检测到该扩展名的默认
85+
// 处理程序是第三方(7-Zip/WinRAR/Bandizip 等),直接调 ShellExecuteW
86+
// (与 Telegram/Chromium/Qt 的做法一致,即"双击"的 API 本体),尊重
87+
// 关联。压缩软件均为 Win32 程序,不受"提权进程无法激活 UWP"的限制。
88+
if is_archive(path) && archive_handler_is_third_party(path) {
89+
if shell_execute_open(path) {
90+
return;
91+
}
92+
crate::logger::log_info!(
93+
"[open_file] ShellExecuteW failed; falling back to explorer.exe"
94+
);
95+
}
96+
8097
if let Err(e) = std::process::Command::new("explorer.exe").arg(path).spawn() {
8198
crate::logger::log_info!("[open_file] explorer.exe failed: {e}");
8299
}
@@ -99,6 +116,113 @@ pub fn open_file(path: &str) {
99116
}
100117
}
101118

119+
/// 直接调 Win32 `ShellExecuteW`("open" 默认 verb)打开路径——这是双击的
120+
/// API 本体,Telegram Desktop / Chromium / Qt `QDesktopServices` 的同款实现。
121+
/// 相比 `cmd /c start`:不额外起 cmd 进程、无引号转义问题、天然无黑框。
122+
/// 返回值 > 32 表示成功(Win32 约定)。
123+
#[cfg(target_os = "windows")]
124+
fn shell_execute_open(path: &str) -> bool {
125+
use windows_sys::Win32::UI::Shell::ShellExecuteW;
126+
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
127+
128+
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
129+
let verb: Vec<u16> = "open".encode_utf16().chain(std::iter::once(0)).collect();
130+
// SAFETY: wide/verb 均为有效的 NUL 结尾 UTF-16 缓冲,在调用期间存活;
131+
// 其余参数按文档允许为空。
132+
let h = unsafe {
133+
ShellExecuteW(
134+
std::ptr::null_mut(),
135+
verb.as_ptr(),
136+
wide.as_ptr(),
137+
std::ptr::null(),
138+
std::ptr::null(),
139+
SW_SHOWNORMAL,
140+
)
141+
};
142+
h as usize > 32
143+
}
144+
145+
/// 常见压缩包扩展名(含复合扩展名 .tar.gz 等——只需匹配末段即可)。
146+
#[cfg(target_os = "windows")]
147+
fn is_archive(path: &str) -> bool {
148+
const ARCHIVE_EXTS: &[&str] = &[
149+
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst", "lz4", "cab",
150+
"arj", "lzh", "wim",
151+
];
152+
std::path::Path::new(path)
153+
.extension()
154+
.and_then(|e| e.to_str())
155+
.map(|e| {
156+
let e = e.to_ascii_lowercase();
157+
ARCHIVE_EXTS.contains(&e.as_str())
158+
})
159+
.unwrap_or(false)
160+
}
161+
162+
/// Windows:该文件扩展名的默认打开程序是否为第三方(非 Explorer)。
163+
///
164+
/// 解析顺序与 Explorer 双击一致:
165+
/// 1. `HKCU\...\Explorer\FileExts\<.ext>\UserChoice` 的 `ProgId`(用户在
166+
/// "打开方式→始终"里选择的结果,优先级最高)
167+
/// 2. 回退 `HKCR\<.ext>` 默认值指向的 ProgId
168+
///
169+
/// 再读 `HKCR\<ProgId>\shell\open\command` 解析可执行文件名。ProgId 为
170+
/// `CompressedFolder`(Explorer 内建 zip)或命令解析为 explorer.exe /
171+
/// 解析失败时返回 `false`(保持 explorer.exe 现状,行为不回退)。
172+
#[cfg(target_os = "windows")]
173+
fn archive_handler_is_third_party(path: &str) -> bool {
174+
use winreg::RegKey;
175+
use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_CURRENT_USER};
176+
177+
let Some(ext) = std::path::Path::new(path)
178+
.extension()
179+
.and_then(|e| e.to_str())
180+
else {
181+
return false;
182+
};
183+
let ext = format!(".{}", ext.to_ascii_lowercase());
184+
185+
// 1) UserChoice ProgId(用户显式选择)
186+
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
187+
let user_choice = hkcu
188+
.open_subkey(format!(
189+
r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\{ext}\UserChoice"
190+
))
191+
.and_then(|k| k.get_value::<String, _>("ProgId"))
192+
.ok();
193+
194+
// 2) 回退 HKCR\<.ext> 默认值
195+
let hkcr = RegKey::predef(HKEY_CLASSES_ROOT);
196+
let progid = match user_choice.filter(|p| !p.trim().is_empty()) {
197+
Some(p) => p,
198+
None => match hkcr
199+
.open_subkey(&ext)
200+
.and_then(|k| k.get_value::<String, _>(""))
201+
{
202+
Ok(p) if !p.trim().is_empty() => p,
203+
_ => return false,
204+
},
205+
};
206+
207+
// Explorer 内建压缩文件夹 handler(zip/cab 默认)。
208+
if progid.eq_ignore_ascii_case("CompressedFolder")
209+
|| progid.eq_ignore_ascii_case("CABFolder")
210+
{
211+
return false;
212+
}
213+
214+
let Ok(cmd) = hkcr
215+
.open_subkey(format!(r"{progid}\shell\open\command"))
216+
.and_then(|k| k.get_value::<String, _>(""))
217+
else {
218+
return false;
219+
};
220+
match exe_basename(&cmd) {
221+
Some(name) => !name.eq_ignore_ascii_case("explorer.exe"),
222+
None => false,
223+
}
224+
}
225+
102226
// ---------------------------------------------------------------------------
103227
// 模板执行:占位符替换 + shell 解析
104228
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)