Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b8b068e
feat(store): add resetUserScopedState action (#900)
oxoxDev Apr 28, 2026
b101bc7
feat(store): reset all user-scoped slices on identity flip (#900)
oxoxDev Apr 28, 2026
ba68f63
fix(core-state): purge persist + reset Redux + drop socket on identit…
oxoxDev Apr 28, 2026
f571230
test(core-state): cover identity-flip cleanup paths (#900)
oxoxDev Apr 28, 2026
054080b
feat(store): user-scoped redux-persist storage namespace per active u…
oxoxDev Apr 28, 2026
5d9d081
fix(core-state): re-point persist namespace on identity flip; never p…
oxoxDev Apr 28, 2026
0279aae
feat(store): migrate legacy unscoped persist:* keys into user namespa…
oxoxDev Apr 28, 2026
1c59d57
fix(core-state): restart on different-user re-login via signed-out wi…
oxoxDev Apr 28, 2026
e868272
fix(core-state): unify flip detection on lastRef !== nextId regardles…
oxoxDev Apr 28, 2026
4ba4f6d
fix(core-state): force restart on cold-bootstrap so first user CEF pr…
oxoxDev Apr 28, 2026
d996009
fix(cef-profile): purge stale pre-login `local` CEF cache on launch w…
oxoxDev Apr 28, 2026
992eb4b
fix(perms): allow restart_app + get_active_user_id + schedule_cef_pro…
oxoxDev Apr 28, 2026
1ecc5c4
fix(webview-accounts): use tauri::async_runtime::spawn for teardown (…
oxoxDev Apr 28, 2026
c6c6f7d
refactor(cef-profile): expose default_root_openhuman_dir + read_activ…
oxoxDev Apr 28, 2026
3a564bb
feat(app): add get_active_user_id Tauri command (#900)
oxoxDev Apr 28, 2026
63ff865
fix(window-state): persist + restore main window across app.restart()…
oxoxDev Apr 28, 2026
54d19a3
feat(tauri-commands): getActiveUserIdFromCore wrapper (#900)
oxoxDev Apr 28, 2026
40ffd65
feat(store): gate userScopedStorage on boot prime (#900)
oxoxDev Apr 28, 2026
2ff7039
fix(boot): prime userScopedStorage from Rust before render (#900)
oxoxDev Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/src-tauri/permissions/allow-core-process.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ description = "Core RPC URL, sidecar restart, dictation hotkey, webview-account,
allow = [
"core_rpc_url",
"restart_core_process",
# `restart_app` triggers `app.restart()` so CEF re-initializes against
# the active user's `users/<id>/cef` profile after an identity flip
# (#900). Without this allow entry, the invoke is silently denied by
# Tauri capabilities and webviews keep the prior user's third-party
# cookies.
"restart_app",
"schedule_cef_profile_purge",
# `get_active_user_id` reads `~/.openhuman/active_user.toml` so the
# frontend can prime `userScopedStorage` from the Rust source of truth
# BEFORE redux-persist hydrates — the prior `localStorage`-only seed
# was bound to the per-user CEF profile dir and went stale across
# restart-driven flips, causing a false re-flip and restart loop on
# every login. (#900)
"get_active_user_id",
"service_install_direct",
"service_start_direct",
"service_stop_direct",
Expand Down
31 changes: 29 additions & 2 deletions app/src-tauri/src/cef_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fn default_root_dir_name() -> &'static str {
}
}

fn default_root_openhuman_dir() -> Result<PathBuf, String> {
pub 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() {
Expand All @@ -50,7 +50,7 @@ fn default_root_openhuman_dir() -> Result<PathBuf, String> {
Ok(home.join(default_root_dir_name()))
}

fn read_active_user_id(default_openhuman_dir: &Path) -> Option<String> {
pub 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()?;
Expand Down Expand Up @@ -297,6 +297,33 @@ pub fn prepare_process_cache_path() -> Result<PathBuf, String> {
user_id,
cache_dir.display()
);

// When a real user is active, the pre-login `users/local/cef` bucket is
// stale third-party state captured during cold-bootstrap (before
// `active_user.toml` existed) — e.g. a Slack/WhatsApp tile added on a
// fresh install while the process was still running on the `local`
// fallback path. If we don't sweep it, those cookies leak into the
// first user's session via webview pre-warm and across users when the
// pre-login bucket is reused on subsequent fresh installs. Drop it
// synchronously here, before CEF init, so it's safe to delete. (#900)
if user_id != PRE_LOGIN_USER_ID {
if let Ok(local_cef) = cache_dir_for_user(&default_openhuman_dir, PRE_LOGIN_USER_ID) {
if local_cef.exists() {
match std::fs::remove_dir_all(&local_cef) {
Ok(()) => log::info!(
"[cef-profile] purged stale pre-login CEF cache path={}",
local_cef.display()
),
Err(error) => log::warn!(
"[cef-profile] failed to purge stale pre-login CEF cache path={} error={}",
local_cef.display(),
error
),
}
}
}
}

Ok(cache_dir)
}

Expand Down
49 changes: 49 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod telegram_scanner;
mod webview_accounts;
mod webview_apis;
mod whatsapp_scanner;
mod window_state;

use std::sync::Mutex;

Expand Down Expand Up @@ -318,9 +319,38 @@ async fn restart_core_process(
#[tauri::command]
async fn restart_app(app: tauri::AppHandle<AppRuntime>) -> Result<(), String> {
log::info!("[app] restart_app invoked from frontend");
// Persist main-window geometry and hide the window before exit so
// the macOS WindowServer doesn't briefly black-out the desktop layer
// on the (now defunct) display when the focused app dies, and so
// the new process can land its window on the same display+position
// the user had it on. (#900 secondary fixes)
if let Some(window) = app.get_webview_window("main") {
window_state::save_main(&window);
if let Err(err) = window.hide() {
log::warn!("[app] hide main window before restart failed: {err}");
}
}
app.restart();
}

/// Read the authoritative active user id from `active_user.toml` so the
/// frontend can seed `userScopedStorage` BEFORE redux-persist hydrates.
///
/// The previous frontend-only seed (a `localStorage` key) was bound to the
/// per-user CEF profile dir, so on every restart-driven user flip the new
/// process read whatever value the new profile's `localStorage` happened to
/// hold from a prior session — usually stale, triggering a false re-flip and
/// a restart loop. The Rust core writes `active_user.toml` atomically as part
/// of `auth_store_session`, so it's the only profile-independent source of
/// truth available to the UI at boot. Reuses
/// `cef_profile::default_root_openhuman_dir()` so the lookup honors
/// `OPENHUMAN_WORKSPACE` overrides used in test harnesses. (#900)
#[tauri::command]
fn get_active_user_id() -> Result<Option<String>, String> {
let dir = cef_profile::default_root_openhuman_dir()?;
Ok(cef_profile::read_active_user_id(&dir))
}

#[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())?;
Expand Down Expand Up @@ -962,6 +992,24 @@ pub fn run() {
}
});

// Restore last-known window position+size before showing the
// window so the user's first paint after a restart-driven flow
// (#900 identity flip) lands on the same display they used,
// not back at the default centered initial size on the
// primary monitor. `tauri.conf.json` ships `visible: false`
// / `center: false` for the main window so the placement
// happens before the first paint and there's no jump.
if let Some(window) = app.get_webview_window("main") {
if !window_state::restore_main(&window) {
window_state::center_main(&window);
}
if !daemon_mode {
if let Err(err) = window.show() {
log::warn!("[window-state] show main window failed: {err}");
}
}
}

if daemon_mode {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
Expand Down Expand Up @@ -1299,6 +1347,7 @@ pub fn run() {
apply_app_update,
restart_core_process,
restart_app,
get_active_user_id,
schedule_cef_profile_purge,
service_install_direct,
service_start_direct,
Expand Down
8 changes: 4 additions & 4 deletions app/src-tauri/src/webview_accounts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,27 +612,27 @@ fn teardown_account_scanners<R: Runtime>(app: &AppHandle<R>, account_id: &str) {
{
let registry = registry.inner().clone();
let acct = account_id.to_string();
tokio::spawn(async move { registry.forget(&acct).await });
tauri::async_runtime::spawn(async move { registry.forget(&acct).await });
}
if let Some(registry) = app.try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
{
let registry = registry.inner().clone();
let acct = account_id.to_string();
tokio::spawn(async move { registry.forget(&acct).await });
tauri::async_runtime::spawn(async move { registry.forget(&acct).await });
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
{
let registry = registry.inner().clone();
let acct = account_id.to_string();
tokio::spawn(async move { registry.forget(&acct).await });
tauri::async_runtime::spawn(async move { registry.forget(&acct).await });
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
{
let registry = registry.inner().clone();
let acct = account_id.to_string();
tokio::spawn(async move { registry.forget(&acct).await });
tauri::async_runtime::spawn(async move { registry.forget(&acct).await });
}
}

Expand Down
192 changes: 192 additions & 0 deletions app/src-tauri/src/window_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//! Persistence of main-window position + size across restarts.
//!
//! `app.restart()` (used by #900's identity-flip flow) spawns a fresh
//! process, so the new window doesn't inherit anything from the old one.
//! Without us re-applying state, every login-driven respawn snaps the
//! window back to the default initial size in the center of the primary
//! display — even when the user had it on an external monitor or had
//! resized it.
//!
//! This module persists a tiny TOML record at
//! `<openhuman_dir>/window_state.toml` capturing the outer position and
//! outer size of the main window in physical pixels. On launch the
//! record is read and applied before the window is shown. On restart we
//! save first, hide the window, then call `app.restart()`.
//!
//! Saved state is best-effort: read errors, missing file, off-screen
//! positions, and non-existent monitors all fall back to the default
//! centered window so we never trap the window where the user can't
//! reach it.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use tauri::{PhysicalPosition, PhysicalSize, Runtime, WebviewWindow};

use crate::cef_profile;

const STATE_FILE: &str = "window_state.toml";

#[derive(Debug, Clone, Serialize, Deserialize)]
struct WindowState {
x: i32,
y: i32,
width: u32,
height: u32,
}

fn state_path() -> Option<PathBuf> {
cef_profile::default_root_openhuman_dir()
.ok()
.map(|root| root.join(STATE_FILE))
}

/// Capture the main window's outer geometry and write it to disk.
///
/// Called from `restart_app` immediately before `app.restart()` so the
/// next process can land the new window where the user left it.
pub fn save_main<R: Runtime>(window: &WebviewWindow<R>) {
let Ok(pos) = window.outer_position() else {
log::warn!("[window-state] outer_position unavailable; skip save");
return;
};
let Ok(size) = window.outer_size() else {
log::warn!("[window-state] outer_size unavailable; skip save");
return;
};
let state = WindowState {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
};
let Some(path) = state_path() else {
log::warn!("[window-state] no path available; skip save");
return;
};
if let Some(parent) = path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
log::warn!(
"[window-state] mkdir {} failed: {}; skip save",
parent.display(),
err
);
return;
}
}
let raw = match toml::to_string_pretty(&state) {
Ok(r) => r,
Err(err) => {
log::warn!("[window-state] serialize failed: {err}; skip save");
return;
}
};
if let Err(err) = std::fs::write(&path, raw) {
log::warn!("[window-state] write {} failed: {err}", path.display());
} else {
log::info!(
"[window-state] saved geometry x={} y={} w={} h={}",
state.x,
state.y,
state.width,
state.height
);
}
}

/// Read the saved geometry (if any) and apply it to the main window.
///
/// Returns `true` when saved geometry was applied. Returns `false` when
/// no saved file exists, the file is malformed, or the saved position
/// falls outside every currently-attached monitor (e.g. the user
/// undocked an external display); the caller is then expected to fall
/// back to a centered default so we never strand the window off-screen.
pub fn restore_main<R: Runtime>(window: &WebviewWindow<R>) -> bool {
let Some(path) = state_path() else {
return false;
};
let Ok(raw) = std::fs::read_to_string(&path) else {
return false;
};
let state: WindowState = match toml::from_str(&raw) {
Ok(s) => s,
Err(err) => {
log::warn!(
"[window-state] parse {} failed: {err}; using default placement",
path.display()
);
return false;
}
};

if !position_visible_on_any_monitor(window, state.x, state.y, state.width, state.height) {
log::info!(
"[window-state] saved position x={} y={} not on any monitor; falling back to centered default",
state.x,
state.y
);
return false;
}

if let Err(err) = window.set_size(PhysicalSize::new(state.width, state.height)) {
log::warn!("[window-state] set_size failed: {err}");
}
if let Err(err) = window.set_position(PhysicalPosition::new(state.x, state.y)) {
log::warn!("[window-state] set_position failed: {err}");
return false;
}
log::info!(
"[window-state] restored geometry x={} y={} w={} h={}",
state.x,
state.y,
state.width,
state.height
);
true
}

/// Center the main window on the primary display (or its current monitor
/// if `current_monitor` resolves) when no saved state applied.
pub fn center_main<R: Runtime>(window: &WebviewWindow<R>) {
let Ok(Some(monitor)) = window
.primary_monitor()
.or_else(|_| window.current_monitor())
else {
let _ = window.center();
return;
Comment on lines +151 to +156
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n app/src-tauri/src/window_state.rs | head -170 | tail -30

Repository: tinyhumansai/openhuman

Length of output: 1195


🏁 Script executed:

cat -n app/src-tauri/src/window_state.rs | head -200 | tail -60

Repository: tinyhumansai/openhuman

Length of output: 2487


🏁 Script executed:

rg -n "primary_monitor|current_monitor" app/src-tauri/src/window_state.rs -B 2 -A 5

Repository: tinyhumansai/openhuman

Length of output: 535


🏁 Script executed:

cat -n app/src-tauri/src/window_state.rs | head -10

Repository: tinyhumansai/openhuman

Length of output: 630


current_monitor() is skipped when primary_monitor() returns Ok(None).

The documentation states the intent is "primary display (or its current monitor if current_monitor resolves)", but Result::or_else only executes on Err, not Ok(None). When primary_monitor() returns Ok(None), the pattern match fails immediately without attempting current_monitor(). The behavior should match the documented intent by explicitly handling the Ok(None) case.

Suggested fix
-    let Ok(Some(monitor)) = window
-        .primary_monitor()
-        .or_else(|_| window.current_monitor())
-    else {
+    let monitor = match window.primary_monitor() {
+        Ok(Some(monitor)) => Some(monitor),
+        Ok(None) | Err(_) => window.current_monitor().ok().flatten(),
+    };
+    let Some(monitor) = monitor else {
         let _ = window.center();
         return;
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let Ok(Some(monitor)) = window
.primary_monitor()
.or_else(|_| window.current_monitor())
else {
let _ = window.center();
return;
let monitor = match window.primary_monitor() {
Ok(Some(monitor)) => Some(monitor),
Ok(None) | Err(_) => window.current_monitor().ok().flatten(),
};
let Some(monitor) = monitor else {
let _ = window.center();
return;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/window_state.rs` around lines 151 - 156, The current
pattern using window.primary_monitor().or_else(...) incorrectly skips calling
window.current_monitor() when primary_monitor() returns Ok(None); update the
logic to explicitly handle Ok(Some), Ok(None), and Err for primary_monitor(): if
Ok(Some(monitor)) use it; if Ok(None) or Err(_) then call
window.current_monitor() and use its Ok(Some) result; if that also yields None
or Err, then call window.center() and return. Locate this change around the let
Ok(Some(monitor)) = ... else { ... } block and replace it with an explicit
match/if-chain that attempts current_monitor() when primary_monitor() returns
Ok(None).

};
let Ok(size) = window.outer_size() else {
let _ = window.center();
return;
};
let mon_pos = monitor.position();
let mon_size = monitor.size();
let x = mon_pos.x + (mon_size.width as i32 - size.width as i32) / 2;
let y = mon_pos.y + (mon_size.height as i32 - size.height as i32) / 2;
let _ = window.set_position(PhysicalPosition::new(x, y));
}

fn position_visible_on_any_monitor<R: Runtime>(
window: &WebviewWindow<R>,
x: i32,
y: i32,
width: u32,
height: u32,
) -> bool {
let Ok(monitors) = window.available_monitors() else {
return false;
};
// Treat the window as on-screen if at least a 100x100 px patch of it
// overlaps any attached monitor.
let win_right = x.saturating_add(width as i32);
let win_bottom = y.saturating_add(height as i32);
monitors.iter().any(|m| {
let pos = m.position();
let size = m.size();
let mon_right = pos.x.saturating_add(size.width as i32);
let mon_bottom = pos.y.saturating_add(size.height as i32);
let overlap_w = (win_right.min(mon_right) - x.max(pos.x)).max(0);
let overlap_h = (win_bottom.min(mon_bottom) - y.max(pos.y)).max(0);
overlap_w >= 100 && overlap_h >= 100
})
}
4 changes: 2 additions & 2 deletions app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
"title": "OpenHuman",
"width": 1000,
"height": 800,
"visible": true,
"visible": false,
"decorations": true,
"resizable": true,
"center": true
"center": false
}
],
"security": {
Expand Down
Loading
Loading