diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 6f7e8c57a..427946391 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -50,8 +50,9 @@ const SettingsHome = () => { setError(null); const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; await clearAllAppData({ clearSession, userId: currentUserId }); // restarts the app - } catch (_error) { - setError(t('clearData.failed')); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message || t('clearData.failed')); } finally { setIsLoading(false); } diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index faadb8ffe..6834f2c8d 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -179,5 +179,35 @@ describe('SettingsHome', () => { expect(args).toMatchObject({ userId: null }); expect(typeof args.clearSession).toBe('function'); }); + + it('surfaces the core error message when clearAllAppData fails (Windows file-lock guidance)', async () => { + const user = userEvent.setup(); + mockClearAllAppData.mockRejectedValueOnce( + new Error( + 'Failed to remove C:\\Users\\me\\.openhuman because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again.' + ) + ); + renderSettingsHome(); + + await user.click(screen.getByText('Clear App Data').closest('button')!); + const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); + await user.click(confirmButtons[confirmButtons.length - 1]); + + expect( + await screen.findByText(/locked by another OpenHuman window or process/) + ).toBeInTheDocument(); + }); + + it('falls back to the translated message when the error has no message', async () => { + const user = userEvent.setup(); + mockClearAllAppData.mockRejectedValueOnce(new Error('')); + renderSettingsHome(); + + await user.click(screen.getByText('Clear App Data').closest('button')!); + const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); + await user.click(confirmButtons[confirmButtons.length - 1]); + + expect(await screen.findByText(/Failed to clear data and logout/)).toBeInTheDocument(); + }); }); }); diff --git a/app/src/pages/Welcome.tsx b/app/src/pages/Welcome.tsx index a26f7d909..5cb159e74 100644 --- a/app/src/pages/Welcome.tsx +++ b/app/src/pages/Welcome.tsx @@ -34,7 +34,9 @@ const Welcome = () => { } catch (err) { const message = err instanceof Error ? err.message : String(err); log('clearAllAppData failed: %s', message); - setResetError('Could not clear app data. Please quit and reopen OpenHuman, then try again.'); + setResetError( + message || 'Could not clear app data. Please quit and reopen OpenHuman, then try again.' + ); setIsClearingAppData(false); } }; diff --git a/app/src/pages/__tests__/Welcome.test.tsx b/app/src/pages/__tests__/Welcome.test.tsx index 900967921..a238e3e82 100644 --- a/app/src/pages/__tests__/Welcome.test.tsx +++ b/app/src/pages/__tests__/Welcome.test.tsx @@ -191,11 +191,22 @@ describe('Welcome — decryption-failure recovery action', () => { fireEvent.click(screen.getByRole('button', { name: /Clear app data & restart/ })); await waitFor(() => { - expect(screen.getByText(/Could not clear app data/)).toBeInTheDocument(); + expect(screen.getByText(/reset blew up/)).toBeInTheDocument(); }); // Button re-enabled so the user can retry. expect(screen.getByRole('button', { name: /Clear app data & restart/ })).not.toBeDisabled(); }); + + it('falls back to the generic message when the error has no message', async () => { + mockClearAllAppData.mockRejectedValueOnce(new Error('')); + + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /Clear app data & restart/ })); + + await waitFor(() => { + expect(screen.getByText(/Could not clear app data/)).toBeInTheDocument(); + }); + }); }); describe('Welcome — Select runtime button', () => { diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index e7575c373..4e8acf544 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -92,6 +92,42 @@ fn config_openhuman_dir(config: &Config) -> PathBuf { .map_or_else(|| PathBuf::from("."), PathBuf::from) } +fn is_windows_file_lock_error(error: &std::io::Error) -> bool { + cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)) +} + +fn reset_local_data_remove_error(path: &Path, error: &std::io::Error) -> String { + if is_windows_file_lock_error(error) { + tracing::warn!( + path = %path.display(), + error = %error, + "[config] reset_local_data: Windows file lock blocked local data deletion" + ); + return format!( + "Failed to remove {} because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again. ({error})", + path.display() + ); + } + + format!("Failed to remove {}: {error}", path.display()) +} + +fn reset_local_data_marker_remove_error(path: &Path, error: &std::io::Error) -> String { + if is_windows_file_lock_error(error) { + tracing::warn!( + marker = %path.display(), + error = %error, + "[config] reset_local_data: Windows file lock blocked active workspace marker deletion" + ); + return format!( + "Failed to remove active workspace marker {} because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again. ({error})", + path.display() + ); + } + + format!("Failed to remove active workspace marker: {error}") +} + /// Internal helper to reset local data by removing specific directories and markers. async fn reset_local_data_for_paths( current_openhuman_dir: &Path, @@ -108,9 +144,12 @@ async fn reset_local_data_for_paths( let mut removed_paths = Vec::new(); if active_workspace_marker.exists() { - tokio::fs::remove_file(&active_workspace_marker) - .await - .map_err(|e| format!("Failed to remove active workspace marker: {e}"))?; + if let Err(error) = tokio::fs::remove_file(&active_workspace_marker).await { + return Err(reset_local_data_marker_remove_error( + &active_workspace_marker, + &error, + )); + } tracing::debug!( marker = %active_workspace_marker.display(), "[config] reset_local_data: removed active workspace marker" @@ -127,9 +166,9 @@ async fn reset_local_data_for_paths( continue; } - tokio::fs::remove_dir_all(target_dir) - .await - .map_err(|e| format!("Failed to remove {}: {e}", target_dir.display()))?; + if let Err(error) = tokio::fs::remove_dir_all(target_dir).await { + return Err(reset_local_data_remove_error(target_dir, &error)); + } tracing::debug!( dir = %target_dir.display(), "[config] reset_local_data: removed directory" diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index a385baef7..834c76ace 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -112,6 +112,28 @@ fn config_openhuman_dir_returns_config_path_parent() { assert_eq!(config_openhuman_dir(&cfg), PathBuf::from("/tmp/xyz")); } +#[cfg(windows)] +#[test] +fn reset_local_data_remove_error_explains_windows_file_locks() { + let err = std::io::Error::from_raw_os_error(32); + let msg = + reset_local_data_remove_error(std::path::Path::new("C:\\Users\\me\\.openhuman"), &err); + + assert!(msg.contains("locked by another OpenHuman window or process")); + assert!(msg.contains("Close all OpenHuman windows and try again")); +} + +#[cfg(windows)] +#[test] +fn reset_local_data_remove_error_explains_windows_lock_violation() { + let err = std::io::Error::from_raw_os_error(33); + let msg = + reset_local_data_remove_error(std::path::Path::new("C:\\Users\\me\\.openhuman"), &err); + + assert!(msg.contains("locked by another OpenHuman window or process")); + assert!(msg.contains("Close all OpenHuman windows and try again")); +} + // ── get_runtime_flags / set_browser_allow_all ───────────────── #[test]