fix(macos): discard a core cache the current user cannot write (#7883)

* fix(macos): discard a core cache the current user cannot write

* docs(changelog): add the unwritable core cache fix

* docs: update changelog to include Linux in cache.db ownership fix

---------

Co-authored-by: oomeow <oomeow@outlook.com>
This commit is contained in:
Rusty Raven
2026-09-09 11:34:43 +00:00
committed by GitHub
co-authored by oomeow
parent 78d6f8eeae
commit 380d03cb97
2 changed files with 104 additions and 0 deletions
+1
View File
@@ -18,6 +18,7 @@
修复 i18n 清理脚本因导入方式错误而无法运行的问题
修复 Linux 桌面环境下主题切换时应用外观不跟随的问题
修复 Gemini 测试正确性
修复 macOS/Linux 用户模式下旧版服务遗留的 cache.db 属主为 root 且当前用户不可写,导致无法记住上次选择节点的问题
修复代理组节点 `PASS-RULE` 被过滤丢失的问题
<details>
+103
View File
@@ -108,6 +108,8 @@ impl CoreManager {
let app_handle = handle::Handle::app_handle();
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
let config_dir = dirs::app_home_dir()?;
#[cfg(unix)]
discard_unwritable_core_cache(&config_dir);
#[cfg(unix)]
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o077) };
@@ -322,6 +324,107 @@ impl CoreManager {
}
}
/// Drops a `cache.db` the current user cannot write before handing the directory to the core.
///
/// mihomo keeps `profile.store-selected` in `cache.db` inside its data directory. Service builds
/// before the runtime staging rework ran the core as root against this same directory without a
/// umask, leaving the file as `root:staff 0644`: still readable, so the core loads stale
/// selections, but never writable again, so it silently stops recording new ones. Nothing
/// repairs it either, because the service-side cleanup only runs inside the service. Removing it
/// lets the core recreate the cache under the current user; the fake-ip leases and frozen
/// selections that go with it could not be updated anyway.
#[cfg(unix)]
fn discard_unwritable_core_cache(config_dir: &Path) {
let cache = config_dir.join("cache.db");
// Appending neither creates nor truncates, so this only asks whether a write would be allowed.
match std::fs::OpenOptions::new().append(true).open(&cache) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
// Unlinking is governed by the directory, which the current user owns.
match std::fs::remove_file(&cache) {
Ok(()) => logging!(
info,
Type::Core,
"Discarded a core cache the current user cannot write: {}",
cache.display()
),
Err(error) => logging!(
warn,
Type::Core,
"Failed to discard the unwritable core cache {}: {error}",
cache.display()
),
}
}
Err(error) => logging!(
warn,
Type::Core,
"Failed to probe the core cache {}: {error}",
cache.display()
),
}
}
#[cfg(all(test, unix))]
mod core_cache_tests {
use super::discard_unwritable_core_cache;
use std::os::unix::fs::PermissionsExt as _;
fn scratch(name: &str) -> anyhow::Result<std::path::PathBuf> {
let root = std::env::temp_dir().join(format!("clash-verge-core-cache-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root)?;
Ok(root)
}
#[test]
fn an_unwritable_cache_is_discarded() -> anyhow::Result<()> {
// root ignores the permission bits, so the probe cannot fail there.
if unsafe { tauri_plugin_clash_verge_sysinfo::libc::geteuid() } == 0 {
return Ok(());
}
let root = scratch("unwritable")?;
let cache = root.join("cache.db");
std::fs::write(&cache, b"stale")?;
std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o444))?;
discard_unwritable_core_cache(&root);
assert!(!cache.exists(), "an unwritable cache must not be handed to the core");
let _ = std::fs::remove_dir_all(&root);
Ok(())
}
#[test]
fn a_writable_cache_is_kept() -> anyhow::Result<()> {
let root = scratch("writable")?;
let cache = root.join("cache.db");
std::fs::write(&cache, b"live")?;
discard_unwritable_core_cache(&root);
assert_eq!(
std::fs::read(&cache)?,
b"live",
"a writable cache carries the stored selections and must survive"
);
let _ = std::fs::remove_dir_all(&root);
Ok(())
}
#[test]
fn a_missing_cache_is_not_an_error() -> anyhow::Result<()> {
let root = scratch("missing")?;
discard_unwritable_core_cache(&root);
assert!(!root.join("cache.db").exists());
let _ = std::fs::remove_dir_all(&root);
Ok(())
}
}
#[cfg(test)]
mod readiness_tests {
use super::{claim_core_readiness_generation, poll_sidecar_readiness, should_clear_terminated_sidecar};