From b58ae5468db39a5c4fe4b628c087128a3e8431dd Mon Sep 17 00:00:00 2001 From: Icy-Cat Date: Fri, 15 May 2026 11:51:29 +0800 Subject: [PATCH] feat(windows): resolve MyDocument: token in Weixin data-root ini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-root ini under %APPDATA%\Tencent\xwechat\config\*.ini is observed to contain either a plain absolute path (e.g. D:\WeChatFiles) or the literal token 'MyDocument:'. The token form is not a real filesystem path, so detect_db_dir_impl() — which previously did PathBuf::from(content).is_dir() — silently failed on it, even though the user's Weixin data was sitting in their (possibly relocated) Documents folder. Empirically the token denotes 'the calling user's Documents folder'. We resolve it via SHGetKnownFolderPath(FOLDERID_Documents), which honours the standard Windows shell-folder redirect (HKCU User Shell Folders\Personal), so users who moved Documents to e.g. D:\Documents now auto-detect correctly. Plain absolute paths still pass through unchanged. Adds Win32_UI_Shell + Win32_System_Com features to the windows crate (needed for SHGetKnownFolderPath and CoTaskMemFree). --- Cargo.toml | 2 ++ src/config.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 527b5e8..2fad488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,8 @@ windows = { version = "0.58", features = [ "Win32_System_Threading", "Win32_Foundation", "Win32_System_Memory", + "Win32_System_Com", + "Win32_UI_Shell", ] } [profile.release] diff --git a/src/config.rs b/src/config.rs index f74fda3..ed91059 100644 --- a/src/config.rs +++ b/src/config.rs @@ -320,9 +320,11 @@ fn detect_db_dir_impl() -> Option { let path = entry.path(); if path.extension().map(|e| e == "ini").unwrap_or(false) { if let Ok(content) = std::fs::read_to_string(&path) { - let data_root = content.trim().to_string(); - if PathBuf::from(&data_root).is_dir() { - let pattern = PathBuf::from(&data_root).join("xwechat_files"); + let Some(data_root) = resolve_windows_data_root(content.trim()) else { + continue; + }; + if data_root.is_dir() { + let pattern = data_root.join("xwechat_files"); if let Ok(entries2) = std::fs::read_dir(&pattern) { for entry2 in entries2.flatten() { let storage = entry2.path().join("db_storage"); @@ -340,6 +342,72 @@ fn detect_db_dir_impl() -> Option { candidates.into_iter().next_back() } +/// Resolve the data-root path that Weixin writes to its `*.ini` file under +/// `%APPDATA%\Tencent\xwechat\config\`. +/// +/// Observed forms in the wild: +/// - A plain absolute path, e.g. `D:\WeChatFiles`. +/// - The literal token `MyDocument:` (sometimes with a trailing slash), +/// which is not a real filesystem path. Empirically this denotes +/// "the current user's Documents folder"; users who relocated +/// Documents to e.g. `D:\Documents` saw auto-detect fail silently +/// because `PathBuf::from("MyDocument:").is_dir()` is false. +/// +/// We accept either form. For the `MyDocument:` token we resolve via +/// `SHGetKnownFolderPath(FOLDERID_Documents)`, which respects the standard +/// shell-folder redirect at +/// `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\Personal`. +#[cfg(target_os = "windows")] +fn resolve_windows_data_root(content: &str) -> Option { + let trimmed = content.trim(); + // Strip an optional trailing slash so `MyDocument:\` and `MyDocument:/` also match. + let stripped = trimmed + .strip_suffix(['\\', '/']) + .unwrap_or(trimmed); + if stripped.eq_ignore_ascii_case("MyDocument:") { + return known_documents_dir(); + } + Some(PathBuf::from(trimmed)) +} + +#[cfg(target_os = "windows")] +fn known_documents_dir() -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Com::CoTaskMemFree; + use windows::Win32::UI::Shell::{ + FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT, + }; + + // SAFETY: standard Win32 known-folder API. SHGetKnownFolderPath either returns + // a heap-allocated PWSTR that the caller must free with CoTaskMemFree, or an + // error — in which case the out-pointer is not allocated. We free on every + // success path. Passing a null token (HANDLE::default()) means "the calling + // user", which is exactly what we want. + unsafe { + let pwstr = + SHGetKnownFolderPath(&FOLDERID_Documents, KF_FLAG_DEFAULT, HANDLE::default()).ok()?; + if pwstr.0.is_null() { + return None; + } + // Walk the NUL-terminated wide string to compute its length. + let mut len = 0usize; + while *pwstr.0.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(pwstr.0, len); + let os_str = OsString::from_wide(slice); + CoTaskMemFree(Some(pwstr.0 as *const _)); + let path = PathBuf::from(os_str); + if path.as_os_str().is_empty() { + None + } else { + Some(path) + } + } +} + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] fn detect_db_dir_impl() -> Option { None @@ -351,6 +419,8 @@ mod tests { config_path_in_dir, default_config_path, find_existing_config_path, home_config_path, resolve_cli_home, }; + #[cfg(target_os = "windows")] + use super::{known_documents_dir, resolve_windows_data_root}; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -409,4 +479,24 @@ mod tests { let path = default_config_path(Some(&cwd), Some(&exe), Some(&home)); assert_eq!(path, cwd.join("config.json")); } + + #[cfg(target_os = "windows")] + #[test] + fn resolve_windows_data_root_passes_through_absolute_path() { + let p = resolve_windows_data_root("D:\\WeChatFiles").unwrap(); + assert_eq!(p, PathBuf::from("D:\\WeChatFiles")); + } + + #[cfg(target_os = "windows")] + #[test] + fn resolve_windows_data_root_recognises_mydocument_keyword() { + // Should match the keyword exactly (case-insensitive, with or without trailing slash) + // and resolve to a non-empty Documents path via SHGetKnownFolderPath. + let docs = known_documents_dir().expect("Documents known folder must resolve"); + for keyword in ["MyDocument:", "mydocument:", "MyDocument:\\", "MyDocument:/"] { + let resolved = resolve_windows_data_root(keyword) + .unwrap_or_else(|| panic!("keyword {keyword:?} should resolve")); + assert_eq!(resolved, docs, "keyword {keyword:?}"); + } + } }