mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Scope CEF data to OpenHuman user profiles (#917)
This commit is contained in:
Generated
+103
-2
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cef",
|
||||
"chrono",
|
||||
"directories",
|
||||
"env_logger",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -36,8 +37,10 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-runtime-cef",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.8.2",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -967,13 +970,34 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ea539174bb236e0e7dc9c12b19b88eae3cb574dedbd0252a2d43ea7e6de13e2"
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -984,7 +1008,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -3687,6 +3711,17 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -6134,6 +6169,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -6185,6 +6229,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -6242,6 +6301,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -6260,6 +6325,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6278,6 +6349,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -6308,6 +6385,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6326,6 +6409,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -6344,6 +6433,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -6362,6 +6457,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -53,6 +53,8 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
directories = "5"
|
||||
# Used by gmail/cdp_fetch for decoding binary IO.read chunks. Base64 is
|
||||
# only emitted by CDP IO.read when the stream contains non-UTF-8 bytes,
|
||||
# but we opt into the feature to stay robust against unexpected responses.
|
||||
@@ -157,6 +159,7 @@ tauri-plugin-notification = { path = "vendor/tauri-plugin-notification" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
tempfile = "3"
|
||||
|
||||
# Fast CI builds: trade runtime perf for compile speed
|
||||
[profile.ci]
|
||||
|
||||
@@ -74,6 +74,15 @@ impl std::error::Error for CefLockError {}
|
||||
|
||||
/// Resolves the macOS default CEF cache directory and runs the preflight.
|
||||
pub fn check_default_cache() -> Result<(), CefLockError> {
|
||||
if let Some(configured) = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH") {
|
||||
let configured = PathBuf::from(configured);
|
||||
log::debug!(
|
||||
"[cef-preflight] using configured cache_path={}",
|
||||
configured.display()
|
||||
);
|
||||
return check_cef_cache_lock(&configured);
|
||||
}
|
||||
|
||||
let home = std::env::var_os("HOME").ok_or(CefLockError::NoHomeDir)?;
|
||||
let cache_path = PathBuf::from(home)
|
||||
.join("Library/Caches")
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CEF_CACHE_PATH_ENV: &str = "OPENHUMAN_CEF_CACHE_PATH";
|
||||
const ACTIVE_USER_STATE_FILE: &str = "active_user.toml";
|
||||
/// Sibling of the OpenHuman data dir (not under it) so the marker survives
|
||||
/// `reset_local_data` removing the whole `default_openhuman_dir` tree.
|
||||
const PENDING_PURGE_STATE_FILE: &str = "openhuman_pending_cef_purge.toml";
|
||||
/// Pre–sibling-layout marker (lived under the data root; `reset_local_data` removed it).
|
||||
const LEGACY_PENDING_PURGE_IN_TREE: &str = "pending_cef_purge.toml";
|
||||
const PRE_LOGIN_USER_ID: &str = "local";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ActiveUserState {
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
struct PendingCefPurgeState {
|
||||
#[serde(default)]
|
||||
paths: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_root_dir_name() -> &'static str {
|
||||
let app_env = std::env::var("OPENHUMAN_APP_ENV")
|
||||
.or_else(|_| std::env::var("VITE_OPENHUMAN_APP_ENV"))
|
||||
.ok()
|
||||
.map(|value| value.trim().to_ascii_lowercase());
|
||||
if matches!(app_env.as_deref(), Some("staging")) {
|
||||
".openhuman-staging"
|
||||
} else {
|
||||
".openhuman"
|
||||
}
|
||||
}
|
||||
|
||||
fn default_root_openhuman_dir() -> Result<PathBuf, String> {
|
||||
if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") {
|
||||
let trimmed = workspace.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
let home = directories::UserDirs::new()
|
||||
.map(|dirs| dirs.home_dir().to_path_buf())
|
||||
.ok_or_else(|| "Could not find home directory".to_string())?;
|
||||
Ok(home.join(default_root_dir_name()))
|
||||
}
|
||||
|
||||
fn read_active_user_id(default_openhuman_dir: &Path) -> Option<String> {
|
||||
let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE);
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
let state: ActiveUserState = toml::from_str(&contents).ok()?;
|
||||
let trimmed = state.user_id.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a single safe path segment for `users/<id>/…`. Rejects traversal, separators,
|
||||
/// and other inputs that would escape the intended profile root.
|
||||
fn validate_user_id_for_path(user_id: &str) -> Result<String, String> {
|
||||
let trimmed = user_id.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("user_id is empty after trim".to_string());
|
||||
}
|
||||
if matches!(trimmed, "." | "..") {
|
||||
return Err("user_id must not be '.' or '..'".to_string());
|
||||
}
|
||||
if trimmed.contains("..")
|
||||
|| trimmed
|
||||
.chars()
|
||||
.any(|c| matches!(c, '/' | '\\' | '\0' | char::REPLACEMENT_CHARACTER) || c.is_control())
|
||||
{
|
||||
return Err("user_id must not contain path components or control characters".to_string());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if trimmed.contains(':') {
|
||||
return Err("user_id must not contain ':' (Windows path roots)".to_string());
|
||||
}
|
||||
if !trimmed
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '@' || c == '.')
|
||||
{
|
||||
return Err("user_id must only use [A-Za-z0-9._@-] (after trim)".to_string());
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn user_openhuman_dir(default_openhuman_dir: &Path, user_id: &str) -> Result<PathBuf, String> {
|
||||
let id = validate_user_id_for_path(user_id)?;
|
||||
Ok(default_openhuman_dir.join("users").join(&id))
|
||||
}
|
||||
|
||||
fn cache_dir_for_user(default_openhuman_dir: &Path, user_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(user_openhuman_dir(default_openhuman_dir, user_id)?.join("cef"))
|
||||
}
|
||||
|
||||
/// `remove_dir_all` is only safe for CEF profile dirs we queued ourselves (under
|
||||
/// `.../users/<id>/cef`). Rejects absolute paths outside that tree, corrupted
|
||||
/// TOML, or anything that `canonicalize` would not place under
|
||||
/// `default_openhuman_dir/users/…/cef`.
|
||||
fn is_trusted_queued_purge_path(default_openhuman_dir: &Path, target: &Path) -> bool {
|
||||
if !target.is_absolute() {
|
||||
log::warn!(
|
||||
"[cef-profile] refusing purge: path is not absolute (possible cwd-relative TOML injection) path={}",
|
||||
target.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let Ok(data_root) = std::fs::canonicalize(default_openhuman_dir) else {
|
||||
log::warn!(
|
||||
"[cef-profile] refusing purge: could not canonicalize data root path={} (cannot validate purge target) target={}",
|
||||
default_openhuman_dir.display(),
|
||||
target.display()
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let users_dir = data_root.join("users");
|
||||
let Ok(users_canon) = std::fs::canonicalize(&users_dir) else {
|
||||
log::warn!(
|
||||
"[cef-profile] refusing purge: could not canonicalize `users` dir under {} (target={})",
|
||||
data_root.display(),
|
||||
target.display()
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let Ok(canon) = std::fs::canonicalize(target) else {
|
||||
log::warn!(
|
||||
"[cef-profile] refusing purge: could not canonicalize target (symlink/permission?) path={}",
|
||||
target.display()
|
||||
);
|
||||
return false;
|
||||
};
|
||||
if !canon.starts_with(&users_canon) {
|
||||
log::warn!(
|
||||
"[cef-profile] refusing purge: canonical path is not under users tree (possible malicious queue entry) data_root={} target_canon={}",
|
||||
data_root.display(),
|
||||
canon.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if canon.file_name() != Some(OsStr::new("cef")) {
|
||||
log::warn!(
|
||||
"[cef-profile] refusing purge: expected a .../users/<id>/cef directory, got file_name={:?} path={}",
|
||||
canon.file_name(),
|
||||
canon.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Marker file lives in the **parent** of the OpenHuman data root so a full
|
||||
/// `remove_dir_all(default_openhuman_dir)` (e.g. from core `reset_local_data`) does
|
||||
/// not delete the pending-purge list before it is processed.
|
||||
fn pending_purge_marker_path(default_openhuman_dir: &Path) -> Result<PathBuf, String> {
|
||||
let parent = default_openhuman_dir.parent().ok_or_else(|| {
|
||||
"default OpenHuman data dir has no parent; cannot place CEF purge marker outside the data tree"
|
||||
.to_string()
|
||||
})?;
|
||||
Ok(parent.join(PENDING_PURGE_STATE_FILE))
|
||||
}
|
||||
|
||||
pub fn configured_cache_path_from_env() -> Option<PathBuf> {
|
||||
std::env::var(CEF_CACHE_PATH_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn load_pending_purge_state(default_openhuman_dir: &Path) -> Result<PendingCefPurgeState, String> {
|
||||
let path = pending_purge_marker_path(default_openhuman_dir)?;
|
||||
if path.exists() {
|
||||
let raw = std::fs::read_to_string(&path).map_err(|error| {
|
||||
format!("read pending CEF purge marker {}: {error}", path.display())
|
||||
})?;
|
||||
return toml::from_str(&raw).map_err(|error| {
|
||||
format!("parse pending CEF purge marker {}: {error}", path.display())
|
||||
});
|
||||
}
|
||||
|
||||
// One-time read from the legacy in-tree file (older app versions).
|
||||
let legacy = default_openhuman_dir.join(LEGACY_PENDING_PURGE_IN_TREE);
|
||||
if !legacy.exists() {
|
||||
return Ok(PendingCefPurgeState::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&legacy).map_err(|error| {
|
||||
format!(
|
||||
"read legacy pending CEF purge marker {}: {error}",
|
||||
legacy.display()
|
||||
)
|
||||
})?;
|
||||
let state: PendingCefPurgeState = toml::from_str(&raw).map_err(|error| {
|
||||
format!(
|
||||
"parse legacy pending CEF purge marker {}: {error}",
|
||||
legacy.display()
|
||||
)
|
||||
})?;
|
||||
match save_pending_purge_state(default_openhuman_dir, &state) {
|
||||
Ok(()) => {
|
||||
let _ = std::fs::remove_file(&legacy);
|
||||
log::info!(
|
||||
"[cef-profile] migrated pending CEF purge list from {} to {}",
|
||||
legacy.display(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Err(err) => log::warn!(
|
||||
"[cef-profile] could not write migrated pending CEF purge marker to {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn save_pending_purge_state(
|
||||
default_openhuman_dir: &Path,
|
||||
state: &PendingCefPurgeState,
|
||||
) -> Result<(), String> {
|
||||
std::fs::create_dir_all(default_openhuman_dir).map_err(|error| {
|
||||
format!(
|
||||
"create OpenHuman root dir {}: {error}",
|
||||
default_openhuman_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = pending_purge_marker_path(default_openhuman_dir)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"create parent of pending CEF purge marker {}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let raw = toml::to_string_pretty(state)
|
||||
.map_err(|error| format!("serialize pending CEF purge marker: {error}"))?;
|
||||
std::fs::write(&path, raw)
|
||||
.map_err(|error| format!("write pending CEF purge marker {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
pub fn queue_profile_purge_for_user(user_id: Option<&str>) -> Result<PathBuf, String> {
|
||||
let default_openhuman_dir = default_root_openhuman_dir()?;
|
||||
let user_id = user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(PRE_LOGIN_USER_ID);
|
||||
let purge_path = cache_dir_for_user(&default_openhuman_dir, user_id)?;
|
||||
|
||||
let mut state = load_pending_purge_state(&default_openhuman_dir)?;
|
||||
let mut unique = BTreeSet::new();
|
||||
for path in state.paths {
|
||||
unique.insert(path);
|
||||
}
|
||||
unique.insert(purge_path.display().to_string());
|
||||
state = PendingCefPurgeState {
|
||||
paths: unique.into_iter().collect(),
|
||||
};
|
||||
save_pending_purge_state(&default_openhuman_dir, &state)?;
|
||||
log::info!(
|
||||
"[cef-profile] queued purge for user={} path={}",
|
||||
user_id,
|
||||
purge_path.display()
|
||||
);
|
||||
Ok(purge_path)
|
||||
}
|
||||
|
||||
pub fn prepare_process_cache_path() -> Result<PathBuf, String> {
|
||||
let default_openhuman_dir = default_root_openhuman_dir()?;
|
||||
drain_pending_purges(&default_openhuman_dir)?;
|
||||
|
||||
let user_id_raw = read_active_user_id(&default_openhuman_dir)
|
||||
.unwrap_or_else(|| PRE_LOGIN_USER_ID.to_string());
|
||||
let user_id = match validate_user_id_for_path(&user_id_raw) {
|
||||
Ok(id) => id,
|
||||
Err(why) => {
|
||||
log::warn!(
|
||||
"[cef-profile] invalid user_id in active user state: {why}; using {}",
|
||||
PRE_LOGIN_USER_ID
|
||||
);
|
||||
PRE_LOGIN_USER_ID.to_string()
|
||||
}
|
||||
};
|
||||
let cache_dir = cache_dir_for_user(&default_openhuman_dir, &user_id)?;
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.map_err(|error| format!("create CEF cache dir {}: {error}", cache_dir.display()))?;
|
||||
std::env::set_var(CEF_CACHE_PATH_ENV, &cache_dir);
|
||||
log::info!(
|
||||
"[cef-profile] configured CEF cache user={} path={}",
|
||||
user_id,
|
||||
cache_dir.display()
|
||||
);
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
fn drain_pending_purges(default_openhuman_dir: &Path) -> Result<(), String> {
|
||||
let marker_path = pending_purge_marker_path(default_openhuman_dir)?;
|
||||
let mut state = load_pending_purge_state(default_openhuman_dir)?;
|
||||
if state.paths.is_empty() {
|
||||
if marker_path.exists() {
|
||||
let _ = std::fs::remove_file(&marker_path);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut remaining: Vec<String> = Vec::new();
|
||||
for raw_path in &state.paths {
|
||||
let target = PathBuf::from(raw_path);
|
||||
if !target.exists() {
|
||||
log::debug!(
|
||||
"[cef-profile] pending purge target already absent path={}",
|
||||
target.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !is_trusted_queued_purge_path(default_openhuman_dir, &target) {
|
||||
log::warn!(
|
||||
"[cef-profile] skipping unsafe purge and retaining queue entry (will not delete) path={} raw_toml={}",
|
||||
target.display(),
|
||||
raw_path
|
||||
);
|
||||
remaining.push(raw_path.clone());
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_dir_all(&target) {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"[cef-profile] purged queued CEF cache path={}",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"[cef-profile] failed to purge queued CEF cache path={} error={}",
|
||||
target.display(),
|
||||
error
|
||||
);
|
||||
remaining.push(raw_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remaining.is_empty() {
|
||||
state.paths = remaining;
|
||||
save_pending_purge_state(default_openhuman_dir, &state)?;
|
||||
log::warn!(
|
||||
"[cef-profile] not removing pending CEF purge marker: {} path(s) still fail purge (will retry) marker={}",
|
||||
state.paths.len(),
|
||||
marker_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if marker_path.exists() {
|
||||
std::fs::remove_file(&marker_path).map_err(|error| {
|
||||
format!(
|
||||
"remove pending CEF purge marker {}: {error}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_active_user_id_ignores_empty_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(ACTIVE_USER_STATE_FILE), "user_id = \" \"").unwrap();
|
||||
assert_eq!(read_active_user_id(tmp.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_dir_for_user_nests_under_users_tree() {
|
||||
let root = PathBuf::from("/tmp/openhuman");
|
||||
assert_eq!(
|
||||
cache_dir_for_user(&root, "u-123").unwrap(),
|
||||
PathBuf::from("/tmp/openhuman/users/u-123/cef")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_id_rejects_path_traversal() {
|
||||
assert!(validate_user_id_for_path("..").is_err());
|
||||
assert!(validate_user_id_for_path("a/../b").is_err());
|
||||
assert!(validate_user_id_for_path("x/y").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_id_accepts_typical_ids() {
|
||||
assert_eq!(validate_user_id_for_path("u-123").unwrap(), "u-123");
|
||||
assert_eq!(
|
||||
validate_user_id_for_path("user@ex.com").unwrap(),
|
||||
"user@ex.com"
|
||||
);
|
||||
}
|
||||
|
||||
/// `default_openhuman_dir` must have a parent (sibling marker uses `parent()`).
|
||||
fn test_data_hierarchy() -> (tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let data_root = tmp.path().join("oh_data");
|
||||
std::fs::create_dir_all(&data_root).unwrap();
|
||||
(tmp, data_root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_purge_marker_migrates_to_sibling_file() {
|
||||
let (_tmp, data_root) = test_data_hierarchy();
|
||||
let legacy = data_root.join(LEGACY_PENDING_PURGE_IN_TREE);
|
||||
let sibling = data_root.parent().unwrap().join(PENDING_PURGE_STATE_FILE);
|
||||
let body = r#"paths = []"#;
|
||||
std::fs::write(&legacy, body).unwrap();
|
||||
assert!(!sibling.exists());
|
||||
|
||||
let _ = load_pending_purge_state(&data_root).unwrap();
|
||||
|
||||
assert!(!legacy.exists());
|
||||
assert!(sibling.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_removes_only_trusted_paths_and_clears_marker() {
|
||||
let (_tmp, data_root) = test_data_hierarchy();
|
||||
let cef = data_root.join("users").join("u1").join("cef");
|
||||
std::fs::create_dir_all(&cef).unwrap();
|
||||
std::fs::write(cef.join("x.txt"), b"x").unwrap();
|
||||
let cef_s = cef.to_string_lossy().to_string();
|
||||
|
||||
let state = PendingCefPurgeState { paths: vec![cef_s] };
|
||||
save_pending_purge_state(&data_root, &state).unwrap();
|
||||
|
||||
drain_pending_purges(&data_root).unwrap();
|
||||
|
||||
assert!(!cef.exists());
|
||||
let marker = pending_purge_marker_path(&data_root).unwrap();
|
||||
assert!(!marker.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_retains_malicious_queue_path_without_deleting() {
|
||||
let (tmp, data_root) = test_data_hierarchy();
|
||||
let outside = tmp.path().join("outside_sandbox");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let outside_s = outside.to_string_lossy().to_string();
|
||||
let state = PendingCefPurgeState {
|
||||
paths: vec![outside_s.clone()],
|
||||
};
|
||||
save_pending_purge_state(&data_root, &state).unwrap();
|
||||
|
||||
drain_pending_purges(&data_root).unwrap();
|
||||
|
||||
assert!(outside.exists());
|
||||
let rest = load_pending_purge_state(&data_root).unwrap();
|
||||
assert_eq!(rest.paths, vec![outside_s]);
|
||||
let marker = pending_purge_marker_path(&data_root).unwrap();
|
||||
assert!(marker.exists());
|
||||
}
|
||||
|
||||
/// Path is under `users/…` but last component is not `cef` (reject, retain in queue).
|
||||
#[test]
|
||||
fn drain_does_not_remove_path_without_cef_final_segment() {
|
||||
let (_tmp, data_root) = test_data_hierarchy();
|
||||
let d = data_root.join("users").join("u1").join("data");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("f"), b"1").unwrap();
|
||||
save_pending_purge_state(
|
||||
&data_root,
|
||||
&PendingCefPurgeState {
|
||||
paths: vec![d.to_string_lossy().to_string()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
drain_pending_purges(&data_root).unwrap();
|
||||
|
||||
assert!(d.exists());
|
||||
let after = load_pending_purge_state(&data_root).unwrap();
|
||||
assert_eq!(after.paths.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp
|
||||
mod cdp;
|
||||
#[cfg(all(feature = "cef", target_os = "macos"))]
|
||||
mod cef_preflight;
|
||||
#[cfg(feature = "cef")]
|
||||
mod cef_profile;
|
||||
mod core_process;
|
||||
mod core_update;
|
||||
#[cfg(feature = "cef")]
|
||||
@@ -330,6 +332,19 @@ async fn restart_core_process(
|
||||
state.inner().restart().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart_app(app: tauri::AppHandle<AppRuntime>) -> Result<(), String> {
|
||||
log::info!("[app] restart_app invoked from frontend");
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[cfg(feature = "cef")]
|
||||
#[tauri::command]
|
||||
async fn schedule_cef_profile_purge(user_id: Option<String>) -> Result<String, String> {
|
||||
let queued = cef_profile::queue_profile_purge_for_user(user_id.as_deref())?;
|
||||
Ok(queued.display().to_string())
|
||||
}
|
||||
|
||||
/// Fire the proactive welcome agent on a detached core-side task.
|
||||
///
|
||||
/// Returns immediately; the agent runs in the background and publishes
|
||||
@@ -762,6 +777,17 @@ pub fn run() {
|
||||
// here and exit cleanly with a message that names the lock-holder PID
|
||||
// and the workaround. Stale locks (PID dead) are removed and we
|
||||
// continue, matching Chromium's own startup recovery.
|
||||
#[cfg(feature = "cef")]
|
||||
match cef_profile::prepare_process_cache_path() {
|
||||
Ok(path) => log::debug!("[cef-profile] startup cache path={}", path.display()),
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"[cef-profile] failed to configure per-user CEF cache; refusing to start with shared/global cache: {error}"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "cef", target_os = "macos"))]
|
||||
if let Err(e) = cef_preflight::check_default_cache() {
|
||||
eprintln!("\n[openhuman] {e}\n");
|
||||
@@ -920,8 +946,17 @@ pub fn run() {
|
||||
// providers (gmail, whatsapp, slack, …) already have a live
|
||||
// session cookie. Best-effort — if we can't resolve the path
|
||||
// the core treats every provider as logged_out.
|
||||
if let Ok(cache_dir) = app.path().app_cache_dir() {
|
||||
let cookies_db = cache_dir.join("cef").join("Default").join("Cookies");
|
||||
if let Some(cache_dir) = {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
cef_profile::configured_cache_path_from_env()
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
} {
|
||||
let cookies_db = cache_dir.join("Default").join("Cookies");
|
||||
log::debug!("[webview_accounts] exposing cookies DB path to core");
|
||||
std::env::set_var("OPENHUMAN_CEF_COOKIES_DB", &cookies_db);
|
||||
} else {
|
||||
@@ -929,7 +964,7 @@ pub fn run() {
|
||||
// stale path from a previous run or the parent shell.
|
||||
std::env::remove_var("OPENHUMAN_CEF_COOKIES_DB");
|
||||
log::warn!(
|
||||
"[webview_accounts] could not resolve app_cache_dir — core \
|
||||
"[webview_accounts] could not resolve configured CEF cache dir — core \
|
||||
will report all webview providers as logged_out"
|
||||
);
|
||||
}
|
||||
@@ -1266,6 +1301,9 @@ pub fn run() {
|
||||
check_app_update,
|
||||
apply_app_update,
|
||||
restart_core_process,
|
||||
restart_app,
|
||||
#[cfg(feature = "cef")]
|
||||
schedule_cef_profile_purge,
|
||||
spawn_welcome_agent,
|
||||
service_install_direct,
|
||||
service_start_direct,
|
||||
|
||||
Vendored
+1
-1
Submodule app/src-tauri/vendor/tauri-cef updated: 448d476046...3a4b24b9e3
@@ -2,14 +2,18 @@ import { useState } from 'react';
|
||||
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { persistor } from '../../store';
|
||||
import { resetOpenHumanDataAndRestartCore } from '../../utils/tauriCommands';
|
||||
import {
|
||||
resetOpenHumanDataAndRestartCore,
|
||||
restartApp,
|
||||
scheduleCefProfilePurge,
|
||||
} from '../../utils/tauriCommands';
|
||||
import SettingsHeader from './components/SettingsHeader';
|
||||
import SettingsMenuItem from './components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
|
||||
|
||||
const SettingsHome = () => {
|
||||
const { navigateToSettings } = useSettingsNavigation();
|
||||
const { clearSession } = useCoreState();
|
||||
const { clearSession, snapshot } = useCoreState();
|
||||
const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -24,6 +28,18 @@ const SettingsHome = () => {
|
||||
};
|
||||
|
||||
const clearAllAppData = async () => {
|
||||
const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null;
|
||||
|
||||
// Queue the current user-scoped CEF profile for deletion on next launch.
|
||||
// The active CEF browser process may still hold SQLite/cache file handles,
|
||||
// so we delete after the shell restarts rather than relying on in-process
|
||||
// removal to succeed everywhere.
|
||||
try {
|
||||
await scheduleCefProfilePurge(currentUserId);
|
||||
} catch (err) {
|
||||
console.warn('[Settings] Failed to queue CEF profile purge:', err);
|
||||
}
|
||||
|
||||
// 1. Logout — clear session in core (auth_clear_session). Best-effort:
|
||||
// if the core process is wedged we still want to wipe local data.
|
||||
try {
|
||||
@@ -55,11 +71,9 @@ const SettingsHome = () => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
|
||||
// 4. Hard-reload to reset in-memory Redux + React state. Without this
|
||||
// the live store still holds user/socket/account data even though
|
||||
// the persisted copy is gone — the UI would look logged-in until
|
||||
// the user manually refreshes.
|
||||
window.location.reload();
|
||||
// 4. Full app restart so the CEF runtime reboots into the fresh
|
||||
// pre-login profile instead of keeping the old browser process alive.
|
||||
await restartApp();
|
||||
};
|
||||
|
||||
const handleLogoutAndClearData = async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../services/coreStateApi';
|
||||
import {
|
||||
openhumanUpdateAnalyticsSettings,
|
||||
restartApp,
|
||||
setOnboardingCompleted,
|
||||
storeSession,
|
||||
syncMemoryClientToken,
|
||||
@@ -374,6 +375,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
|
||||
const storeSessionToken = useCallback(
|
||||
async (token: string, user?: object) => {
|
||||
const previousIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot);
|
||||
logoutGuardUntilRef.current = 0;
|
||||
await storeSession(token, user ?? {});
|
||||
try {
|
||||
@@ -386,6 +388,17 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
await refreshTeams().catch(err => {
|
||||
log('refreshTeams failed after session store: %O', sanitizeError(err));
|
||||
});
|
||||
|
||||
const nextIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot);
|
||||
if (nextIdentity && previousIdentity !== nextIdentity) {
|
||||
const mask = (s: string | null) =>
|
||||
s == null ? 'none' : s.length > 4 ? `****${s.slice(-4)}` : '****';
|
||||
console.debug('[core-state] user changed; restarting app to switch CEF profile', {
|
||||
previousIdentity: mask(previousIdentity),
|
||||
nextIdentity: mask(nextIdentity),
|
||||
});
|
||||
await restartApp();
|
||||
}
|
||||
},
|
||||
[refresh, refreshTeams]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
describe('tauriCommands/core', () => {
|
||||
const mockIsTauri = isTauri as Mock;
|
||||
const mockInvoke = invoke as Mock;
|
||||
let restartApp: typeof import('./core').restartApp;
|
||||
let scheduleCefProfilePurge: typeof import('./core').scheduleCefProfilePurge;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
const actual = await vi.importActual<typeof import('./core')>('./core');
|
||||
restartApp = actual.restartApp;
|
||||
scheduleCefProfilePurge = actual.scheduleCefProfilePurge;
|
||||
});
|
||||
|
||||
describe('restartApp', () => {
|
||||
test('no-ops when not in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
await restartApp();
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
expect(debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('restartApp: skipped — not running in Tauri')
|
||||
);
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
test('invokes restart_app when in Tauri', async () => {
|
||||
await restartApp();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('restart_app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleCefProfilePurge', () => {
|
||||
test('returns null and does not invoke when not in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
const out = await scheduleCefProfilePurge('x');
|
||||
|
||||
expect(out).toBeNull();
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
expect(debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('scheduleCefProfilePurge: skipped — not running in Tauri')
|
||||
);
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
test('invoke with userId null when argument is undefined', async () => {
|
||||
mockInvoke.mockResolvedValueOnce('/path');
|
||||
|
||||
const out = await scheduleCefProfilePurge();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: null });
|
||||
expect(out).toBe('/path');
|
||||
});
|
||||
|
||||
test('invoke with userId null when argument is null', async () => {
|
||||
mockInvoke.mockResolvedValueOnce('/path');
|
||||
|
||||
const out = await scheduleCefProfilePurge(null);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: null });
|
||||
expect(out).toBe('/path');
|
||||
});
|
||||
|
||||
test('invoke with userId string when provided', async () => {
|
||||
mockInvoke.mockResolvedValueOnce('/other');
|
||||
|
||||
const out = await scheduleCefProfilePurge('user-9');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: 'user-9' });
|
||||
expect(out).toBe('/other');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,32 @@ export async function restartCoreProcess(): Promise<void> {
|
||||
console.debug('[core] restartCoreProcess: done');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the desktop shell so CEF relaunches with updated profile paths.
|
||||
*/
|
||||
export async function restartApp(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
console.debug('[app] restartApp: skipped — not running in Tauri');
|
||||
return;
|
||||
}
|
||||
console.debug('[app] restartApp: invoking restart_app');
|
||||
await invoke<void>('restart_app');
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue deletion of a user-scoped CEF profile on the next app launch.
|
||||
*/
|
||||
export async function scheduleCefProfilePurge(userId?: string | null): Promise<string | null> {
|
||||
if (!isTauri()) {
|
||||
console.debug('[cef-profile] scheduleCefProfilePurge: skipped — not running in Tauri');
|
||||
return null;
|
||||
}
|
||||
console.debug('[cef-profile] scheduleCefProfilePurge: invoking schedule_cef_profile_purge', {
|
||||
hasUserId: userId != null,
|
||||
});
|
||||
return invoke<string>('schedule_cef_profile_purge', { userId: userId ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the running core sidecar is outdated compared to what the app expects.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user