feat(windows): resolve MyDocument: token in Weixin data-root ini

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).
pull/77/head
Icy-Cat 2026-05-15 11:51:29 +08:00
parent 52cc39a55c
commit b58ae5468d
2 changed files with 95 additions and 3 deletions

View File

@ -71,6 +71,8 @@ windows = { version = "0.58", features = [
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Memory", "Win32_System_Memory",
"Win32_System_Com",
"Win32_UI_Shell",
] } ] }
[profile.release] [profile.release]

View File

@ -320,9 +320,11 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
let path = entry.path(); let path = entry.path();
if path.extension().map(|e| e == "ini").unwrap_or(false) { if path.extension().map(|e| e == "ini").unwrap_or(false) {
if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(content) = std::fs::read_to_string(&path) {
let data_root = content.trim().to_string(); let Some(data_root) = resolve_windows_data_root(content.trim()) else {
if PathBuf::from(&data_root).is_dir() { continue;
let pattern = PathBuf::from(&data_root).join("xwechat_files"); };
if data_root.is_dir() {
let pattern = data_root.join("xwechat_files");
if let Ok(entries2) = std::fs::read_dir(&pattern) { if let Ok(entries2) = std::fs::read_dir(&pattern) {
for entry2 in entries2.flatten() { for entry2 in entries2.flatten() {
let storage = entry2.path().join("db_storage"); let storage = entry2.path().join("db_storage");
@ -340,6 +342,72 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
candidates.into_iter().next_back() 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<PathBuf> {
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<PathBuf> {
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")))] #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl() -> Option<PathBuf> {
None None
@ -351,6 +419,8 @@ mod tests {
config_path_in_dir, default_config_path, find_existing_config_path, home_config_path, config_path_in_dir, default_config_path, find_existing_config_path, home_config_path,
resolve_cli_home, resolve_cli_home,
}; };
#[cfg(target_os = "windows")]
use super::{known_documents_dir, resolve_windows_data_root};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@ -409,4 +479,24 @@ mod tests {
let path = default_config_path(Some(&cwd), Some(&exe), Some(&home)); let path = default_config_path(Some(&cwd), Some(&exe), Some(&home));
assert_eq!(path, cwd.join("config.json")); 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:?}");
}
}
} }