From 6252f7a663596add41b75c953fea29b22e4e3c75 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Sat, 5 Sep 2026 12:06:52 -0300 Subject: [PATCH 01/22] Fix Windows notifications and multi-account startup --- settings-ui/index.html | 6 ++ settings-ui/main.js | 71 ++++++++++++--------- settings-ui/style.css | 12 +++- src-tauri/capabilities/lock.json | 2 +- src-tauri/capabilities/main-remote.json | 2 +- src-tauri/capabilities/settings.json | 2 +- src-tauri/permissions/app.toml | 22 +++++++ src-tauri/resources/bridge.js | 7 +- src-tauri/resources/notifications.test.mjs | 71 +++++++++++++++++++++ src-tauri/src/accounts.rs | 74 +++++++++++++++++++++- src-tauri/src/aumid.rs | 1 - src-tauri/src/commands.rs | 55 ++++++++++++++-- src-tauri/src/lib.rs | 65 ++++++++++++++----- src-tauri/src/lock.rs | 17 +++++ src-tauri/src/settings.rs | 19 ++++-- src-tauri/src/window.rs | 36 ++++++++++- 16 files changed, 393 insertions(+), 69 deletions(-) create mode 100644 src-tauri/permissions/app.toml create mode 100644 src-tauri/resources/notifications.test.mjs diff --git a/settings-ui/index.html b/settings-ui/index.html index b672469..c1116f8 100644 --- a/settings-ui/index.html +++ b/settings-ui/index.html @@ -12,11 +12,17 @@

whatRust Settings

Accounts

+ +

This account opens first. Other accounts stay connected in the background.

+


diff --git a/settings-ui/main.js b/settings-ui/main.js index 9b86d17..8e39172 100644 --- a/settings-ui/main.js +++ b/settings-ui/main.js @@ -46,11 +46,35 @@ async function save() { } // --- Accounts --- +let accountsBusy = false; + +async function accountAction(action) { + if (accountsBusy) return; + accountsBusy = true; + document.querySelectorAll('#accounts-list button, .add-row input, .add-row button, #startup_account') + .forEach(el => { el.disabled = true; }); + const note = document.getElementById('accounts-note'); + note.textContent = 'Working…'; + try { + await action(); + note.textContent = ''; + } catch (error) { + note.textContent = String(error); + } finally { + accountsBusy = false; + document.getElementById('new_account_name').disabled = false; + document.getElementById('add_account').disabled = false; + document.getElementById('startup_account').disabled = false; + await loadAccounts(); + } +} function renderAccounts(accounts) { const list = document.getElementById("accounts-list"); list.textContent = ""; const canRemove = accounts.length > 1; + const startup = document.getElementById('startup_account'); + startup.textContent = ''; for (const a of accounts) { const row = document.createElement("div"); @@ -60,6 +84,11 @@ function renderAccounts(accounts) { name.className = "acct-name"; name.textContent = a.name; row.appendChild(name); + const option = document.createElement('option'); + option.value = a.id; + option.textContent = a.name; + option.selected = a.is_default; + startup.appendChild(option); if (a.unread > 0) { const badge = document.createElement("span"); @@ -72,7 +101,7 @@ function renderAccounts(accounts) { openBtn.className = "secondary"; openBtn.textContent = "Open"; openBtn.addEventListener("click", async () => { - await invoke("open_account", { id: a.id }); + await accountAction(() => invoke("open_account", { id: a.id })); }); row.appendChild(openBtn); @@ -87,12 +116,7 @@ function renderAccounts(accounts) { requiredMessage: "An account needs a name.", }); if (next === null || next === a.name) return; - try { - await invoke("rename_account", { id: a.id, name: next }); - } catch (e) { - await Dlg.alert(String(e), { title: "Could not rename" }); - } - await loadAccounts(); + await accountAction(() => invoke("rename_account", { id: a.id, name: next })); }); row.appendChild(renameBtn); @@ -107,12 +131,7 @@ function renderAccounts(accounts) { danger: true, }); if (!ok) return; - try { - await invoke("remove_account", { id: a.id }); - } catch (e) { - await Dlg.alert(String(e), { title: "Could not remove" }); - } - await loadAccounts(); + await accountAction(() => invoke("remove_account", { id: a.id })); }); row.appendChild(removeBtn); @@ -125,32 +144,18 @@ async function loadAccounts() { const accounts = await invoke("list_accounts"); renderAccounts(accounts); } catch (e) { - // ignore — listing failed + document.getElementById('accounts-note').textContent = 'Could not load accounts: ' + String(e); } } async function addAccount() { const input = document.getElementById("new_account_name"); const name = input.value.trim(); - if (!name) return; - try { + if (!name || accountsBusy) return; + await accountAction(async () => { await invoke("add_account", { name }); input.value = ""; - await loadAccounts(); - } catch (e) { - const msg = String(e); - // Only the macOS < 14 case is permanent — disable Add and surface the note. - // Other failures (disk write, window build) are transient: report and let the user retry. - if (msg.includes("macOS 14")) { - const note = document.getElementById("macos-note"); - note.textContent = msg; - note.hidden = false; - document.getElementById("add_account").disabled = true; - document.getElementById("new_account_name").disabled = true; - } else { - await Dlg.alert(msg, { title: "Could not add account" }); - } - } + }); } window.addEventListener("DOMContentLoaded", () => { @@ -160,6 +165,10 @@ window.addEventListener("DOMContentLoaded", () => { wireLock(); document.getElementById("save").addEventListener("click", save); document.getElementById("add_account").addEventListener("click", addAccount); + document.getElementById('startup_account').addEventListener('change', event => { + const id = event.target.value; + accountAction(() => invoke('set_default_account', { id })); + }); document.getElementById("new_account_name").addEventListener("keydown", (e) => { if (e.key === "Enter") addAccount(); }); diff --git a/settings-ui/style.css b/settings-ui/style.css index 10d7031..042c2fc 100644 --- a/settings-ui/style.css +++ b/settings-ui/style.css @@ -1,7 +1,15 @@ :root { color-scheme: light dark; --green: #25d366; + --option-bg: #fff; + --option-fg: #171717; } +@media (prefers-color-scheme: dark) { + :root { --option-bg: #202020; --option-fg: #f5f5f5; } +} +/* WebView2's native popup can use a light surface even when select inherits + white text. Specify BOTH colors so every option stays readable without hover. */ +.field select option { background-color: var(--option-bg); color: var(--option-fg); } * { box-sizing: border-box; } body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; @@ -44,10 +52,10 @@ h2 { font-size: 14px; margin: 0 0 10px; font-weight: 600; } .divider { border: 0; border-top: 1px solid #8883; margin: 18px 0; } #accounts-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } .account-row { - display: flex; align-items: center; gap: 8px; + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #8884; border-radius: 8px; } -.account-row .acct-name { flex: 1 1 auto; font-size: 13px; } +.account-row .acct-name { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; font-size: 13px; } .account-row .acct-unread { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--green); color: #042f1a; font-size: 11px; font-weight: 700; diff --git a/src-tauri/capabilities/lock.json b/src-tauri/capabilities/lock.json index ae6d562..c621574 100644 --- a/src-tauri/capabilities/lock.json +++ b/src-tauri/capabilities/lock.json @@ -3,5 +3,5 @@ "identifier": "lock-local", "description": "IPC for the app-lock screen. core:default for invoke; no remote, no account/notification permissions. Per-command authorization is enforced in Rust (is_lock_window).", "windows": ["lock"], - "permissions": ["core:default"] + "permissions": ["core:default", "allow-lock-commands"] } diff --git a/src-tauri/capabilities/main-remote.json b/src-tauri/capabilities/main-remote.json index cab9942..e176657 100644 --- a/src-tauri/capabilities/main-remote.json +++ b/src-tauri/capabilities/main-remote.json @@ -6,5 +6,5 @@ "remote": { "urls": ["https://web.whatsapp.com/*"] }, - "permissions": ["notification:default"] + "permissions": ["allow-whatsapp-bridge"] } diff --git a/src-tauri/capabilities/settings.json b/src-tauri/capabilities/settings.json index a18a0c2..9164e06 100644 --- a/src-tauri/capabilities/settings.json +++ b/src-tauri/capabilities/settings.json @@ -3,5 +3,5 @@ "identifier": "settings-local", "description": "Local IPC for the settings window.", "windows": ["settings"], - "permissions": ["core:default"] + "permissions": ["core:default", "allow-settings-commands"] } diff --git a/src-tauri/permissions/app.toml b/src-tauri/permissions/app.toml new file mode 100644 index 0000000..7037939 --- /dev/null +++ b/src-tauri/permissions/app.toml @@ -0,0 +1,22 @@ +# Custom commands need their own ACL entries; notification:default only grants +# plugin:notification commands. Defining this manifest also opts LOCAL windows +# into ACL checks, so preserve their separate settings and unlock permissions. +[[permission]] +identifier = "allow-whatsapp-bridge" +description = "Forward notifications, unread titles and diagnostics from WhatsApp account windows." +commands.allow = ["notify", "set_unread", "dlog"] + +[[permission]] +identifier = "allow-settings-commands" +description = "Manage settings and accounts from the bundled settings window." +commands.allow = [ + "get_settings", "set_settings", "open_settings", "list_accounts", + "add_account", "remove_account", "rename_account", "open_account", "set_default_account", + "get_lock_status", "set_app_lock_password", "change_app_lock_password", + "disable_app_lock", "set_app_lock_options", "set_biometric_enabled", "lock_app" +] + +[[permission]] +identifier = "allow-lock-commands" +description = "Read lock status and unlock or reset from the bundled lock screen." +commands.allow = ["get_lock_status", "unlock_password", "unlock_biometric", "reset_app_lock"] diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index f269d37..3313539 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -5,7 +5,12 @@ function invoke(cmd, args) { var t = window.__TAURI__; if (t && t.core && typeof t.core.invoke === "function") { - return t.core.invoke(cmd, args).catch(function () {}); + return t.core.invoke(cmd, args).catch(function () { + // Do not log args or error payloads: they can contain message content. + // In particular, an ACL rejection must not silently disable both + // notifications and the unread tray indicator. + console.error("whatRust bridge: IPC failed for " + cmd); + }); } return Promise.resolve(); } diff --git a/src-tauri/resources/notifications.test.mjs b/src-tauri/resources/notifications.test.mjs new file mode 100644 index 0000000..50f1b38 --- /dev/null +++ b/src-tauri/resources/notifications.test.mjs @@ -0,0 +1,71 @@ +// Run with: node --test src-tauri/resources/notifications.test.mjs +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { test } from 'node:test'; + +const source = readFileSync(new URL('./bridge.js', import.meta.url), 'utf8'); + +function harness({ reject = false } = {}) { + const calls = [], errors = [], intervals = []; + const document = { + title: '(3) WhatsApp', readyState: 'complete', + querySelector: () => null, querySelectorAll: () => [], + addEventListener() {}, + }; + const window = { + location: { origin: 'https://web.whatsapp.com' }, addEventListener() {}, + ServiceWorkerRegistration: class { + showNotification() {} + getNotifications() {} + }, + __TAURI__: { core: { invoke(cmd, args) { + calls.push({ cmd, args }); + return reject ? Promise.reject('private message payload') : Promise.resolve(); + } } }, + }; + runInNewContext(source, { + window, document, navigator: {}, + console: { error: message => errors.push(message), log() {} }, + MutationObserver: class { observe() {} }, + setInterval: callback => { intervals.push(callback); return 0; }, + setTimeout: () => 0, clearTimeout() {}, + }); + return { window, document, calls, errors, intervals }; +} + +test('page and service-worker registration notifications reach the custom Rust command', async () => { + const { window, calls } = harness(); + new window.Notification('test A', { body: 'body A' }); + await new window.ServiceWorkerRegistration().showNotification('test B', { body: 'body B' }); + const notifications = calls.filter(c => c.cmd === 'notify'); + assert.equal(notifications.length, 2); + assert.equal(notifications[0].args.title, 'test A'); + assert.equal(notifications[1].args.body, 'body B'); +}); + +test('the two notification paths do not duplicate the same alert', async () => { + const { window, calls } = harness(); + new window.Notification('same', { body: 'message' }); + await new window.ServiceWorkerRegistration().showNotification('same', { body: 'message' }); + assert.equal(calls.filter(c => c.cmd === 'notify').length, 1); +}); + +test('unread initialization, changes and clearing reach Rust without repeating unchanged titles', () => { + const { document, calls, intervals } = harness(); + const poll = intervals[0]; + poll(); + document.title = '(4) WhatsApp'; poll(); + document.title = 'WhatsApp'; poll(); poll(); + assert.deepEqual(calls.filter(c => c.cmd === 'set_unread').map(c => c.args.title), + ['(3) WhatsApp', '(4) WhatsApp', 'WhatsApp']); +}); + +test('IPC rejection is visible without logging private notification content', async () => { + const { window, errors } = harness({ reject: true }); + new window.Notification('private title', { body: 'private body' }); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(errors.includes('whatRust bridge: IPC failed for notify')); + assert.ok(errors.includes('whatRust bridge: IPC failed for set_unread')); + assert.ok(errors.every(message => !message.includes('private'))); +}); diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 013b4fd..61ab6be 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -8,6 +8,8 @@ use tauri::{AppHandle, Manager}; pub type UnreadMap = Mutex>; /// Window label of the last-focused account window (e.g. `wa-default`). Managed app state. pub type ActiveAccount = Mutex; +/// Serialize account mutations now that window-opening commands run off the UI thread. +pub static MUTATION: Mutex<()> = Mutex::new(()); /// A single WhatsApp account. /// @@ -32,6 +34,8 @@ pub struct Account { pub struct AccountsFile { pub accounts: Vec, pub next_seq: u32, + /// Preferred startup account; independent of the historical `default` profile id. + pub startup_account: Option, } impl Default for AccountsFile { @@ -44,6 +48,7 @@ impl Default for AccountsFile { store_uuid: None, }], next_seq: 1, + startup_account: None, } } } @@ -82,7 +87,27 @@ pub fn remove(f: &mut AccountsFile, id: &str) -> Result { let Some(pos) = f.accounts.iter().position(|a| a.id == id) else { return Err(format!("unknown account: {id}")); }; - Ok(f.accounts.remove(pos)) + let removed = f.accounts.remove(pos); + if f.startup_account.as_deref() == Some(id) { + f.startup_account = None; + } + Ok(removed) +} + +/// Older files and a removed preference fall back to the first account in UI order. +pub fn startup_account(f: &AccountsFile) -> Option<&Account> { + f.startup_account + .as_deref() + .and_then(|id| f.accounts.iter().find(|a| a.id == id)) + .or_else(|| f.accounts.iter().min_by_key(|a| a.order)) +} + +pub fn set_startup_account(f: &mut AccountsFile, id: &str) -> Result<(), String> { + if !f.accounts.iter().any(|a| a.id == id) { + return Err(format!("unknown account: {id}")); + } + f.startup_account = Some(id.to_owned()); + Ok(()) } /// Rename an account by id. Errors if the id is unknown. @@ -207,6 +232,53 @@ pub fn gen_store_uuid() -> [u8; 16] { mod tests { use super::*; + #[test] + fn startup_preference_survives_roundtrip_without_changing_profile_identity() { + let mut f = AccountsFile::default(); + let second = add(&mut f, "Work"); + set_startup_account(&mut f, &second.id).unwrap(); + let restored: AccountsFile = + serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap(); + assert_eq!(startup_account(&restored), Some(&second)); + assert_eq!(restored.accounts[0].id, "default"); + assert_eq!(restored.accounts[0].store_uuid, None); + } + + #[test] + fn missing_or_stale_startup_preference_uses_ui_order() { + let mut f = AccountsFile::default(); + add(&mut f, "Work"); + f.accounts.reverse(); + assert_eq!(startup_account(&f).unwrap().id, "default"); + f.startup_account = Some("removed-id".into()); + assert_eq!(startup_account(&f).unwrap().id, "default"); + } + + #[test] + fn removing_preferred_account_selects_remaining_account() { + let mut f = AccountsFile::default(); + let second = add(&mut f, "Work"); + set_startup_account(&mut f, &second.id).unwrap(); + remove(&mut f, &second.id).unwrap(); + assert_eq!(startup_account(&f).unwrap().id, "default"); + assert_eq!(f.startup_account, None); + } + + #[test] + fn startup_selection_rejects_unknown_id_without_mutating_state() { + let mut f = AccountsFile::default(); + let before = f.clone(); + assert!(set_startup_account(&mut f, "missing").is_err()); + assert_eq!(f, before); + } + + #[test] + fn no_startup_account_when_list_is_empty() { + let mut f = AccountsFile::default(); + f.accounts.clear(); + assert!(startup_account(&f).is_none()); + } + #[test] fn default_file_has_single_default_account() { let f = AccountsFile::default(); diff --git a/src-tauri/src/aumid.rs b/src-tauri/src/aumid.rs index 72b42f8..319b95e 100644 --- a/src-tauri/src/aumid.rs +++ b/src-tauri/src/aumid.rs @@ -27,7 +27,6 @@ use tauri::AppHandle; pub fn register(app: &AppHandle) { #[cfg(windows)] { - use tauri::Manager; let config = app.config(); let aumid = &config.identifier; let display = config.product_name.as_deref().unwrap_or("whatRust"); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ad9a5b3..280baca 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -134,6 +134,7 @@ pub struct AccountView { pub order: u32, pub unread: u32, pub open: bool, + pub is_default: bool, } fn account_views(app: &tauri::AppHandle) -> Vec { @@ -151,6 +152,7 @@ fn account_views(app: &tauri::AppHandle) -> Vec { open: app .get_webview_window(&accounts::window_label(&a.id)) .is_some(), + is_default: accounts::startup_account(&f).is_some_and(|selected| selected.id == a.id), }) .collect(); views.sort_by_key(|v| v.order); @@ -170,7 +172,7 @@ pub fn list_accounts( } #[tauri::command] -pub fn add_account( +pub async fn add_account( window: tauri::Window, app: tauri::AppHandle, name: String, @@ -187,11 +189,24 @@ pub fn add_account( return Err("account name cannot be empty".into()); } + // WebView2 construction must not run inside a synchronous IPC callback. + // Do not move this command back onto the UI thread (wry issue #583). + let _mutation = accounts::MUTATION + .lock() + .map_err(|_| "account operation failed")?; let mut f = accounts::load(&app); let acct = accounts::add(&mut f, name); accounts::save(&app, &f).map_err(|e| e.to_string())?; - crate::window::open_account_window(&app, &acct, false).map_err(|e| e.to_string())?; + if let Err(error) = crate::window::open_account_window(&app, &acct, true) { + // Keep the allocated sequence, but do not leave a phantom account after + // a failed window build. A retry must allocate a fresh profile id. + f.accounts.retain(|a| a.id != acct.id); + accounts::save(&app, &f) + .map_err(|rollback| format!("{error}; could not roll back account: {rollback}"))?; + return Err(error.to_string()); + } + crate::window::show_account(&app, &accounts::window_label(&acct.id)); crate::tray::rebuild_menu(&app); Ok(AccountView { @@ -200,11 +215,12 @@ pub fn add_account( order: acct.order, unread: 0, open: true, + is_default: false, }) } #[tauri::command] -pub fn remove_account( +pub async fn remove_account( window: tauri::Window, app: tauri::AppHandle, id: String, @@ -214,6 +230,9 @@ pub fn remove_account( } lock::require_unlocked(&app)?; + let _mutation = accounts::MUTATION + .lock() + .map_err(|_| "account operation failed")?; let mut f = accounts::load(&app); let removed = accounts::remove(&mut f, &id)?; accounts::save(&app, &f).map_err(|e| e.to_string())?; @@ -242,7 +261,7 @@ pub fn remove_account( } #[tauri::command] -pub fn rename_account( +pub async fn rename_account( window: tauri::Window, app: tauri::AppHandle, id: String, @@ -257,6 +276,9 @@ pub fn rename_account( return Err("account name cannot be empty".into()); } + let _mutation = accounts::MUTATION + .lock() + .map_err(|_| "account operation failed")?; let mut f = accounts::load(&app); accounts::rename(&mut f, &id, name)?; accounts::save(&app, &f).map_err(|e| e.to_string())?; @@ -269,7 +291,7 @@ pub fn rename_account( } #[tauri::command] -pub fn open_account( +pub async fn open_account( window: tauri::Window, app: tauri::AppHandle, id: String, @@ -278,6 +300,9 @@ pub fn open_account( return Err("forbidden".into()); } lock::require_unlocked(&app)?; + let _mutation = accounts::MUTATION + .lock() + .map_err(|_| "account operation failed")?; let f = accounts::load(&app); let Some(acct) = f.accounts.iter().find(|a| a.id == id) else { return Err(format!("unknown account: {id}")); @@ -287,7 +312,7 @@ pub fn open_account( .get_webview_window(&accounts::window_label(&acct.id)) .is_none() { - crate::window::open_account_window(&app, acct, false).map_err(|e| e.to_string())?; + crate::window::open_account_window(&app, acct, true).map_err(|e| e.to_string())?; } crate::window::show_account(&app, &accounts::window_label(&acct.id)); // Track as the active account. @@ -297,6 +322,24 @@ pub fn open_account( Ok(()) } +#[tauri::command] +pub async fn set_default_account( + window: tauri::Window, + app: tauri::AppHandle, + id: String, +) -> Result<(), String> { + if is_remote(&window) { + return Err("forbidden".into()); + } + lock::require_unlocked(&app)?; + let _mutation = accounts::MUTATION + .lock() + .map_err(|_| "account operation failed")?; + let mut f = accounts::load(&app); + accounts::set_startup_account(&mut f, &id)?; + accounts::save(&app, &f).map_err(|e| e.to_string()) +} + // --------------------------------------------------------------------------- // App-lock commands. // --------------------------------------------------------------------------- diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8253b77..0033b8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,27 +35,34 @@ pub fn run() { // single-instance MUST be registered first. #[cfg(desktop)] { - let mut single_instance = tauri_plugin_single_instance::Builder::::new() - .callback(|app, args, _cwd| { + let single_instance = tauri_plugin_single_instance::Builder::::new().callback( + |app, args, _cwd| { // `whatrust --toggle` (bind it to an OS keyboard shortcut — the reliable // global-hotkey path on Wayland, where in-process X11 grabs don't fire) // toggles the active window. Otherwise a 2nd launch raises it, except an // autostart relaunch carrying --minimized (stay hidden in the tray). // Both toggle_active and show_main defer to the lock screen when locked, // so neither can bypass the app lock. - if args.iter().any(|a| a == "--toggle") { + if args.iter().any(|a| a == "--settings") { + window::open_settings_window(app); + } else if args.iter().any(|a| a == "--toggle") { window::toggle_active(app); } else if !args.iter().any(|a| a == "--minimized") { window::show_main(app); } - }); + }, + ); // Flatpak only permits a sandbox to own D-Bus names under its exported // application ID. Native packages keep using the stable Tauri identifier. #[cfg(target_os = "linux")] - if let Some(id) = settings::flatpak_id() { - single_instance = single_instance.dbus_id(id); - } + let single_instance = { + let mut single_instance = single_instance; + if let Some(id) = settings::flatpak_id() { + single_instance = single_instance.dbus_id(id); + } + single_instance + }; builder = builder.plugin(single_instance.build()); } @@ -107,6 +114,7 @@ pub fn run() { commands::remove_account, commands::rename_account, commands::open_account, + commands::set_default_account, commands::get_lock_status, commands::set_app_lock_password, commands::change_app_lock_password, @@ -137,6 +145,7 @@ pub fn run() { // Load accounts (seeds a single `default` on first run / corrupt file). let mut f = accounts::load(handle); + dlog::log("startup: account configuration loaded"); // Backfill a persisted store_uuid for any non-default account missing one // (older state predating multi-account). Save only if something changed. @@ -157,18 +166,42 @@ pub fn run() { handle.manage(lock::LockState::new(!lock_on_launch)); let open_hidden = start_hidden || lock_on_launch; - // Open every account window so each one receives messages/notifications. - for a in &f.accounts { - window::open_account_window(handle, a, open_hidden)?; + let startup_label = + accounts::startup_account(&f).map(|account| accounts::window_label(&account.id)); + if let Some(label) = &startup_label { + *handle.state::().lock().unwrap() = label.clone(); } - tray::setup(handle)?; - tray::rebuild_menu(handle); - let _ = settings::apply(handle, &s); - - if lock_on_launch && !start_hidden { - lock::show_lock_window(handle); + // Build windows while Tauri is still on its UI thread. On Windows a + // WebView2 controller is created lazily for a hidden window, so the + // preferred account must be the one made visible here. This keeps + // every other account connected in the background and guarantees the + // selected startup account is the first usable window. + for account in &f.accounts { + let is_startup = startup_label + .as_deref() + .is_some_and(|label| label == accounts::window_label(&account.id)); + window::open_account_window(handle, account, open_hidden || !is_startup)?; } + dlog::log("startup: account windows built"); + tray::setup(handle)?; + // Let setup return before accessing tray/window APIs again. This + // avoids waiting on the Windows UI loop while it is being created. + let startup_handle = handle.clone(); + let open_settings = !start_hidden && args.iter().any(|a| a == "--settings"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + let ui_handle = startup_handle.clone(); + let _ = startup_handle.run_on_main_thread(move || { + tray::rebuild_menu(&ui_handle); + let _ = settings::apply(&ui_handle, &s); + if lock_on_launch && !start_hidden { + lock::show_lock_window(&ui_handle); + } else if open_settings { + window::open_settings_window(&ui_handle); + } + }); + }); // Idle auto-lock watcher. Always running; no-op unless the lock is active // with idle_secs > 0 and the app is currently unlocked. diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index 221665a..229977c 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -99,6 +99,17 @@ pub fn unlock(app: &AppHandle) { /// capture of the lock screen; `prevent_close` while locked stops the X from /// relaunching into an unlocked state. pub fn show_lock_window(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + static CREATE: Mutex<()> = Mutex::new(()); + let _guard = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + if !is_unlocked(&app) { + show_lock_window_inner(&app); + } + }); +} + +fn show_lock_window_inner(app: &AppHandle) { if let Some(w) = app.get_webview_window("lock") { let _ = w.show(); let _ = w.set_focus(); @@ -116,6 +127,12 @@ pub fn show_lock_window(app: &AppHandle) { .build(); if let Ok(win) = built { + // Unlock can race a queued window creation; never leave a stale lock + // screen on top after authentication has already succeeded. + if is_unlocked(app) { + let _ = win.destroy(); + return; + } let app_handle = app.clone(); win.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 28cec7c..d14a0f9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -157,11 +157,20 @@ pub fn apply(app: &AppHandle, s: &Settings) -> Option { if !flatpak_autostart_handled { use tauri_plugin_autostart::ManagerExt; let autostart = app.autolaunch(); - let result = if s.autostart { - autostart.enable() - } else { - autostart.disable() - }; + let result = + if s.autostart { + autostart.enable() + } else { + // Windows reports NotFound when deleting an absent Run value. + // Saving "off" when already off is a successful no-op. + autostart.is_enabled().and_then(|enabled| { + if enabled { + autostart.disable() + } else { + Ok(()) + } + }) + }; if let Err(e) = result { warnings.push(format!("autostart could not be updated: {e}")); } diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 7869be5..1a896e8 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -115,6 +115,7 @@ pub fn open_account_window( let builder = apply_isolation(builder, account, app); let win = builder.build()?; + crate::dlog::log("account window: WebView created"); // Close-to-tray (reads the live setting so the toggle takes effect without a restart). let app_handle = app.clone(); @@ -973,6 +974,10 @@ fn enable_media_windows(webview: tauri::webview::PlatformWebview) { /// Show + unminimize + focus an account window by its `wa-` label. pub fn show_account(app: &AppHandle, label: &str) { + if !crate::lock::is_unlocked(app) { + crate::lock::show_lock_window(app); + return; + } if let Some(w) = app.get_webview_window(label) { let _ = w.show(); let _ = w.unminimize(); @@ -1063,16 +1068,41 @@ pub fn toggle_active(app: &AppHandle) { /// Opens (or focuses) the local settings window. pub fn open_settings_window(app: &AppHandle) { + // Tray/menu callbacks can also deadlock WebView2 when they construct a + // window synchronously. Serialize creation on a blocking worker instead. + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + static CREATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + open_settings_window_inner(&app); + }); +} + +fn open_settings_window_inner(app: &AppHandle) { + if !crate::lock::is_unlocked(app) { + crate::lock::show_lock_window(app); + return; + } if let Some(w) = app.get_webview_window("settings") { let _ = w.show(); let _ = w.set_focus(); return; } - let _ = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("index.html".into())) + match WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("index.html".into())) .title("whatRust — Settings") .inner_size(440.0, 680.0) - .resizable(false) - .build(); + .min_inner_size(440.0, 520.0) + .resizable(true) + .visible(false) + .build() + { + Ok(w) if crate::lock::is_unlocked(app) => { + let _ = w.show(); + let _ = w.set_focus(); + } + Ok(_) => crate::lock::show_lock_window(app), + Err(e) => crate::dlog::log(&format!("settings window creation failed: {e}")), + } } #[cfg(test)] From 40b479f07636917c2766206b3d8007a11138e31a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Fri, 16 Oct 2026 15:57:47 -0300 Subject: [PATCH 02/22] Fix Windows build and account notifications --- src-tauri/src/aumid.rs | 2 +- src-tauri/src/commands.rs | 8 ++++++-- src-tauri/src/notify.rs | 16 +++++++--------- src-tauri/src/window.rs | 12 ++++++------ 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/aumid.rs b/src-tauri/src/aumid.rs index e430a7f..b633d4a 100644 --- a/src-tauri/src/aumid.rs +++ b/src-tauri/src/aumid.rs @@ -18,7 +18,7 @@ //! The AUMID is read from the live Tauri config `identifier`, i.e. the exact //! value `notify.rs` passes to `Toast::new`, so the two can never drift apart. -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; /// No-op on every platform except Windows. #[allow(unused_variables)] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a4b5e41..19915a6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -236,7 +236,10 @@ pub async fn switch_account( let Some(acct) = f.accounts.iter().find(|a| a.id == id) else { return Err("unknown account".into()); }; - if app.get_webview_window(&accounts::window_label(&acct.id)).is_none() { + if app + .get_webview_window(&accounts::window_label(&acct.id)) + .is_none() + { crate::window::open_account_window(&app, acct, false).map_err(|e| e.to_string())?; } crate::window::show_account(&app, &accounts::window_label(&acct.id)); @@ -307,13 +310,14 @@ pub async fn add_account( } crate::tray::rebuild_menu(&app); + let is_default = accounts::startup_account(&f).is_some_and(|startup| startup.id == acct.id); Ok(AccountView { id: acct.id, name: acct.name, order: acct.order, unread: 0, open: true, - is_default: accounts::startup_account(&f).is_some_and(|startup| startup.id == acct.id), + is_default, }) } diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 652f882..71f4bd3 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -134,13 +134,7 @@ pub fn from_account( } /// Account-attributed message toast; activation targets the exact account window. -fn show_account_click( - app: &AppHandle, - label: &str, - title: &str, - body: &str, - icon: Option<&Path>, -) { +fn show_account_click(app: &AppHandle, label: &str, title: &str, body: &str, icon: Option<&Path>) { #[cfg(windows)] windows_toast( app, @@ -248,9 +242,13 @@ fn windows_toast( let result = toast .on_activated(move |_| { let app = click.clone(); + let account_label = click_label.clone(); let _ = click.run_on_main_thread(move || { - if let Some(label) = click_label { crate::window::show_account(&app, &label); } - else { crate::window::show_main(&app); } + if let Some(label) = account_label { + crate::window::show_account(&app, &label); + } else { + crate::window::show_main(&app); + } }); Ok(()) }) diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index becca57..ef6238f 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -127,10 +127,9 @@ pub fn open_account_window( .file_name() .and_then(|n| n.to_str()) .unwrap_or("download"); - let Some(path) = choose_download_destination( - suggested, - webview.window().hwnd().ok(), - ) else { + let Some(path) = + choose_download_destination(suggested, webview.window().hwnd().ok()) + else { crate::dlog::log("download: save dialog cancelled"); return false; }; @@ -248,8 +247,8 @@ fn choose_download_destination( use std::os::windows::ffi::OsStringExt; use windows::core::{PCWSTR, PWSTR}; use windows::Win32::UI::Controls::Dialogs::{ - GetSaveFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_NOCHANGEDIR, OFN_OVERWRITEPROMPT, - OFN_PATHMUSTEXIST, + GetSaveFileNameW, OFN_EXPLORER, OFN_NOCHANGEDIR, OFN_OVERWRITEPROMPT, OFN_PATHMUSTEXIST, + OPENFILENAMEW, }; let mut buffer = vec![0u16; 32_768]; @@ -937,6 +936,7 @@ fn base64_encode(data: &[u8]) -> String { /// absolute path with a file name as-is; otherwise rebuild it as /// `/` (file name defaulting to "download", directory /// defaulting to the temp dir if the platform can't name a Downloads folder). +#[cfg(any(not(windows), test))] fn ensure_download_destination( destination: &mut std::path::PathBuf, download_dir: Option, From bfb489ba1ef335d5531fc31429d61328fe7f814b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Fri, 16 Oct 2026 16:35:13 -0300 Subject: [PATCH 03/22] Fix account switcher layout and package Windows installer --- src-tauri/resources/bridge.js | 52 +++++++++++++++++----- src-tauri/resources/bridge.test.mjs | 1 + src-tauri/resources/notifications.test.mjs | 10 ++--- src-tauri/tauri.conf.json | 24 ++++++++-- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index 6762025..709e2d0 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -23,42 +23,70 @@ // only account names/unread counts and a request to focus an existing account. function mountAccountSwitcher() { if (!document.body || document.getElementById("__whatrust_accounts")) return; + // Join the WhatsApp header's normal layout, so the control never covers + // the navigation icons or conversations. Wait until the header is mounted. + var header = document.querySelector("#side header, #app header"); + if (!header) return; var trigger = document.createElement("button"); trigger.id = "__whatrust_accounts"; trigger.type = "button"; - trigger.textContent = "Accounts"; - trigger.setAttribute("aria-label", "Switch WhatsApp account"); - trigger.style.cssText = "position:fixed;z-index:2147483000;left:12px;bottom:12px;border:0;border-radius:18px;padding:9px 14px;background:#075e54;color:white;font:600 13px system-ui;box-shadow:0 2px 10px #0005;cursor:pointer"; + trigger.textContent = "⇄"; + trigger.title = "Trocar conta do WhatsApp"; + trigger.setAttribute("aria-label", "Trocar conta do WhatsApp"); + trigger.setAttribute("aria-haspopup", "dialog"); + trigger.setAttribute("aria-expanded", "false"); + trigger.style.cssText = "position:static;display:inline-flex;align-items:center;justify-content:center;flex:0 0 36px;align-self:center;width:36px;height:36px;margin-inline-start:8px;padding:0;border:1px solid #00806966;border-radius:50%;background:#00806922;color:inherit;font:600 24px system-ui;line-height:1;cursor:pointer;outline-offset:3px"; + var oldDialog = document.getElementById("__whatrust_accounts_dialog"); + if (oldDialog) oldDialog.remove(); var dialog = document.createElement("dialog"); - dialog.setAttribute("aria-label", "WhatsApp accounts"); + dialog.id = "__whatrust_accounts_dialog"; + dialog.setAttribute("aria-label", "Contas do WhatsApp"); dialog.style.cssText = "border:0;border-radius:14px;padding:16px;min-width:260px;max-width:90vw;box-shadow:0 8px 36px #0006;font:14px system-ui;color:#222"; - var heading = document.createElement("h2"); heading.textContent = "WhatsApp accounts"; + var heading = document.createElement("h2"); heading.textContent = "Contas do WhatsApp"; heading.style.cssText = "font-size:16px;margin:0 0 10px"; var list = document.createElement("div"); list.setAttribute("role", "list"); - var close = document.createElement("button"); close.type = "button"; close.textContent = "Close"; + var close = document.createElement("button"); close.type = "button"; close.textContent = "Fechar"; close.style.cssText = "margin-top:12px;padding:7px 12px;border:0;border-radius:8px;cursor:pointer"; close.addEventListener("click", function () { dialog.close(); trigger.focus(); }); dialog.append(heading, list, close); dialog.addEventListener("click", function (e) { if (e.target === dialog) { dialog.close(); trigger.focus(); } }); - trigger.addEventListener("click", function () { + dialog.addEventListener("close", function () { + trigger.setAttribute("aria-expanded", "false"); + trigger.focus(); + }); + trigger.addEventListener("click", function (event) { + event.stopPropagation(); invoke("switcher_accounts").then(function (accounts) { if (!Array.isArray(accounts)) return; list.textContent = ""; accounts.forEach(function (account) { var item = document.createElement("button"); item.type = "button"; - item.setAttribute("role", "listitem"); - item.textContent = String(account.name || "WhatsApp") + (account.unread > 0 ? " · " + account.unread + " unread" : ""); - item.setAttribute("aria-label", String(account.name || "WhatsApp") + (account.unread > 0 ? ", " + account.unread + " unread" : "")); + var row = document.createElement("div"); + row.setAttribute("role", "listitem"); + item.textContent = String(account.name || "WhatsApp") + (account.unread > 0 ? " · " + account.unread + " não lidas" : ""); + item.setAttribute("aria-label", String(account.name || "WhatsApp") + (account.unread > 0 ? ", " + account.unread + " não lidas" : "")); item.style.cssText = "display:block;width:100%;text-align:left;padding:10px;border:0;border-radius:8px;background:white;color:#222;cursor:pointer"; item.addEventListener("click", function () { invoke("switch_account", { id: account.id }); dialog.close(); }); - list.appendChild(item); + row.appendChild(item); + list.appendChild(row); }); if (!dialog.open) dialog.showModal(); + trigger.setAttribute("aria-expanded", "true"); var first = list.querySelector("button"); if (first) first.focus(); else close.focus(); }); }); - document.body.append(trigger, dialog); + header.appendChild(trigger); + document.body.appendChild(dialog); + } + // React can replace the header when navigating or reconnecting. + var switcherObserver = new MutationObserver(mountAccountSwitcher); + function watchAccountSwitcher() { + if (!document.body) return; + switcherObserver.observe(document.body, { childList: true, subtree: true }); + mountAccountSwitcher(); } + if (document.body) watchAccountSwitcher(); + else document.addEventListener("DOMContentLoaded", watchAccountSwitcher, { once: true }); var switcherTimer = setInterval(mountAccountSwitcher, 500); setTimeout(function () { clearInterval(switcherTimer); }, 30000); diff --git a/src-tauri/resources/bridge.test.mjs b/src-tauri/resources/bridge.test.mjs index bd41e15..991c219 100644 --- a/src-tauri/resources/bridge.test.mjs +++ b/src-tauri/resources/bridge.test.mjs @@ -134,6 +134,7 @@ function makeHarness(options = {}) { body.isRoot = true; const document = { body, + getElementById() { return null; }, createElement: (tag) => new FakeElement(tag), title: "WhatsApp", readyState: "complete", diff --git a/src-tauri/resources/notifications.test.mjs b/src-tauri/resources/notifications.test.mjs index 50f1b38..c47adda 100644 --- a/src-tauri/resources/notifications.test.mjs +++ b/src-tauri/resources/notifications.test.mjs @@ -26,9 +26,9 @@ function harness({ reject = false } = {}) { }; runInNewContext(source, { window, document, navigator: {}, - console: { error: message => errors.push(message), log() {} }, + console: { warn: message => errors.push(message), error: message => errors.push(message), log() {} }, MutationObserver: class { observe() {} }, - setInterval: callback => { intervals.push(callback); return 0; }, + setInterval: (callback, delay) => { intervals.push({ callback, delay }); return 0; }, setTimeout: () => 0, clearTimeout() {}, }); return { window, document, calls, errors, intervals }; @@ -53,7 +53,7 @@ test('the two notification paths do not duplicate the same alert', async () => { test('unread initialization, changes and clearing reach Rust without repeating unchanged titles', () => { const { document, calls, intervals } = harness(); - const poll = intervals[0]; + const poll = intervals.find(timer => timer.delay === 2000).callback; poll(); document.title = '(4) WhatsApp'; poll(); document.title = 'WhatsApp'; poll(); poll(); @@ -65,7 +65,7 @@ test('IPC rejection is visible without logging private notification content', as const { window, errors } = harness({ reject: true }); new window.Notification('private title', { body: 'private body' }); await new Promise(resolve => setImmediate(resolve)); - assert.ok(errors.includes('whatRust bridge: IPC failed for notify')); - assert.ok(errors.includes('whatRust bridge: IPC failed for set_unread')); + assert.ok(errors.includes('[whatRust] notify was refused')); + assert.ok(errors.includes('[whatRust] set_unread was refused')); assert.ok(errors.every(message => !message.includes('private'))); }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 75d99eb..5a16eb6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,13 +17,22 @@ "deep-link": { "desktop": { "name": "WhatsApp link", - "schemes": ["whatsapp"] + "schemes": [ + "whatsapp" + ] } } }, "bundle": { "active": true, - "targets": ["deb", "appimage", "app", "dmg", "nsis", "msi"], + "targets": [ + "deb", + "appimage", + "app", + "dmg", + "nsis", + "msi" + ], "macOS": { "minimumSystemVersion": "12.1" }, @@ -40,6 +49,15 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "nsis": { + "languages": [ + "PortugueseBR" + ], + "installMode": "currentUser", + "startMenuFolder": "whatRust" + } + } } } From b75053992416edc48a7b54fa40542324cb2e67e8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 11:32:56 -0300 Subject: [PATCH 04/22] Improve Windows workspace and notification reliability --- .gitignore | 4 + docs/windows-usabilidade.md | 51 ++ scripts/adapter-check.template.cjs | 43 ++ scripts/test-adapter.ps1 | 15 + settings-ui/index.html | 116 ++-- settings-ui/main.js | 146 ++--- settings-ui/panel.js | 33 + settings-ui/shell.html | 1 + settings-ui/shell.js | 7 + settings-ui/style.css | 210 +++---- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 9 +- src-tauri/build.rs | 11 + src-tauri/capabilities/lock.json | 4 +- src-tauri/capabilities/main-remote.json | 12 +- src-tauri/capabilities/settings.json | 14 +- src-tauri/capabilities/shell.json | 13 + src-tauri/resources/bridge.js | 9 +- src-tauri/resources/bridge.test.mjs | 15 + src-tauri/resources/integration.js | 93 +++ src-tauri/src/accounts.rs | 27 + src-tauri/src/biometric/windows.rs | 4 +- src-tauri/src/commands.rs | 121 ++-- src-tauri/src/dlog.rs | 5 + src-tauri/src/integration.rs | 328 ++++++++++ src-tauri/src/lib.rs | 44 +- src-tauri/src/links.rs | 6 +- src-tauri/src/lock.rs | 18 +- src-tauri/src/notify.rs | 783 +++++++++++++++++------- src-tauri/src/sessions.rs | 443 ++++++++++++++ src-tauri/src/settings.rs | 13 + src-tauri/src/smoke.rs | 96 +++ src-tauri/src/tray.rs | 2 +- src-tauri/src/window.rs | 179 +++++- src-tauri/tauri.conf.json | 2 +- 35 files changed, 2294 insertions(+), 586 deletions(-) create mode 100644 docs/windows-usabilidade.md create mode 100644 scripts/adapter-check.template.cjs create mode 100644 scripts/test-adapter.ps1 create mode 100644 settings-ui/panel.js create mode 100644 settings-ui/shell.html create mode 100644 settings-ui/shell.js create mode 100644 src-tauri/capabilities/shell.json create mode 100644 src-tauri/resources/integration.js create mode 100644 src-tauri/src/integration.rs create mode 100644 src-tauri/src/sessions.rs create mode 100644 src-tauri/src/smoke.rs diff --git a/.gitignore b/.gitignore index 79adbbd..81b07a3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ dist/ /builddir/ /repo/ /*.flatpak + +# UI validation artifacts +/.playwright-cli/ +/output/ diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md new file mode 100644 index 0000000..5fa7149 --- /dev/null +++ b/docs/windows-usabilidade.md @@ -0,0 +1,51 @@ +# Windows: interface integrada e notificações + +Esta implementação usa Tauri 2.11.2 fixado e suas APIs experimentais de múltiplas visualizações. macOS e Linux conservam o fluxo de uma janela por conta. + +## Estrutura + +- `sessions.rs`: uma WebView viva por conta, janela principal, destaque/reintegração, painel local e acesso de recuperação pelo menu da janela. +- `integration.rs` e `resources/integration.js`: contrato mínimo por origem/visualização, dados de exibição, montagem sem classes geradas, Shadow DOM e detector conservador. A criação e o gerenciamento de visualizações só ocorrem no Rust. +- `notify.rs`: políticas, correlação por identidade, recibos de envio, falhas síncronas/assíncronas e histórico limitado a 100 eventos sem conteúdo de mensagens. + +O painel de configurações é uma visualização local com armazenamento e permissões próprios. WhatsApp recebe apenas comandos de relato, exibição e abertura do painel. `Shadow DOM` isola CSS; não é fronteira de segurança. + +## Compatibilidade + +A conta histórica `default` continua com o armazenamento padrão. As demais preservam o mesmo diretório por ID. Destacar usa reparent da visualização existente, preservando sua sessão e estado em memória. Configurações antigas recebem as três preferências novas ativadas, mantendo `notifications` como controle global. + +A preferência de conta inicial continua independente do ID histórico `default`. A ordem só aceita uma permutação completa das contas. Remover uma conta exige confirmação no painel e não permite remover a última. + +## Notificações + +No Windows, WebView2 é a rota principal. A ponte JavaScript assume quando essa rota está indisponível ou recusa a criação. O detector visual só complementa eventos com identificadores de mensagens recebidas reconhecidos no DOM montado da conversa atual. Não lê texto de mensagens nem estruturas internas do React. Identificadores transmitidos pelo detector são hashes em memória. + +Histórico, troca de conversa, remetente próprio e reconstruções sem referência de continuidade não geram avisos adicionais. O silenciamento exige prova explícita. Sem essa prova, o reforço permanece silencioso. Em layouts compatíveis, abrir o menu da conversa permite reconhecer a ação explícita de silenciar/reativar; esse reconhecimento fica isolado no adaptador. + +Uma confirmação de envio ao Windows não prova que houve banner. Não perturbe e as permissões do sistema continuam sendo respeitados. O diagnóstico distingue envio pendente, aceitação, falha, supressão, clique e encerramento. Conteúdo não é escrito no histórico nem nos logs. + +## Verificações + +```powershell +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +node src-tauri/resources/bridge.test.mjs +node --test src-tauri/resources/notifications.test.mjs +node settings-ui/comboToAccelerator.test.mjs +node settings-ui/dialog.test.mjs +.\scripts\test-adapter.ps1 +``` + +Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout. O teste do adaptador usa Playwright CLI com página e dados sintéticos e não acessa conversas reais. + +`WHATRUST_SMOKE=1` é aceito somente por uma compilação debug. Ele usa o identificador isolado `com.karem.whatrust.smoke`, duas contas sintéticas, log separado e um caminho de relatório definido em `WHATRUST_SMOKE_REPORT`. O teste verifica o WebView2 real, movimentação da mesma visualização, origem do painel, recusa de IPC remoto, redimensionamento, bloqueio e submissão de notificação. Ele não está disponível na compilação release. + +## Entrega e limites + +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.1`, acima da versão 0.6.8 anteriormente instalada. + +Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. + +## Recuperação + +O PR #26 e sua branch foram preservados. Antes das mudanças foi criado um bundle Git completo fora do checkout. A versão anterior do instalador permanece na pasta de entrega. Para voltar à interface anterior, reinstale a versão anterior sem selecionar exclusão de dados do aplicativo. A implementação não migra os diretórios de sessão para outro formato. diff --git a/scripts/adapter-check.template.cjs b/scripts/adapter-check.template.cjs new file mode 100644 index 0000000..c5cec0b --- /dev/null +++ b/scripts/adapter-check.template.cjs @@ -0,0 +1,43 @@ +async (page) => { + const checks=[]; + function check(name,pass){if(!pass)throw Error(name);checks.push(name);} + await page.clock.install(); + await page.addInitScript(() => { + window.__WHATRUST_WINDOWS__=true;window.calls=[]; + window.__TAURI__={core:{invoke:async(cmd,args)=>{window.calls.push({cmd,args});if(cmd==='integration_state')return {accounts:[{id:'default',name:'Matheus',unread:2}],current_id:'default',native_notifications:true};return null;}}}; + }); + await page.route('https://web.whatsapp.com/**',route=>route.fulfill({contentType:'text/html',body:'

WhatsApp

Histórico
'})); + await page.goto('https://web.whatsapp.com/'); + await page.addScriptTag({content:__ADAPTER_SOURCE__}); + await page.clock.runFor(3000); + const signals=()=>page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_message_signal').map(c=>c.args.signal)); + check('initial_history_is_silent',(await signals()).length===0); + async function append(id){await page.evaluate(id=>{const r=document.createElement('div');r.dataset.id=id;r.textContent='Mesmo texto';document.getElementById('messages').appendChild(r);},id);await page.clock.runFor(300);await page.waitForTimeout(20);} + await append('false_chatA_new1');await append('false_chatA_new2'); + let s=await signals();check('identical_text_distinct_messages',s.length===2&&s[0].id!==s[1].id); + check('identifier_is_hashed',s.every(x=>/^[a-f0-9]{64}$/.test(x.id))); + check('explicit_unmuted_state',s.every(x=>x.muted===false)); + await append('true_chatA_outgoing');check('outgoing_is_silent',(await signals()).length===2); + await page.evaluate(()=>document.querySelector('#main header').dataset.muted='true');await append('false_chatA_muted'); + check('muted_state_reported',(await signals()).at(-1).muted===true); + await page.evaluate(()=>document.querySelector('#main header').removeAttribute('data-muted'));await append('false_chatA_unknown'); + check('unknown_mute_state_reported',(await signals()).at(-1).muted===null); + let count=(await signals()).length; + await page.evaluate(()=>{const r=document.createElement('div');r.dataset.id='false_chatA_older';document.getElementById('messages').prepend(r);});await page.clock.runFor(300); + check('scroll_history_is_silent',(await signals()).length===count); + await page.evaluate(()=>document.getElementById('messages').innerHTML='
Outra conversa
');await page.clock.runFor(300); + check('conversation_change_is_silent',(await signals()).length===count); + await page.getByRole('button',{name:'Configurações do whatRust'}).click(); + check('settings_uses_limited_opener',await page.evaluate(()=>window.calls.some(c=>c.cmd==='open_app_panel'))); + await page.evaluate(()=>document.querySelector('#app>header').remove());await page.clock.runFor(300); + check('missing_header_activates_recovery',await page.evaluate(()=>window.calls.some(c=>c.cmd==='report_integration'&&c.args.mounted===false))); + await page.evaluate(()=>{const h=document.createElement('header');h.innerHTML='

WhatsApp

';document.getElementById('app').prepend(h);});await page.clock.runFor(300); + check('header_replacement_remounts_once',await page.locator('#__whatrust_controls').count()===1); + await page.getByRole('button',{name:'Matheus · 2',exact:true}).click(); + await page.evaluate(()=>window.__whatrustState({accounts:[{id:'default',name:'Matheus',unread:3}],current_id:'default',native_notifications:true})); + check('live_unread_update_preserves_menu_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.dataset.item==='default')); + await page.keyboard.press('Escape'); + check('escape_restores_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.title==='Trocar conta do WhatsApp')); + for(const width of [560,900,1920]){await page.setViewportSize({width,height:800});const box=await page.locator('#__whatrust_controls').boundingBox();check('header_fits_'+width,box.x>=0&&box.x+box.width<=width);} + return {passed:true,checks}; +} \ No newline at end of file diff --git a/scripts/test-adapter.ps1 b/scripts/test-adapter.ps1 new file mode 100644 index 0000000..f645791 --- /dev/null +++ b/scripts/test-adapter.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop' +$taskRoot=Split-Path -Parent $PSScriptRoot +$taskOutput=Join-Path $taskRoot 'output\playwright' +New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null +$taskSource=Get-Content -LiteralPath (Join-Path $taskRoot 'src-tauri\resources\integration.js') -Raw -Encoding UTF8 +$taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'adapter-check.template.cjs') -Raw -Encoding UTF8 +$taskCode=$taskTemplate.Replace('__ADAPTER_SOURCE__',($taskSource | ConvertTo-Json -Compress)) +$taskFile=Join-Path $taskOutput 'adapter-check.cjs' +[IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter open about:blank | Out-Null +$taskResult=npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter run-code --filename $taskFile +$taskResult | Set-Content -LiteralPath (Join-Path $taskOutput 'adapter-result.log') -Encoding UTF8 +$taskResult | Select-Object -First 3 +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter close | Out-Null +if (($taskResult -join "`n") -match '### Error') { exit 1 } diff --git a/settings-ui/index.html b/settings-ui/index.html index 594db1e..2ca78dd 100644 --- a/settings-ui/index.html +++ b/settings-ui/index.html @@ -1,54 +1,54 @@ - + - whatRust — Settings + whatRust — Configurações +
ConfiguraçõeswhatRust
+
-
-

Accounts

+
+

Contas

- - + +
- +

+

Notificações

+

O detector adicional só usa mensagens novas identificáveis. Conversas silenciadas, histórico e estados desconhecidos não geram avisos adicionais.

-
-

Behavior

+
+

Comportamento

- +
-
-

Appearance

+
+

Aparência

-
-

Keyboard Shortcut

+
+

Atalhos

- Shortcut + Atalho
- - + +
-

On Linux/Wayland the in-app shortcut can't fire (the desktop blocks global key grabs). Bind whatrust --toggle in your system keyboard settings instead — see the README.

+

No Linux/Wayland, configure whatrust --toggle nos atalhos do sistema.

-
-

App Lock

+
+

Segurança

- +
-

App lock controls who can open whatRust's windows. It does - not encrypt your data on disk.

+

O bloqueio protege o acesso às janelas do whatRust. Ele + não criptografa os dados no disco.

+
+

Diagnóstico de notificações

+
Verificar avisos do WindowsO teste confirma o envio; confira se o Windows exibe o banner.
Permissões e Não perturbe
+

+

Atividade desta sessão

Somente horário, conta e resultado. Não armazena o texto das mensagens.

-
+
- +
+ diff --git a/settings-ui/main.js b/settings-ui/main.js index 5d09cc3..a555759 100644 --- a/settings-ui/main.js +++ b/settings-ui/main.js @@ -1,5 +1,5 @@ const invoke = window.__TAURI__.core.invoke; -const BOOLS = ["close_to_tray", "start_minimized", "autostart", "notifications", "hotkey_enabled", "menu_bar_icon"]; +const BOOLS = ["close_to_tray", "start_minimized", "autostart", "notifications", "hotkey_enabled", "menu_bar_icon", "notify_when_focused", "notify_active_chat", "respect_muted"]; // The menu-bar icon can only be hidden on macOS, where the Dock icon still // brings the window back (issue #44). const IS_MAC = /Mac/.test(navigator.platform || navigator.userAgent); @@ -38,10 +38,10 @@ async function save() { try { const warn = await invoke("set_settings", { settings: s }); if (warn) { - note.textContent = "Saved with warning: " + warn; + note.textContent = "Salvo com observação: " + warn; setTimeout(() => (note.textContent = ""), 6000); } else { - note.textContent = "Saved ✓"; + note.textContent = "Alterações salvas ✓"; setTimeout(() => (note.textContent = ""), 1500); } } catch (e) { @@ -58,7 +58,7 @@ async function accountAction(action) { document.querySelectorAll('#accounts-list button, .add-row input, .add-row button, #startup_account') .forEach(el => { el.disabled = true; }); const note = document.getElementById('accounts-note'); - note.textContent = 'Working…'; + note.textContent = 'Aguarde…'; try { await action(); note.textContent = ''; @@ -73,73 +73,43 @@ async function accountAction(action) { } } +function accountMenu(a, accounts, anchor) { + document.getElementById("account-actions")?.remove(); + const menu = document.createElement("div"); menu.id="account-actions";menu.className="account-actions"; + const nativePopover=typeof menu.showPopover==="function"; + if(nativePopover)menu.setAttribute("popover", "auto"); + else {menu.style.position="fixed";menu.style.zIndex="9999";menu.showPopover=()=>{menu.style.display="block";menu.dataset.open="true";};menu.hidePopover=()=>{menu.style.display="none";menu.dataset.open="false";anchor.focus();};}menu.setAttribute("role", "menu");menu.setAttribute("aria-label", "Ações de " + a.name); + const actions = [ + ["Abrir conta", () => accountAction(() => invoke("open_account", {id:a.id}))], + ...( /Win/.test(navigator.platform || navigator.userAgent) ? [[a.detached ? "Reintegrar à janela" : "Abrir em janela separada", () => accountAction(() => invoke(a.detached?"attach_account":"detach_account",{id:a.id}))]] : []), + ["Usar ao iniciar",()=>accountAction(()=>invoke("set_default_account",{id:a.id}))], + ["Renomear",async()=>{const name=await Dlg.prompt("",{title:"Renomear conta",value:a.name,okLabel:"Renomear",requiredMessage:"Informe um nome."});if(name!==null&&name!==a.name)await accountAction(()=>invoke("rename_account",{id:a.id,name}));}], + ]; + const index=accounts.findIndex(x=>x.id===a.id); + for(const [delta,label] of [[-1,"Mover para cima"],[1,"Mover para baixo"]]) { + if(index+delta<0||index+delta>=accounts.length)continue; + actions.push([label,()=>accountAction(()=>{const ids=accounts.map(x=>x.id);[ids[index],ids[index+delta]]=[ids[index+delta],ids[index]];return invoke("reorder_accounts",{ids});})]); + } + if(accounts.length>1)actions.push(["Remover conta",async()=>{if(await Dlg.confirm("A sessão local desta conta será apagada. Para usá-la novamente, você precisará ler o QR code.",{title:`Remover "${a.name}"?`,okLabel:"Remover",danger:true}))await accountAction(()=>invoke("remove_account",{id:a.id}));}]); + for(const [label,action] of actions){const button=document.createElement("button");button.type="button";button.textContent=label;button.setAttribute("role","menuitem");if(label==="Remover conta")button.className="danger";button.onclick=()=>{menu.hidePopover();menu.dataset.open="false";action();};menu.appendChild(button);} + document.body.appendChild(menu);const rect=anchor.getBoundingClientRect();menu.style.top=Math.max(12,Math.min(rect.bottom+4,innerHeight-menu.childElementCount*42-16))+"px";menu.style.right=Math.max(12,innerWidth-rect.right)+"px";menu.showPopover();menu.dataset.open="true";menu.querySelector("button").focus(); + menu.addEventListener("keydown",e=>{if(e.key==="Escape"&&!nativePopover){e.preventDefault();menu.hidePopover();return;}const buttons=[...menu.querySelectorAll("button")],at=buttons.indexOf(document.activeElement);if(e.key==="ArrowDown"||e.key==="ArrowUp"){e.preventDefault();buttons[(at+(e.key==="ArrowDown"?1:-1)+buttons.length)%buttons.length].focus();}}); + menu.addEventListener("toggle",e=>{menu.dataset.open=String(e.newState==="open");anchor.setAttribute("aria-expanded",String(e.newState==="open"));if(e.newState==="closed")anchor.focus();}); +} function renderAccounts(accounts) { - const list = document.getElementById("accounts-list"); - list.textContent = ""; - const canRemove = accounts.length > 1; - const startup = document.getElementById('startup_account'); - startup.textContent = ''; - - for (const a of accounts) { - const row = document.createElement("div"); - row.className = "account-row"; - - const name = document.createElement("span"); - name.className = "acct-name"; - name.textContent = a.name; - row.appendChild(name); - const option = document.createElement('option'); - option.value = a.id; - option.textContent = a.name; - option.selected = a.is_default; - startup.appendChild(option); - - if (a.unread > 0) { - const badge = document.createElement("span"); - badge.className = "acct-unread"; - badge.textContent = String(a.unread); - row.appendChild(badge); - } - - const openBtn = document.createElement("button"); - openBtn.className = "secondary"; - openBtn.textContent = "Open"; - openBtn.addEventListener("click", async () => { - await accountAction(() => invoke("open_account", { id: a.id })); - }); - row.appendChild(openBtn); - - const renameBtn = document.createElement("button"); - renameBtn.className = "secondary"; - renameBtn.textContent = "Rename"; - renameBtn.addEventListener("click", async () => { - const next = await Dlg.prompt("", { - title: "Rename account", - value: a.name, - okLabel: "Rename", - requiredMessage: "An account needs a name.", - }); - if (next === null || next === a.name) return; - await accountAction(() => invoke("rename_account", { id: a.id, name: next })); - }); - row.appendChild(renameBtn); - - const removeBtn = document.createElement("button"); - removeBtn.className = "secondary"; - removeBtn.textContent = "Remove"; - removeBtn.disabled = !canRemove; - removeBtn.addEventListener("click", async () => { - const ok = await Dlg.confirm(`Its local session will be deleted, and you will need to scan the QR code again to use this number.`, { - title: `Remove "${a.name}"?`, - okLabel: "Remove", - danger: true, - }); - if (!ok) return; - await accountAction(() => invoke("remove_account", { id: a.id })); - }); - row.appendChild(removeBtn); - - list.appendChild(row); + const list=document.getElementById("accounts-list"),startup=document.getElementById("startup_account"); + if(document.getElementById("account-actions")?.dataset.open==="true")return; + list.replaceChildren();startup.replaceChildren(); + for(const a of accounts){ + const option=document.createElement("option");option.value=a.id;option.textContent=a.name;option.selected=a.is_default;startup.appendChild(option); + const row=document.createElement("div");row.className="account-row"; + const avatar=document.createElement("span");avatar.className="account-avatar";avatar.setAttribute("aria-hidden","true");avatar.textContent=a.name.trim().split(/\s+/).slice(0,2).map(x=>x[0]).join("").toUpperCase(); + const open=document.createElement("button");open.type="button";open.className="account-open";open.setAttribute("aria-label","Abrir "+a.name); + const name=document.createElement("span");name.className="acct-name";name.textContent=a.name; + const info=document.createElement("span");info.className="acct-subtitle";info.textContent=a.detached?"Em janela separada":a.is_default?"Conta ao iniciar":"WhatsApp";open.append(name,info);open.onclick=()=>accountAction(()=>invoke("open_account",{id:a.id})); + row.append(avatar,open); + if(a.unread){const badge=document.createElement("span");badge.className="acct-unread";badge.textContent=a.unread;badge.title=a.unread+" conversas não lidas";row.appendChild(badge);} + const more=document.createElement("button");more.type="button";more.className="account-more";more.textContent="⋮";more.setAttribute("aria-label","Opções de "+a.name);more.setAttribute("aria-haspopup","menu");more.onclick=()=>accountMenu(a,accounts,more);row.appendChild(more);list.appendChild(row); } } @@ -148,7 +118,7 @@ async function loadAccounts() { const accounts = await invoke("list_accounts"); renderAccounts(accounts); } catch (e) { - document.getElementById('accounts-note').textContent = 'Could not load accounts: ' + String(e); + document.getElementById('accounts-note').textContent = 'Não foi possível carregar as contas: ' + String(e); } } @@ -199,7 +169,7 @@ async function loadLock() { if (s.enabled) { const row = document.getElementById("biometric_row"); row.hidden = !s.biometric_available; - document.getElementById("biometric_label").textContent = "Use " + s.biometric_label; + document.getElementById("biometric_label").textContent = "Usar " + s.biometric_label; document.getElementById("biometric_enabled").checked = s.biometric_enabled; document.getElementById("lock_on_launch").checked = s.lock_on_launch; document.getElementById("lock_on_hide").checked = s.lock_on_hide; @@ -225,23 +195,23 @@ function wireLock() { document.getElementById("lock_pw1").value = ""; document.getElementById("lock_pw2").value = ""; await loadLock(); - } catch (e) { await Dlg.alert(String(e), { title: "Could not enable the lock" }); } + } catch (e) { await Dlg.alert(String(e), { title: "Não foi possível ativar o bloqueio" }); } }); document.getElementById("lock_now").addEventListener("click", async () => { - try { await invoke("lock_app"); } catch (e) { await Dlg.alert(String(e), { title: "Could not lock" }); } + try { await invoke("lock_app"); } catch (e) { await Dlg.alert(String(e), { title: "Não foi possível bloquear" }); } }); document.getElementById("change_pw").addEventListener("click", async () => { const current = await Dlg.prompt("", { - title: "Current password", + title: "Senha atual", password: true, okLabel: "Continue", requiredMessage: "Enter your current password.", }); if (current === null) return; - const next = await Dlg.prompt("At least 4 characters.", { - title: "New password", + const next = await Dlg.prompt("Pelo menos 4 caracteres.", { + title: "Nova senha", password: true, okLabel: "Change", requiredMessage: "Enter a new password.", @@ -249,13 +219,13 @@ function wireLock() { if (next === null) return; try { await invoke("change_app_lock_password", { current, new: next, confirm: next }); - await Dlg.alert("Your app-lock password has been changed.", { title: "Password changed" }); - } catch (e) { await Dlg.alert(String(e), { title: "Could not change the password" }); } + await Dlg.alert("Sua senha de bloqueio foi alterada.", { title: "Senha alterada" }); + } catch (e) { await Dlg.alert(String(e), { title: "Não foi possível alterar a senha" }); } }); document.getElementById("disable_lock").addEventListener("click", async () => { - const current = await Dlg.prompt("Enter your current password to turn the app lock off.", { - title: "Disable app lock", + const current = await Dlg.prompt("Informe sua senha atual para desativar o bloqueio.", { + title: "Desativar bloqueio", password: true, okLabel: "Disable", danger: true, @@ -265,7 +235,7 @@ function wireLock() { try { await invoke("disable_app_lock", { current }); await loadLock(); - } catch (e) { await Dlg.alert(String(e), { title: "Could not disable the lock" }); } + } catch (e) { await Dlg.alert(String(e), { title: "Não foi possível desativar o bloqueio" }); } }); document.getElementById("biometric_enabled").addEventListener("change", async (e) => { @@ -273,12 +243,12 @@ function wireLock() { await invoke("set_biometric_enabled", { enabled: e.target.checked }); } catch (err) { e.target.checked = !e.target.checked; // revert on failure - await Dlg.alert(String(err), { title: "Could not change biometric unlock" }); + await Dlg.alert(String(err), { title: "Não foi possível alterar a biometria" }); } await loadLock(); }); - const reportLockOptionFailure = (e) => Dlg.alert(String(e), { title: "Could not save" }); + const reportLockOptionFailure = (e) => Dlg.alert(String(e), { title: "Não foi possível salvar" }); for (const id of ["lock_on_launch", "lock_on_hide"]) { document.getElementById(id).addEventListener("change", () => saveLockOptions().catch(reportLockOptionFailure)); } @@ -303,7 +273,7 @@ function stopRecording(restore) { window.removeEventListener("keydown", onRecordKeydown, true); window.removeEventListener("blur", onRecordBlur, true); const btn = document.getElementById("record_hotkey"); - btn.textContent = "Record"; + btn.textContent = "Gravar"; btn.classList.remove("recording"); if (restore) document.getElementById("hotkey").value = hotkeyPrev; } @@ -317,9 +287,9 @@ function onRecordKeydown(e) { if (e.key === "Escape") { setHotkeyHint(""); stopRecording(true); return; } const mods = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }; const accel = window.HotkeyFmt.comboToAccelerator(mods, e.code); - if (accel === null) { setHotkeyHint("Unsupported key — try another."); return; } + if (accel === null) { setHotkeyHint("Tecla não suportada. Tente outra."); return; } if (!window.HotkeyFmt.isValidCombo(mods, e.code)) { - setHotkeyHint("Add a modifier (Ctrl / Alt / Shift)."); + setHotkeyHint("Inclua Ctrl, Alt ou Shift."); return; } document.getElementById("hotkey").value = accel; @@ -332,7 +302,7 @@ function toggleRecording() { recordingHotkey = true; hotkeyPrev = document.getElementById("hotkey").value; const btn = document.getElementById("record_hotkey"); - btn.textContent = "Press keys… (Esc to cancel)"; + btn.textContent = "Pressione as teclas… (Esc cancela)"; btn.classList.add("recording"); setHotkeyHint(""); window.addEventListener("keydown", onRecordKeydown, true); diff --git a/settings-ui/panel.js b/settings-ui/panel.js new file mode 100644 index 0000000..097b119 --- /dev/null +++ b/settings-ui/panel.js @@ -0,0 +1,33 @@ +"use strict"; +(function(){ + const invoke=window.__TAURI__.core.invoke, isWindows=/Win/.test(navigator.platform||navigator.userAgent); + const back=document.getElementById("back-to-chat");back.hidden=!isWindows; + let dirty=false, diagnosticsBusy=false, timer; + document.querySelector(".prefs").addEventListener("input",e=>{if(e.target.id!=="new_account_name")dirty=true;}); + document.getElementById("save").addEventListener("click",()=>{setTimeout(()=>{if(document.getElementById("note").textContent.startsWith("Alterações salvas"))dirty=false;},400);}); + async function close(){if(dirty && !await Dlg.confirm("As alterações ainda não foram salvas.",{title:"Voltar sem salvar?",okLabel:"Voltar"}))return;await invoke("close_app_panel");} + back.onclick=close; + document.addEventListener("keydown",e=>{if(e.key==="Escape" && !document.querySelector('.dlg-backdrop,dialog[open],.account-actions[data-open=true]') && !e.defaultPrevented){e.preventDefault();close();}}); + const names={pending:"Aguardando envio",submitted:"Enviada ao Windows; exibição não confirmada",failed:"Falha no envio",suppressed:"Não enviada",activated:"Aviso aberto",dismissed:"Aviso encerrado"}; + const reasons={locked:"Aplicativo bloqueado",notifications_disabled:"Notificações desativadas",foreground_disabled:"Avisos em primeiro plano desativados",active_chat_disabled:"Conversa ativa",muted:"Conversa silenciada",mute_state_unknown:"Silenciamento não confirmado",windows_rejected:"Windows recusou o envio"}; + function row(title,text){const r=document.createElement("div");r.className="row";const t=document.createElement("span");t.className="row-text";const strong=document.createElement("span");strong.className="row-title";strong.textContent=title;const small=document.createElement("span");small.className="row-subtitle";small.textContent=text;t.append(strong,small);r.appendChild(t);return r;} + async function diagnostics(){ + if(diagnosticsBusy||document.hidden)return;diagnosticsBusy=true; + try{ + const d=await invoke("notification_diagnostics"), routes=document.getElementById("route-status"),history=document.getElementById("notification-history"); + routes.replaceChildren();history.replaceChildren(); + for(const a of d.accounts){const adapter=d.adapters["wa-"+a.id];routes.appendChild(row(a.name,(d.native[a.id]?"Captura nativa ativa":"Captura nativa indisponível; ponte alternativa")+" · "+(adapter?.mounted?"Controles integrados":"Acesso pelo menu da janela")+" · "+(adapter?.detector?"Detector adicional disponível quando houver sinais confiáveis":"Aguardando conversa compatível")));} + for(const n of d.recent.slice(0,20)){const account=d.accounts.find(a=>a.id===n.account_id)?.name||"whatRust";history.appendChild(row(new Date(n.time*1000).toLocaleTimeString("pt-BR")+" · "+account,(names[n.status]||n.status)+(n.reason?" — "+(reasons[n.reason]||n.reason):"")));} + if(!d.recent.length)history.appendChild(row("Nenhum aviso registrado","Os próximos eventos aparecerão aqui durante esta execução.")); + document.documentElement.dataset.version=d.version; + }catch(e){document.getElementById("diagnostic-note").textContent=String(e);}finally{diagnosticsBusy=false;} + } + document.getElementById("test-notification").onclick=async()=>{try{document.getElementById("diagnostic-note").textContent=await invoke("test_notification");await diagnostics();}catch(e){document.getElementById("diagnostic-note").textContent=String(e);}}; + document.getElementById("windows-notifications").onclick=()=>invoke("open_notification_settings").catch(e=>{document.getElementById("diagnostic-note").textContent=String(e);}); + if(!isWindows){document.getElementById("windows-notifications").hidden=true;back.hidden=true;} + const paths={contas:'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M17 3a4 4 0 0 1 0 8 M23 21v-2a4 4 0 0 0-3-3.87 M13 7a4 4 0 1 1-8 0a4 4 0 0 1 8 0',notificacoes:'M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9 M10 21h4',aparencia:'M12 3a9 9 0 1 0 0 18a3 3 0 0 0 0-6h-1a2 2 0 0 1 0-4h5a5 5 0 0 0-4-8',seguranca:'M12 3l8 4v6c0 5-8 8-8 8s-8-3-8-8V7z M9 12l2 2l4-4',sistema:'M12 2v10 M6 5a9 9 0 1 0 12 0',atalhos:'M3 5h18v14H3z M6 9h1 M10 9h1 M14 9h1 M18 9h1 M6 13h1 M10 13h1 M14 13h4',diagnostico:'M3 12h4l3-8l4 16l3-8h4'}; + function showSection(id){if(!document.getElementById(id))id='sec-contas';for(const group of document.querySelectorAll('.prefs>.group'))group.hidden=group.id!==id;for(const link of document.querySelectorAll('.settings-nav a'))link.setAttribute('aria-current',link.hash==='#'+id?'page':'false');document.querySelector('.prefs').scrollTop=0;} + for(const link of document.querySelectorAll('.settings-nav a')){const key=link.hash.replace('#sec-',''),svg=document.createElementNS('http://www.w3.org/2000/svg','svg'),path=document.createElementNS('http://www.w3.org/2000/svg','path');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('aria-hidden','true');path.setAttribute('d',paths[key]);svg.appendChild(path);link.prepend(svg);link.onclick=e=>{e.preventDefault();history.replaceState(null,'',link.hash);showSection(link.hash.slice(1));};} + showSection(location.hash.slice(1)||'sec-contas'); + diagnostics();timer=setInterval(diagnostics,3000);window.addEventListener("pagehide",()=>clearInterval(timer)); +})(); diff --git a/settings-ui/shell.html b/settings-ui/shell.html new file mode 100644 index 0000000..d6dad04 --- /dev/null +++ b/settings-ui/shell.html @@ -0,0 +1 @@ +whatRust
whatRust

As contas estão sendo carregadas. Se todas estiverem destacadas, use o menu Contas para retornar.

\ No newline at end of file diff --git a/settings-ui/shell.js b/settings-ui/shell.js new file mode 100644 index 0000000..08cbca5 --- /dev/null +++ b/settings-ui/shell.js @@ -0,0 +1,7 @@ +"use strict"; +const invoke=window.__TAURI__.core.invoke; +let busy=false; +window.refresh=async()=>{if(busy)return;busy=true;try{const list=await invoke("list_accounts"),sel=document.getElementById("accounts"),previous=sel.value;sel.replaceChildren();for(const a of list){const o=document.createElement("option");o.value=a.id;o.textContent=a.name+(a.unread?" · "+a.unread:"");sel.appendChild(o);}if(list.some(a=>a.id===previous))sel.value=previous;}catch{}finally{busy=false;}}; +document.getElementById("settings").onclick=()=>invoke("open_app_panel"); +document.getElementById("accounts").onchange=e=>invoke("open_account",{id:e.target.value}); +refresh(); diff --git a/settings-ui/style.css b/settings-ui/style.css index edb292c..d82c592 100644 --- a/settings-ui/style.css +++ b/settings-ui/style.css @@ -1,150 +1,92 @@ -/* Settings, styled after GNOME/Libadwaita preferences (issue #21): titled groups - of rounded "boxed list" rows, a title and subtitle on the left and the control - on the right, switches instead of checkboxes, one compact column. */ +/* Local management UI, matching WhatsApp desktop surfaces and spacing. */ :root { color-scheme: light dark; - --green: #25d366; - --on-green: #042f1a; - --bg: #fafafa; - --card: #ffffff; - --fg: #1f1f1f; - --dim: #6f6f73; - --line: rgba(0, 0, 0, 0.08); - --border: rgba(0, 0, 0, 0.12); - --hover: rgba(0, 0, 0, 0.04); - --switch-off: #cfcfd2; - --danger: #c01c28; - --warn: #b86e00; + --bg: #fff; --card: #fff; --fg: #111b21; --dim: #667781; + --green: #008069; --on-green: #fff; --border: #e9edef; + --line: #e9edef; --hover: #f0f2f5; --switch-off: #b7bec2; + --danger: #c01c28; --warn: #996600; } @media (prefers-color-scheme: dark) { :root { - --bg: #222226; - --card: #2e2e32; - --fg: #f0f0f0; - --dim: #a3a3a8; - --line: rgba(255, 255, 255, 0.07); - --border: rgba(255, 255, 255, 0.14); - --hover: rgba(255, 255, 255, 0.05); - --switch-off: #55555b; - --danger: #ff7b81; - --warn: #f0a500; + --bg: #161717; --card: #161717; --fg: #e9edef; --dim: #aebac1; + --green: #00a884; --on-green: #111b21; --border: #303334; + --line: #303334; --hover: #252728; --switch-off: #596268; + --danger: #ff7b81; --warn: #e3b341; } } * { box-sizing: border-box; } html, body { height: 100%; } body { - margin: 0; - display: flex; flex-direction: column; + margin: 0; display: flex; flex-direction: column; background: var(--bg); color: var(--fg); - font-family: system-ui, -apple-system, "Segoe UI", Cantarell, Roboto, Ubuntu, sans-serif; - font-size: 14px; -} -.prefs { flex: 1; overflow-y: auto; padding: 18px 16px 8px; } - -/* --- groups --- */ -.group { margin: 0 0 22px; } -.group-title { - font-size: 13px; font-weight: 700; margin: 0 0 8px 2px; color: var(--fg); -} -.group-note { font-size: 12px; color: var(--dim); margin: 8px 2px 0; line-height: 1.4; } -.group-note code { background: var(--hover); padding: 1px 5px; border-radius: 4px; font-size: 11px; } -.group-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } - -/* --- boxed list --- */ -.boxed-list { - background: var(--card); border: 1px solid var(--line); border-radius: 12px; - overflow: hidden; + font: 14px "Segoe UI", Helvetica, Arial, sans-serif; } +[hidden] { display: none !important; } +button, input, select { font: inherit; } +button { cursor: pointer; border: 0; border-radius: 22px; padding: 9px 18px; background: var(--green); color: var(--on-green); font-weight: 500; } +button:disabled { opacity: .45; cursor: default; } +button.secondary { background: transparent; color: var(--green); } +button:hover:not(:disabled) { filter: brightness(.95); } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } +.app-header { padding: 18px 24px; min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); } +.app-header > div { display: flex; align-items: baseline; gap: 12px; } +.app-header strong { font-size: 22px; font-weight: 600; } +.app-header span { font-size: 12px; color: var(--dim); } +.app-header button { color: var(--dim); font-weight: 400; } +.settings-layout { display: flex; flex: 1; min-height: 0; overflow: hidden; } +.settings-nav { flex: 0 0 280px; padding: 16px 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); } +.settings-nav a { display: flex; align-items: center; gap: 24px; padding: 17px 24px; font-size: 15px; color: inherit; text-decoration: none; } +.settings-nav a:hover, .settings-nav a[aria-current=page] { background: var(--hover); } +.settings-nav svg { width: 22px; height: 22px; stroke: var(--dim); fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; } +.prefs { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 40px; scroll-behavior: smooth; } +.group { max-width: 740px; margin: 0 auto; } +.group-title { font-size: 21px; font-weight: 500; margin: 0 0 24px; } +.group-note { font-size: 13px; line-height: 1.55; margin: 16px 0; color: var(--dim); } +.group-note code { font: inherit; background: var(--hover); padding: 2px 4px; } +.group-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } +.boxed-list > * + * { border-top: 1px solid var(--border); } .boxed-list:empty { display: none; } -.boxed-list > * + * { border-top: 1px solid var(--line); } -.boxed-list > [hidden] { display: none; } -.boxed-list > [hidden] + * { border-top: 0; } -.row { - display: flex; align-items: center; gap: 12px; - min-height: 50px; padding: 8px 12px; -} +.row { display: flex; align-items: center; gap: 20px; min-height: 76px; padding: 16px 0; } label.row { cursor: pointer; } -label.row:hover { background: var(--hover); } -.row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } -.row-title { font-size: 14px; } -.row-subtitle { font-size: 12px; color: var(--dim); line-height: 1.35; } -.startup-row { background: var(--card); border: 1px solid var(--line); border-radius: 12px; margin-bottom: 8px; } - -/* An entry row: label left, text field filling the rest. */ -.entry-row .row-title { flex: 0 0 76px; } +.row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; } +.row-title { font-size: 15px; } +.row-subtitle { font-size: 13px; line-height: 1.5; color: var(--dim); } +.startup-row { margin: 24px 0 16px; border-bottom: 1px solid var(--border); } +.entry-row .row-title { flex: 0 0 90px; } .entry-row input { flex: 1; min-width: 0; } - -/* --- controls --- */ -input[type="text"], input[type="password"], input[type="number"], select { - padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px; - background: transparent; color: inherit; font: inherit; font-size: 13px; -} -input:focus-visible, select:focus-visible, button:focus-visible { - outline: 2px solid color-mix(in srgb, var(--green) 60%, transparent); outline-offset: 1px; -} -input[type="number"] { width: 72px; } -/* A native popup button carries its own fixed metrics and ignores most padding; - drop the native look and draw the chevron so it matches the text fields. */ -select { - appearance: none; -webkit-appearance: none; - line-height: normal; padding-right: 30px; min-width: 112px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238696a0' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; -} -/* The open list is drawn by the engine, not by the select's transparent box: - on WebView2 that left it white while the text followed the dark theme - (issue #40), so give the options the page's own colours explicitly. */ -select option { background-color: var(--bg); color: var(--fg); } - -/* Checkbox drawn as an Adwaita-style switch. */ -input.switch { - appearance: none; -webkit-appearance: none; flex: none; margin: 0; - position: relative; width: 42px; height: 24px; border-radius: 12px; - background: var(--switch-off); cursor: pointer; transition: background 0.15s; -} -input.switch::before { - content: ""; position: absolute; top: 3px; left: 3px; - width: 18px; height: 18px; border-radius: 50%; - background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); transition: transform 0.15s; -} -input.switch:checked { background: var(--green); } -input.switch:checked::before { transform: translateX(18px); } -input.switch:disabled { opacity: 0.5; cursor: not-allowed; } - -button { - background: var(--green); color: var(--on-green); border: 0; border-radius: 8px; - padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; -} -button:hover { filter: brightness(1.05); } -button:disabled { opacity: 0.4; cursor: not-allowed; } -button.secondary { background: var(--hover); color: inherit; border: 1px solid var(--border); } -button.secondary:hover { filter: none; background: var(--line); } - -/* --- accounts --- */ -.account-row { display: flex; align-items: center; gap: 6px; min-height: 50px; padding: 8px 10px 8px 12px; } -.account-row .acct-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.account-row .acct-unread { - min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; - background: var(--green); color: var(--on-green); font-size: 11px; font-weight: 700; - display: inline-flex; align-items: center; justify-content: center; -} -.account-row button { padding: 5px 10px; font-size: 12px; } -.add-row { display: flex; gap: 8px; margin-top: 10px; } -.add-row input { flex: 1 1 auto; min-width: 0; } -.macos-note { color: var(--danger); font-size: 12px; margin: 8px 2px 0; } -.macos-note[hidden] { display: none; } - -/* --- shortcut --- */ -.hotkey-row { display: flex; gap: 6px; align-items: center; flex: 0 1 230px; min-width: 0; } -.hotkey-row #hotkey { flex: 1; min-width: 0; } -.hotkey-row button { padding: 6px 10px; font-size: 12px; } -#record_hotkey.recording { background: var(--warn); color: #1a1a1a; border-color: transparent; } -.hotkey-hint { font-size: 12px; color: var(--warn); margin: 8px 2px 0; } - -/* --- footer --- */ -.footer { - display: flex; align-items: center; justify-content: flex-end; gap: 12px; - padding: 10px 16px; border-top: 1px solid var(--line); background: var(--bg); +input[type=text], input[type=password], input[type=number], select { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; background: transparent; color: inherit; } +input[type=number] { width: 80px; } +select { min-width: 120px; } +select option { background: var(--bg); color: var(--fg); } +.switch { appearance: none; -webkit-appearance: none; position: relative; width: 38px; height: 22px; margin: 0; flex: 0 0 38px; border: 0; border-radius: 12px; background: var(--switch-off); cursor: pointer; } +.switch::before { content: ""; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; } +.switch:checked { background: var(--green); } +.switch:checked::before { transform: translateX(16px); } +.account-row { display: flex; align-items: center; min-height: 88px; gap: 15px; padding: 14px 0; } +.account-avatar { height: 48px; width: 48px; border-radius: 50%; background: var(--hover); color: var(--dim); display: grid; place-items: center; font-size: 17px; flex: 0 0 48px; } +.account-open { display: flex; flex: 1; min-width: 0; align-items: flex-start; flex-direction: column; gap: 5px; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; text-align: left; } +.acct-name { font-size: 16px; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } +.acct-subtitle { font-size: 13px; color: var(--dim); font-weight: 400; } +.account-more { font-size: 26px; line-height: 1; width: 36px; height: 36px; border-radius: 50%; padding: 0; color: var(--dim); background: transparent; } +.acct-unread { background: #25d366; color: #111b21; font-size: 12px; padding: 2px 7px; border-radius: 10px; } +.account-actions { position: fixed; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); box-shadow: 0 3px 18px #0004; padding: 6px 0; width: 245px; margin: 0; max-height: calc(100vh - 24px); overflow-y: auto; } +.account-actions button { display: block; width: 100%; border-radius: 0; background: transparent; color: inherit; text-align: left; font-size: 14px; font-weight: 400; padding: 12px 22px; } +.account-actions button:hover, .account-actions button:focus-visible { background: var(--hover); } +.account-actions .danger { color: var(--danger); } +.add-row { display: flex; gap: 10px; margin-top: 20px; } +.add-row input { min-width: 0; flex: 1; background: var(--hover); border: 0; padding: 12px 14px; } +.hotkey-row { display: flex; gap: 8px; flex-wrap: wrap; } +.hotkey-row input { min-width: 0; width: 190px; } +.recording { outline: 2px dashed var(--green); } +.hotkey-hint { font-size: 13px; color: var(--warn); } +.footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 24px; border-top: 1px solid var(--border); } +.footer button { padding: 10px 24px; margin-left: auto; } +.note { font-size: 13px; color: var(--green); } +.prefs h3 { font-size: 14px; margin: 24px 0 6px; } +@media(max-width:900px) { .settings-nav { flex-basis: 220px; } .settings-nav a { padding: 16px 20px; gap: 18px; } .prefs { padding: 26px 24px; } } +@media(max-width:640px) { + .settings-nav { flex-basis: 62px; overflow: hidden; } .settings-nav a { font-size: 0; padding: 18px 20px; gap: 24px; } .settings-nav svg { flex: 0 0 22px; } + .prefs { padding: 24px 18px; } .app-header { padding: 14px 18px; min-height: 66px; } .row { gap: 12px; flex-wrap: wrap; } .row-text { flex-basis: 160px; } + .account-row { gap: 10px; } .account-avatar { width: 40px; height: 40px; flex-basis: 40px; } .group-title { font-size: 19px; } } -.note { flex: 1; color: var(--green); font-weight: 600; font-size: 13px; } +@media(max-width:480px) { .app-header span { display: none; } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d534e7d..c3101f1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.8" +version = "0.6.9-local.1" dependencies = [ "argon2", "block2", @@ -4712,6 +4712,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-window-state", "tauri-winrt-notification", + "tokio", "user-idle", "webkit2gtk", "webview2-com", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2467d5c..2dc0e65 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.8" +version = "0.6.9-local.1" edition = "2021" rust-version = "1.82" @@ -19,9 +19,10 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri = { version = "=2.11.2", features = ["tray-icon", "image-png", "unstable"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["time"] } argon2 = { version = "0.5.3", features = ["std"] } # Argon2id password hashing for the app lock (PHC strings) [target.'cfg(target_os = "linux")'.dependencies] @@ -38,7 +39,9 @@ windows = { version = "0.61", features = [ "Win32_System_Com", # existing (webview2 permission handler) "Security_Credentials_UI", # UserConsentVerifier + result/availability enums "Win32_System_WinRT", # IUserConsentVerifierInterop - "Foundation", # async return-type plumbing + "Foundation", + "Data_Xml_Dom", + "UI_Notifications", # async return-type plumbing "Win32_Security", # SECURITY_ATTRIBUTES (referenced by RegCreateKeyExW) "Win32_System_Registry", # RegCreateKeyExW/RegSetValueExW: register the toast AUMID in HKCU "Win32_UI_Shell", # SetCurrentProcessExplicitAppUserModelID, ShellExecuteW (open links/files) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 4e9b38e..ff7a0ca 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -8,6 +8,17 @@ fn main() { tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&[ // WhatsApp page (bridge.js) + "integration_state", + "report_integration", + "report_message_signal", + "open_app_panel", + "close_app_panel", + "detach_account", + "attach_account", + "reorder_accounts", + "notification_diagnostics", + "test_notification", + "open_notification_settings", "notify", "set_unread", "dlog", diff --git a/src-tauri/capabilities/lock.json b/src-tauri/capabilities/lock.json index d94d6b7..fee1cf4 100644 --- a/src-tauri/capabilities/lock.json +++ b/src-tauri/capabilities/lock.json @@ -2,12 +2,14 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "lock-local", "description": "IPC for the app-lock screen. core:default for invoke plus the unlock commands; no remote, no account/notification permissions. Per-command authorization is also enforced in Rust (is_lock_window).", - "windows": ["lock"], "permissions": [ "core:default", "allow-get-lock-status", "allow-unlock-password", "allow-unlock-biometric", "allow-reset-app-lock" + ], + "webviews": [ + "lock" ] } diff --git a/src-tauri/capabilities/main-remote.json b/src-tauri/capabilities/main-remote.json index b898716..6389ed0 100644 --- a/src-tauri/capabilities/main-remote.json +++ b/src-tauri/capabilities/main-remote.json @@ -2,9 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-remote", "description": "What the remote WhatsApp Web page may call through bridge.js: notifications, the unread count, diagnostic breadcrumbs and the download toast's Open/Show buttons. core:default is intentionally NOT granted so the remote origin cannot emit/listen on internal event channels or introspect the app, and of the notification plugin only its read-only permission query is granted: its init script asks it on every page load (refused, that logged an unhandled rejection), while its `notify` would bypass the lock-aware `notify` and stays denied.", - "windows": [ - "wa-*" - ], "remote": { "urls": [ "https://web.whatsapp.com/*" @@ -18,6 +15,13 @@ "allow-reveal-download", "allow-switcher-accounts", "allow-switch-account", - "notification:allow-is-permission-granted" + "notification:allow-is-permission-granted", + "allow-integration-state", + "allow-report-integration", + "allow-report-message-signal", + "allow-open-app-panel" + ], + "webviews": [ + "wa-*" ] } diff --git a/src-tauri/capabilities/settings.json b/src-tauri/capabilities/settings.json index 694d40f..b9952ce 100644 --- a/src-tauri/capabilities/settings.json +++ b/src-tauri/capabilities/settings.json @@ -2,7 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "settings-local", "description": "Local IPC for the settings window: settings, accounts and app-lock management.", - "windows": ["settings"], "permissions": [ "core:default", "allow-get-settings", @@ -19,6 +18,17 @@ "allow-disable-app-lock", "allow-set-app-lock-options", "allow-set-biometric-enabled", - "allow-lock-app" + "allow-lock-app", + "allow-open-app-panel", + "allow-close-app-panel", + "allow-detach-account", + "allow-attach-account", + "allow-reorder-accounts", + "allow-notification-diagnostics", + "allow-test-notification", + "allow-open-notification-settings" + ], + "webviews": [ + "settings" ] } diff --git a/src-tauri/capabilities/shell.json b/src-tauri/capabilities/shell.json new file mode 100644 index 0000000..ba7fb51 --- /dev/null +++ b/src-tauri/capabilities/shell.json @@ -0,0 +1,13 @@ +{ + "identifier": "shell-local", + "description": "Local recovery controls only; no password or account mutation permissions.", + "webviews": [ + "shell-*" + ], + "permissions": [ + "allow-list-accounts", + "allow-open-account", + "allow-open-app-panel", + "allow-close-app-panel" + ] +} diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index 709e2d0..755cc02 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -22,6 +22,7 @@ // A small, keyboard-accessible account switcher owned by whatRust. It exposes // only account names/unread counts and a request to focus an existing account. function mountAccountSwitcher() { + if (window.__WHATRUST_WINDOWS__) return; if (!document.body || document.getElementById("__whatrust_accounts")) return; // Join the WhatsApp header's normal layout, so the control never covers // the navigation icons or conversations. Wait until the header is mounted. @@ -149,7 +150,7 @@ if (now - recentNotif[k] > DEDUP_MS) delete recentNotif[k]; // prune stale keys } var key = title + " " + body; - if (recentNotif[key] && now - recentNotif[key] <= DEDUP_MS) return false; + if (!window.__WHATRUST_WINDOWS__ && recentNotif[key] && now - recentNotif[key] <= DEDUP_MS) return false; recentNotif[key] = now; var scheme = typeof icon === "string" && (/^(blob|data|https):/.exec(icon) || [])[1]; if (!scheme) { @@ -285,7 +286,9 @@ // 2) Notification override — forward to a native OS notification via Rust. try { + var OriginalNotification = window.Notification; function ShimNotification(title, options) { + if (window.__whatrustPreferNative && typeof OriginalNotification === "function") { try { return new OriginalNotification(title, options); } catch (e) { notePath("native constructor refused; page fallback"); } } options = options || {}; this.title = title; this.body = options.body || ""; @@ -349,7 +352,10 @@ try { var SWR = window.ServiceWorkerRegistration; if (SWR && SWR.prototype && typeof SWR.prototype.showNotification === "function") { + var originalShowNotification = SWR.prototype.showNotification; + var originalGetNotifications = SWR.prototype.getNotifications; SWR.prototype.showNotification = function (title, options) { + if (window.__whatrustPreferNative) return originalShowNotification.call(this, title, options).catch(function(){options=options||{};nativeNotify(title,options.body,options.icon);}); options = options || {}; notePath("page registration.showNotification()"); // Route through nativeNotify so the service-worker path shares the same de-dup @@ -360,6 +366,7 @@ }; if (typeof SWR.prototype.getNotifications === "function") { SWR.prototype.getNotifications = function () { + if (window.__whatrustPreferNative) return originalGetNotifications.apply(this, arguments); return Promise.resolve([]); }; } diff --git a/src-tauri/resources/bridge.test.mjs b/src-tauri/resources/bridge.test.mjs index 991c219..8fc959b 100644 --- a/src-tauri/resources/bridge.test.mjs +++ b/src-tauri/resources/bridge.test.mjs @@ -152,6 +152,9 @@ function makeHarness(options = {}) { }; const window = { location: { origin: "https://web.whatsapp.com" }, + Notification: options.nativeNotification, + __WHATRUST_WINDOWS__: !!options.windows, + __whatrustPreferNative: !!options.preferNative, addEventListener() {}, __TAURI__: { core: { @@ -585,7 +588,19 @@ async function testUnreadReportsSayWhenThePageHasSettled() { assert(first && first.args.settled === false, "the first report after load is not settled"); } +async function testWindowsNativePreferenceAndFallback() { + let nativeCalls=0; + const healthy=makeHarness({windows:true,preferNative:true,nativeNotification:class {constructor(){nativeCalls++;}}}); + new healthy.window.Notification("one",{body:"same"});new healthy.window.Notification("two",{body:"same"}); + assert(nativeCalls===2,"healthy native route owns delivery"); + assert(!healthy.invocations.some(x=>x.cmd==="notify"),"page route does not duplicate native delivery"); + const failed=makeHarness({windows:true,preferNative:true,nativeNotification:class {constructor(){throw Error("permission");}}}); + new failed.window.Notification("same",{body:"same"});new failed.window.Notification("same",{body:"same"}); + assert(failed.invocations.filter(x=>x.cmd==="notify").length===2,"native rejection falls back without dropping distinct repeated text"); +} + const tests = [ + testWindowsNativePreferenceAndFallback, testUnreadReportsSayWhenThePageHasSettled, testNotificationPathIsLoggedOnceWithoutContent, testDownloadToastOffersOpenAndShowInFolder, diff --git a/src-tauri/resources/integration.js b/src-tauri/resources/integration.js new file mode 100644 index 0000000..a8b53a5 --- /dev/null +++ b/src-tauri/resources/integration.js @@ -0,0 +1,93 @@ +(function () { + "use strict"; + if (!window.__WHATRUST_WINDOWS__ || location.origin !== "https://web.whatsapp.com") return; + var state = { accounts: [], current_id: "", native_notifications: false }; + var host, root, menu, accountButton, status, lastReport = "", observer, scheduled; + function call(cmd, args) { var t=window.__TAURI__;if(!t||!t.core)return Promise.resolve(null);return t.core.invoke(cmd, args).catch(function () { return null; }); } + function button(text, fn) { var b=document.createElement("button");b.type="button";b.textContent=text;b.addEventListener("click",function(e){e.stopPropagation();fn();});return b; } + function openPanel() { if(menu.open)menu.close();call("open_app_panel"); } + function draw() { + if(!accountButton)return; + var focusKey=root.activeElement && root.activeElement.dataset.item; + var keepFocus=menu.open && !!focusKey; + var own=state.accounts.find(function(a){return a.id===state.current_id;}); + accountButton.textContent=own ? own.name + (own.unread ? " · "+own.unread : "") : "Contas"; + accountButton.title="Trocar conta do WhatsApp"; + menu.replaceChildren(); + var title=document.createElement("h2");title.textContent="Suas contas";menu.appendChild(title); + state.accounts.forEach(function(a){var b=button(a.name+(a.unread?" · "+a.unread+" conversas não lidas":""),function(){menu.close();call("switch_account",{id:a.id});});b.setAttribute("aria-current",String(a.id===state.current_id));b.dataset.item=a.id;menu.appendChild(b);}); + var manage=button("Gerenciar contas e configurações",openPanel);manage.dataset.item="manage";menu.appendChild(manage); + var close=button("Fechar",function(){menu.close();});close.dataset.item="close";menu.appendChild(close); + status=document.createElement("p");status.setAttribute("role","status");status.textContent="Controles do whatRust";menu.appendChild(status); + if(keepFocus){var restore=Array.from(menu.querySelectorAll("button")).find(function(b){return b.dataset.item===focusKey;})||menu.querySelector("button");if(restore)restore.focus();} + } + window.__whatrustState=function(next){if(!next)return;state=next;window.__whatrustPreferNative=!!next.native_notifications;draw();}; + function create(){ + host=document.createElement("span");host.id="__whatrust_controls";host.setAttribute("data-whatrust","controls"); + host.style.cssText="display:inline-flex;align-items:center;flex:0 1 auto;min-width:40px;max-width:190px;margin-inline-start:8px"; + root=host.attachShadow({mode:"open"}); + var style=document.createElement("style");style.textContent=":host{color:inherit;font:13px system-ui}*{box-sizing:border-box}button{font:inherit;cursor:pointer;border:0;border-radius:8px;padding:7px 8px;background:transparent;color:inherit;min-height:36px}button:hover{background:#00806933}button:focus-visible{outline:2px solid #00a884;outline-offset:2px}.bar{display:flex;align-items:center;gap:5px;min-width:0}.account{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px;font-weight:500}svg{width:21px;height:21px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}@media(max-width:720px){.account{max-width:68px}}dialog{color-scheme:light dark;color:light-dark(#182229,#e9edef);background:light-dark(#fff,#202c33);border:1px solid #8884;border-radius:16px;box-shadow:0 12px 50px #0006;width:330px;max-width:calc(100vw - 32px);padding:16px}dialog::backdrop{background:#0004}dialog button{display:block;width:100%;text-align:left;margin:5px 0;border-radius:9px}h2{font-size:17px;margin:0 0 12px}p{font-size:12px;opacity:.7;margin:12px 0 0}[aria-current=true]{border-color:#00a884}"; + var bar=document.createElement("span");bar.className="bar"; + accountButton=button("Contas",function(){draw();if(!menu.open)menu.showModal();accountButton.setAttribute("aria-expanded","true");var b=menu.querySelector("button");if(b)b.focus();});accountButton.className="account";accountButton.setAttribute("aria-haspopup","dialog");accountButton.setAttribute("aria-expanded","false"); + var gear=button("⚙",openPanel);gear.setAttribute("aria-label","Configurações do whatRust");gear.title="Configurações do whatRust"; + gear.textContent=""; + var svg=document.createElementNS("http://www.w3.org/2000/svg","svg"),path=document.createElementNS("http://www.w3.org/2000/svg","path");svg.setAttribute("viewBox","0 0 24 24");svg.setAttribute("aria-hidden","true");path.setAttribute("d","M9 3h6l1 3l3 1l2 5l-2 5l-3 1l-1 3H9l-1-3l-3-1l-2-5l2-5l3-1z M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0");svg.appendChild(path);gear.appendChild(svg); + menu=document.createElement("dialog");menu.setAttribute("aria-label","Contas do whatRust");menu.addEventListener("close",function(){accountButton.setAttribute("aria-expanded","false");accountButton.focus();}); + bar.append(accountButton,gear);root.append(style,bar,menu);draw(); + } + function report(mounted,detector){var value=mounted+":"+detector;if(value===lastReport)return;lastReport=value;var t=window.__TAURI__;if(!t||!t.core){lastReport="";return;}t.core.invoke("report_integration",{mounted:mounted,detector:detector}).catch(function(){lastReport="";});} + function anchor(){return document.querySelector("#side header") || Array.from(document.querySelectorAll("#app header, #app [role=banner]")).find(function(e){return !e.closest("#main");}) || null;} + function mount(){ + var target=anchor();if(!host)create(); + if(target && host.parentNode!==target)target.appendChild(host); + if(!target && host.isConnected)host.remove(); + report(!!target,messageRows().length>0); + scanMessages(); + } + function schedule(){if(scheduled)return;scheduled=setTimeout(function(){scheduled=0;mount();},200);} + // Adapter contract: only mounted message IDs/direction and explicit mute state. + // No React internals, message text, chat names, or contacts are read. + var cursor=null, chat=null, baseline=0, muteKnown=null, sent=new Set(), lastMenuText="", menuChat=null, menuTime=0; + document.addEventListener("click",function(e){var item=e.target.closest && e.target.closest("[role=menuitem]");if(item){muteKnown=null;return;}var control=e.target.closest && e.target.closest("#main header button,#main header [role=button]");if(control){menuChat=chat;menuTime=Date.now();}},true); + function messageRows(){var main=document.getElementById("main");return main?Array.from(main.querySelectorAll("[data-id]")).filter(function(e){return /^(true|false)_[^_]+_.+/.test(e.getAttribute("data-id")||"");}):[];} + function muteState(){ + var main=document.getElementById("main");if(!main)return null; + if(main.querySelector('header[data-muted="true"], header [data-muted="true"], header [data-icon="muted"], header [data-icon="muted-alt"]'))return true; + if(main.querySelector('header[data-muted="false"], header [data-muted="false"]'))return false; + // Only explicit, visible actions prove the opposite current state. These + // bounded labels belong to this adapter, never to the mounting algorithm. + var actions=Array.from(document.querySelectorAll('[role="menuitem"],[role="menu"] button,[role="menu"] [role="button"]')).filter(function(e){return e.getClientRects().length;}); + var text=actions.map(function(e){return e.textContent.trim().toLowerCase();}).join("|"); + if(menuChat===chat && Date.now()-menuTime<5000 && text){lastMenuText=text;if(actions.some(function(e){return /^(unmute notifications|reativar notificações|ativar notificações)$/.test(e.textContent.trim().toLowerCase());}))muteKnown=true; + else if(actions.some(function(e){return /^(mute notifications|silenciar notificações)$/.test(e.textContent.trim().toLowerCase());}))muteKnown=false;} + return muteKnown; + } + async function send(row,muted){ + var id=row.getAttribute("data-id");if(sent.has(id))return;sent.add(id);if(sent.size>1024)sent.delete(sent.values().next().value); + if(!crypto.subtle)return; + var bytes=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(id)); + var key=Array.from(new Uint8Array(bytes)).map(function(b){return b.toString(16).padStart(2,"0");}).join(""); + await call("report_message_signal",{signal:{id:key,active:true,muted:muted}}); + } + function scanMessages(){ + var rows=messageRows();if(!rows.length){cursor=null;chat=null;return;} + var end=rows[rows.length-1].getAttribute("data-id"), current=end.split("_")[1]; + if(current!==chat){chat=current;cursor=end;baseline=Date.now();muteKnown=null;lastMenuText="";rows.forEach(function(r){sent.add(r.getAttribute("data-id"));});return;} + var muted=muteState(); + if(end===cursor)return; + var index=rows.findIndex(function(r){return r.getAttribute("data-id")===cursor;}); + cursor=end; + // Disappearing anchor = virtualization/reconnect/history, not proof of news. + if(index<0 || Date.now()-baseline<2500)return; + rows.slice(index+1).filter(function(r){return r.getAttribute("data-id").startsWith("false_");}).forEach(function(r){send(r,muted);}); + } + function start(){ + if(!document.body)return; + observer=new MutationObserver(function(records){if(records.some(function(r){return !host || !host.contains(r.target);}))schedule();}); + observer.observe(document.body,{subtree:true,childList:true,attributes:true,attributeFilter:["data-id","data-muted"]}); + mount();call("integration_state").then(window.__whatrustState); + setInterval(function(){call("integration_state").then(window.__whatrustState);mount();},10000); + window.addEventListener("pagehide",function(){observer.disconnect();}); + } + if(document.body)start();else document.addEventListener("DOMContentLoaded",start,{once:true}); +})(); diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 238e17b..1c277b8 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -124,6 +124,21 @@ pub fn rename(f: &mut AccountsFile, id: &str, name: &str) -> Result<(), String> Ok(()) } +/// Exact permutation only: no omitted or repeated accounts. +pub fn reorder(f: &mut AccountsFile, ids: &[String]) -> Result<(), String> { + let unique: std::collections::HashSet<_> = ids.iter().collect(); + if ids.len() != f.accounts.len() + || unique.len() != ids.len() + || ids.iter().any(|id| !f.accounts.iter().any(|a| &a.id == id)) + { + return Err("A ordem deve conter todas as contas uma única vez".into()); + } + for a in &mut f.accounts { + a.order = ids.iter().position(|id| id == &a.id).unwrap() as u32; + } + Ok(()) +} + /// Sum of unread across all accounts. pub fn aggregate_unread(map: &HashMap) -> u32 { map.values().copied().sum() @@ -237,6 +252,18 @@ pub fn gen_store_uuid() -> [u8; 16] { mod tests { use super::*; + #[test] + fn reorder_requires_an_exact_permutation_without_changing_profiles() { + let mut f = AccountsFile::default(); + let other = add(&mut f, "B"); + let before = f.clone(); + assert!(reorder(&mut f, &["default".into(), "default".into()]).is_err()); + assert_eq!(f, before); + reorder(&mut f, &[other.id.clone(), "default".into()]).unwrap(); + assert_eq!(startup_account(&f).unwrap().id, other.id); + assert_eq!(f.accounts[1].store_uuid, before.accounts[1].store_uuid); + } + #[test] fn startup_preference_survives_roundtrip_without_changing_profile_identity() { let mut f = AccountsFile::default(); diff --git a/src-tauri/src/biometric/windows.rs b/src-tauri/src/biometric/windows.rs index 78784b8..9eedca4 100644 --- a/src-tauri/src/biometric/windows.rs +++ b/src-tauri/src/biometric/windows.rs @@ -23,8 +23,8 @@ pub fn authenticate(app: &tauri::AppHandle, reason: &str) -> Result bool { - is_remote_label(window.label()) +fn is_remote(window: &tauri::Webview) -> bool { + is_remote_label(window.label()) || !crate::integration::trusted_local(window) } /// Pure predicate behind `is_remote`, broken out so it can be unit-tested without a -/// live `tauri::Window`. +/// live `tauri::Webview`. fn is_remote_label(label: &str) -> bool { label.starts_with("wa-") } @@ -24,12 +24,15 @@ fn is_remote_label(label: &str) -> bool { /// WhatsApp gave one and it could be loaded. #[tauri::command] pub fn notify( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, title: String, body: String, icon: Option, ) { + if crate::integration::remote(&window).is_err() { + return; + } // issue #3 diagnostics: confirm the command is actually reached from the // injected bridge. No message content is logged (PII). crate::dlog::log("commands::notify invoked"); @@ -86,11 +89,14 @@ pub fn reveal_download(app: tauri::AppHandle, id: u64) -> Result<(), String> { #[tauri::command] pub fn set_unread( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, title: String, settled: Option, ) { + if crate::integration::remote(&window).is_err() { + return; + } let count = crate::unread::parse_unread(&title); let Some(id) = accounts::id_from_label(window.label()) else { return; @@ -108,19 +114,23 @@ pub fn set_unread( crate::tray::update_badge(&app, total); crate::tray::rebuild_menu(&app); + crate::integration::broadcast(&app); // A new unread chat while the window is in the background: make sure the // user hears about it even when WhatsApp raises its own notification where // whatRust can't see it (issue #3). Covered by a real notification, this // stays silent; see notify::unread_increased. - let focused = window.is_focused().unwrap_or(false) && window.is_visible().unwrap_or(false); - if crate::notify::fallback_due(previous, count, focused, settled.unwrap_or(false)) { + let focused = window.window().is_focused().unwrap_or(false) + && window.window().is_visible().unwrap_or(false); + if !cfg!(windows) + && crate::notify::fallback_due(previous, count, focused, settled.unwrap_or(false)) + { crate::notify::unread_increased(&app, window.label(), count); } } #[tauri::command] -pub fn get_settings(window: tauri::Window, app: tauri::AppHandle) -> Result { +pub fn get_settings(window: tauri::Webview, app: tauri::AppHandle) -> Result { if is_remote(&window) { return Err("forbidden".into()); } @@ -130,7 +140,7 @@ pub fn get_settings(window: tauri::Window, app: tauri::AppHandle) -> Result Result, String> { @@ -148,7 +158,7 @@ pub fn set_settings( // on Windows (wry#583); an async command runs on the async runtime instead. // Keep every window-creating command async. #[tauri::command] -pub async fn open_settings(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> { +pub async fn open_settings(window: tauri::Webview, app: tauri::AppHandle) -> Result<(), String> { if is_remote(&window) { return Err("forbidden".into()); } @@ -169,6 +179,7 @@ pub struct AccountView { pub unread: u32, pub open: bool, pub is_default: bool, + pub detached: bool, } fn account_views(app: &tauri::AppHandle) -> Vec { @@ -183,9 +194,11 @@ fn account_views(app: &tauri::AppHandle) -> Vec { name: a.name.clone(), order: a.order, unread: map.get(&a.id).copied().unwrap_or(0), - open: app - .get_webview_window(&accounts::window_label(&a.id)) - .is_some(), + open: app.get_webview(&accounts::window_label(&a.id)).is_some(), + detached: cfg!(windows) + && app + .get_webview(&accounts::window_label(&a.id)) + .is_some_and(|v| v.window().label().starts_with("detached-")), is_default: accounts::startup_account(&f).is_some_and(|startup| startup.id == a.id), }) .collect(); @@ -204,12 +217,10 @@ pub struct SwitcherAccount { #[tauri::command] pub fn switcher_accounts( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, ) -> Result, String> { - if !is_remote(&window) { - return Err("forbidden".into()); - } + crate::integration::remote(&window)?; lock::require_unlocked(&app)?; Ok(account_views(&app) .into_iter() @@ -223,23 +234,18 @@ pub fn switcher_accounts( #[tauri::command] pub async fn switch_account( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, id: String, ) -> Result<(), String> { - if !is_remote(&window) { - return Err("forbidden".into()); - } + crate::integration::remote(&window)?; lock::require_unlocked(&app)?; let _guard = accounts::lock_mutations(); let f = accounts::load(&app); let Some(acct) = f.accounts.iter().find(|a| a.id == id) else { return Err("unknown account".into()); }; - if app - .get_webview_window(&accounts::window_label(&acct.id)) - .is_none() - { + if app.get_webview(&accounts::window_label(&acct.id)).is_none() { crate::window::open_account_window(&app, acct, false).map_err(|e| e.to_string())?; } crate::window::show_account(&app, &accounts::window_label(&acct.id)); @@ -251,7 +257,7 @@ pub async fn switch_account( #[tauri::command] pub fn list_accounts( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, ) -> Result, String> { if is_remote(&window) { @@ -263,7 +269,7 @@ pub fn list_accounts( #[tauri::command] pub fn set_default_account( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, id: String, ) -> Result<(), String> { @@ -279,7 +285,7 @@ pub fn set_default_account( #[tauri::command] pub async fn add_account( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, name: String, ) -> Result { @@ -309,6 +315,7 @@ pub async fn add_account( return Err(e.to_string()); } crate::tray::rebuild_menu(&app); + crate::integration::broadcast(&app); let is_default = accounts::startup_account(&f).is_some_and(|startup| startup.id == acct.id); Ok(AccountView { @@ -318,12 +325,13 @@ pub async fn add_account( unread: 0, open: true, is_default, + detached: false, }) } #[tauri::command] pub async fn remove_account( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, id: String, ) -> Result<(), String> { @@ -338,8 +346,12 @@ pub async fn remove_account( accounts::save(&app, &f).map_err(|e| e.to_string())?; // Close the window if open. - if let Some(w) = app.get_webview_window(&accounts::window_label(&removed.id)) { - let _ = w.destroy(); + if let Some(_w) = app.get_webview(&accounts::window_label(&removed.id)) { + let _ = _w.clear_all_browsing_data(); + #[cfg(windows)] + crate::sessions::remove_view(&app, &removed.id).map_err(|e| e.to_string())?; + #[cfg(not(windows))] + let _ = _w.close(); } // Drop the per-account unread count. { @@ -357,12 +369,13 @@ pub async fn remove_account( }; crate::tray::update_badge(&app, total); crate::tray::rebuild_menu(&app); + crate::integration::broadcast(&app); Ok(()) } #[tauri::command] pub async fn rename_account( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, id: String, name: String, @@ -381,16 +394,24 @@ pub async fn rename_account( accounts::rename(&mut f, &id, name)?; accounts::save(&app, &f).map_err(|e| e.to_string())?; - if let Some(w) = app.get_webview_window(&accounts::window_label(&id)) { - let _ = w.set_title(&format!("whatRust — {name}")); + if let Some(w) = app.get_webview(&accounts::window_label(&id)) { + #[cfg(windows)] + let visible_account = + crate::sessions::selected(&app, w.window().label()).as_deref() == Some(w.label()); + #[cfg(not(windows))] + let visible_account = true; + if visible_account { + let _ = w.window().set_title(&format!("whatRust — {name}")); + } } crate::tray::rebuild_menu(&app); + crate::integration::broadcast(&app); Ok(()) } #[tauri::command] pub async fn open_account( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, id: String, ) -> Result<(), String> { @@ -404,10 +425,7 @@ pub async fn open_account( return Err(format!("unknown account: {id}")); }; // Open the window if it was closed, then show + focus it. - if app - .get_webview_window(&accounts::window_label(&acct.id)) - .is_none() - { + if app.get_webview(&accounts::window_label(&acct.id)).is_none() { crate::window::open_account_window(&app, acct, false).map_err(|e| e.to_string())?; } crate::window::show_account(&app, &accounts::window_label(&acct.id)); @@ -436,7 +454,10 @@ pub struct LockStatus { /// Read-only status for BOTH the settings window and the lock screen. Never returns /// the password hash. Allowed from any non-remote window, even while locked. #[tauri::command] -pub fn get_lock_status(window: tauri::Window, app: tauri::AppHandle) -> Result { +pub fn get_lock_status( + window: tauri::Webview, + app: tauri::AppHandle, +) -> Result { if is_remote(&window) { return Err("forbidden".into()); } @@ -460,7 +481,7 @@ pub fn get_lock_status(window: tauri::Window, app: tauri::AppHandle) -> Result Result<(), String> { @@ -531,12 +553,13 @@ pub fn disable_app_lock( c = AppLockConfig::default(); // fully reset: disabled, no hash, no biometric, default triggers applock::save(&app, &c).map_err(|e| e.to_string())?; crate::tray::rebuild_menu(&app); + crate::integration::broadcast(&app); Ok(()) } #[tauri::command] pub fn set_app_lock_options( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, lock_on_launch: bool, lock_on_hide: bool, @@ -557,7 +580,7 @@ pub fn set_app_lock_options( /// report Available, and one successful test authentication. #[tauri::command] pub fn set_biometric_enabled( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, enabled: bool, ) -> Result<(), String> { @@ -586,7 +609,7 @@ pub fn set_biometric_enabled( /// Manual "Lock now" from the settings window. Async: it builds the lock window. #[tauri::command] -pub async fn lock_app(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> { +pub async fn lock_app(window: tauri::Webview, app: tauri::AppHandle) -> Result<(), String> { if is_remote(&window) { return Err("forbidden".into()); } @@ -600,7 +623,7 @@ pub async fn lock_app(window: tauri::Window, app: tauri::AppHandle) -> Result<() /// Unlock with the password. Lock-window only; works while locked (no require_unlocked). #[tauri::command] pub fn unlock_password( - window: tauri::Window, + window: tauri::Webview, app: tauri::AppHandle, password: String, ) -> Result { @@ -623,7 +646,7 @@ pub fn unlock_password( /// Unlock with biometric. Lock-window only; works while locked. #[tauri::command] -pub fn unlock_biometric(window: tauri::Window, app: tauri::AppHandle) -> Result { +pub fn unlock_biometric(window: tauri::Webview, app: tauri::AppHandle) -> Result { if !lock::is_lock_window(&window) { return Err("forbidden".into()); } @@ -641,7 +664,7 @@ pub fn unlock_biometric(window: tauri::Window, app: tauri::AppHandle) -> Result< /// Forgot-password reset: wipe ALL account sessions + the app-lock config, then /// relaunch fresh (logged out, lock disabled). Lock-window only. #[tauri::command] -pub fn reset_app_lock(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> { +pub fn reset_app_lock(window: tauri::Webview, app: tauri::AppHandle) -> Result<(), String> { if !lock::is_lock_window(&window) { return Err("forbidden".into()); } diff --git a/src-tauri/src/dlog.rs b/src-tauri/src/dlog.rs index 5709519..9dcf95e 100644 --- a/src-tauri/src/dlog.rs +++ b/src-tauri/src/dlog.rs @@ -18,6 +18,11 @@ use std::sync::Mutex; static LOCK: Mutex<()> = Mutex::new(()); fn log_path() -> Option { + if cfg!(debug_assertions) && std::env::var("WHATRUST_SMOKE").as_deref() == Ok("1") { + return std::env::var_os("WHATRUST_SMOKE_REPORT") + .map(PathBuf::from) + .map(|p| p.with_extension("log")); + } #[cfg(windows)] let base: Option = std::env::var_os("LOCALAPPDATA").map(PathBuf::from); #[cfg(not(windows))] diff --git a/src-tauri/src/integration.rs b/src-tauri/src/integration.rs new file mode 100644 index 0000000..5a6f883 --- /dev/null +++ b/src-tauri/src/integration.rs @@ -0,0 +1,328 @@ +//! Narrow page adapter API. Never grant account/password management to WhatsApp. +use crate::{accounts, notify}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager, Webview}; +#[derive(Default)] +pub struct Integration(pub Mutex>); +#[derive(Clone, Default, Serialize)] +pub struct Adapter { + pub mounted: bool, + pub detector: bool, + #[serde(skip)] + pub last_signal: Option, + #[serde(skip)] + pub signals: u32, +} +pub fn local_url(u: &tauri::Url) -> bool { + matches!( + (u.scheme(), u.host_str()), + ("tauri", Some("localhost")) | ("http" | "https", Some("tauri.localhost")) + ) +} +pub fn trusted_local(v: &Webview) -> bool { + (matches!(v.label(), "settings" | "lock") || v.label().starts_with("shell-")) + && v.url().is_ok_and(|u| local_url(&u)) +} +fn local(v: &Webview, app: &AppHandle) -> Result<(), String> { + if !trusted_local(v) || !v.url().is_ok_and(|u| local_url(&u)) { + return Err("forbidden".into()); + } + crate::lock::require_unlocked(app) +} +pub fn remote(v: &Webview) -> Result<(), String> { + if !v.label().starts_with("wa-") + || !v + .url() + .is_ok_and(|u| u.scheme() == "https" && u.host_str() == Some("web.whatsapp.com")) + { + return Err("forbidden".into()); + } + Ok(()) +} +#[derive(Clone, Serialize)] +pub struct DisplayAccount { + pub id: String, + pub name: String, + pub unread: u32, + pub detached: bool, + pub is_default: bool, +} +pub fn display_accounts(app: &AppHandle) -> Vec { + let f = accounts::load(app); + let counts = app.state::().lock().unwrap().clone(); + let startup = accounts::startup_account(&f).map(|a| a.id.clone()); + let mut list = f.accounts.clone(); + list.sort_by_key(|a| a.order); + list.into_iter() + .map(|a| DisplayAccount { + id: a.id.clone(), + name: a.name, + unread: counts.get(&a.id).copied().unwrap_or(0), + detached: cfg!(windows) + && app + .get_webview(&accounts::window_label(&a.id)) + .is_some_and(|v| v.window().label().starts_with("detached-")), + is_default: startup.as_deref() == Some(&a.id), + }) + .collect() +} +#[derive(Serialize)] +pub struct BridgeState { + pub accounts: Vec, + pub current_id: String, + pub native_notifications: bool, + pub windows: bool, +} +fn bridge_state(app: &AppHandle, label: &str) -> BridgeState { + BridgeState { + accounts: if crate::lock::is_unlocked(app) { + display_accounts(app) + } else { + vec![] + }, + current_id: accounts::id_from_label(label).unwrap_or("").into(), + native_notifications: app.state::().native_ready(label), + windows: cfg!(windows), + } +} +#[tauri::command] +pub fn integration_state(window: Webview, app: AppHandle) -> Result { + remote(&window)?; + Ok(bridge_state(&app, window.label())) +} +pub fn broadcast(app: &AppHandle) { + for (label, v) in app.webviews() { + if label.starts_with("wa-") { + if let Ok(json) = serde_json::to_string(&bridge_state(app, &label)) { + let _ = v.eval(format!( + "window.__whatrustState && window.__whatrustState({json})" + )); + } + } else if label == "settings" { + let _ = v.eval("typeof loadAccounts === 'function' && !accountsBusy && loadAccounts()"); + } else if label.starts_with("shell-") { + let _ = v.eval("window.refresh && window.refresh()"); + } + } +} +#[tauri::command] +pub fn report_integration( + window: Webview, + app: AppHandle, + mounted: bool, + detector: bool, +) -> Result<(), String> { + remote(&window)?; + app.state::() + .0 + .lock() + .unwrap() + .entry(window.label().into()) + .or_default() + .clone_from(&Adapter { + mounted, + detector, + last_signal: None, + signals: 0, + }); + #[cfg(windows)] + crate::sessions::integration_changed(&app, window.label(), mounted); + Ok(()) +} +#[derive(Deserialize)] +pub struct MessageSignal { + pub id: String, + pub active: bool, + pub muted: Option, +} +#[tauri::command] +pub fn report_message_signal( + window: Webview, + app: AppHandle, + signal: MessageSignal, +) -> Result<(), String> { + remote(&window)?; + if !cfg!(windows) { + return Ok(()); + } + if signal.id.is_empty() || signal.id.len() > 180 { + return Err("invalid signal".into()); + } + let state = app.state::(); + let mut m = state.0.lock().unwrap(); + let a = m.entry(window.label().into()).or_default(); + // Bound abusive/faulty adapters independently of the JavaScript limiter. + if a.last_signal + .is_none_or(|t| t.elapsed() > Duration::from_secs(60)) + { + a.last_signal = Some(Instant::now()); + a.signals = 0; + } + if a.signals >= 600 { + return Ok(()); + } + a.signals += 1; + drop(m); + notify::visual_signal( + &app, + window.label(), + &signal.id, + signal.active, + signal.muted, + ); + Ok(()) +} +#[tauri::command] +pub async fn open_app_panel(window: Webview, app: AppHandle) -> Result<(), String> { + if window.label().starts_with("wa-") { + remote(&window)?; + } else { + local(&window, &app)?; + } + #[cfg(windows)] + { + crate::sessions::open_panel(&app, Some(window.window().label())) + .map_err(|e| e.to_string())?; + } + #[cfg(not(windows))] + crate::window::open_settings_window(&app); + Ok(()) +} +#[tauri::command] +pub fn close_app_panel(window: Webview, app: AppHandle) -> Result<(), String> { + local(&window, &app)?; + #[cfg(windows)] + crate::sessions::close_panel(&app); + Ok(()) +} +#[tauri::command] +pub async fn detach_account(window: Webview, app: AppHandle, id: String) -> Result<(), String> { + local(&window, &app)?; + let _g = accounts::lock_mutations(); + #[cfg(windows)] + crate::sessions::detach(&app, &id)?; + #[cfg(not(windows))] + return Err("Disponível no Windows".into()); + broadcast(&app); + Ok(()) +} +#[tauri::command] +pub async fn attach_account(window: Webview, app: AppHandle, id: String) -> Result<(), String> { + local(&window, &app)?; + let _g = accounts::lock_mutations(); + #[cfg(windows)] + crate::sessions::attach(&app, &id)?; + #[cfg(not(windows))] + return Err("Disponível no Windows".into()); + broadcast(&app); + Ok(()) +} +#[tauri::command] +pub fn reorder_accounts(window: Webview, app: AppHandle, ids: Vec) -> Result<(), String> { + local(&window, &app)?; + let _g = accounts::lock_mutations(); + let mut f = accounts::load(&app); + accounts::reorder(&mut f, &ids)?; + accounts::save(&app, &f).map_err(|e| e.to_string())?; + broadcast(&app); + crate::tray::rebuild_menu(&app); + Ok(()) +} +#[derive(Serialize)] +pub struct Diagnostics { + pub version: String, + pub accounts: Vec, + pub adapters: HashMap, + pub native: HashMap, + pub recent: Vec, +} +#[tauri::command] +pub fn notification_diagnostics(window: Webview, app: AppHandle) -> Result { + local(&window, &app)?; + let accounts = display_accounts(&app); + let native = accounts + .iter() + .map(|a| { + ( + a.id.clone(), + app.state::() + .native_ready(&accounts::window_label(&a.id)), + ) + }) + .collect(); + Ok(Diagnostics { + version: app.package_info().version.to_string(), + accounts, + adapters: app.state::().0.lock().unwrap().clone(), + native, + recent: app.state::().records(), + }) +} +#[tauri::command] +pub fn test_notification(window: Webview, app: AppHandle) -> Result { + local(&window, &app)?; + if !crate::settings::load(&app).notifications { + return Err("Ative as notificações antes do teste.".into()); + } + let label = app + .state::() + .lock() + .unwrap() + .clone(); + notify::from_account( + &app, + &label, + "Teste do whatRust", + "Se você está vendo este aviso, o Windows exibiu o teste.", + None, + notify::Source::Test, + ); + Ok("Teste solicitado. Confira o banner e a central do Windows; aceitação não confirma exibição.".into()) +} +#[tauri::command] +pub fn open_notification_settings(window: Webview, app: AppHandle) -> Result<(), String> { + local(&window, &app)?; + #[cfg(windows)] + { + use windows::core::w; + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + let result = unsafe { + ShellExecuteW( + None, + w!("open"), + w!("ms-settings:notifications"), + None, + None, + SW_SHOWNORMAL, + ) + }; + if result.0 as isize <= 32 { + return Err("Não foi possível abrir as configurações do Windows".into()); + } + } + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn local_origin_allowlist_is_exact() { + for u in [ + "http://tauri.localhost/index.html", + "tauri://localhost/index.html", + ] { + assert!(local_url(&u.parse().unwrap())); + } + for u in [ + "https://web.whatsapp.com", + "http://localhost:8080", + "https://tauri.localhost.evil.test", + ] { + assert!(!local_url(&u.parse().unwrap())); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1e2fd2..1858461 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,12 +5,17 @@ mod biometric; mod commands; mod dlog; mod downloads; +mod integration; mod links; mod lock; mod notif_icon; mod notify; mod opener; +#[cfg(windows)] +mod sessions; mod settings; +#[cfg(all(windows, debug_assertions))] +mod smoke; mod tray; mod unread; mod window; @@ -40,6 +45,12 @@ pub fn run() { std::env::set_var("JSC_useSharedArrayBuffer", "1"); } + let smoke_mode = + cfg!(debug_assertions) && std::env::var("WHATRUST_SMOKE").as_deref() == Ok("1"); + let mut context = tauri::generate_context!(); + if smoke_mode { + context.config_mut().identifier = "com.karem.whatrust.smoke".into(); + } let mut builder = tauri::Builder::default(); // single-instance MUST be registered first. @@ -78,7 +89,9 @@ pub fn run() { single_instance }; - builder = builder.plugin(single_instance.build()); + if !smoke_mode { + builder = builder.plugin(single_instance.build()); + } } builder = builder.plugin(tauri_plugin_notification::init()); @@ -113,12 +126,27 @@ pub fn run() { )); } + #[cfg(windows)] + let builder = builder.manage(sessions::Sessions::default()); + builder .manage(accounts::UnreadMap::default()) .manage(downloads::Downloads::default()) .manage(notify::Recent::default()) + .manage(integration::Integration::default()) .manage(accounts::ActiveAccount::new("wa-default".into())) .invoke_handler(tauri::generate_handler![ + integration::integration_state, + integration::report_integration, + integration::report_message_signal, + integration::open_app_panel, + integration::close_app_panel, + integration::detach_account, + integration::attach_account, + integration::reorder_accounts, + integration::notification_diagnostics, + integration::test_notification, + integration::open_notification_settings, commands::notify, commands::set_unread, commands::dlog, @@ -146,7 +174,7 @@ pub fn run() { commands::unlock_biometric, commands::reset_app_lock, ]) - .setup(|app| { + .setup(move |app| { let handle = app.handle(); // Start a fresh diagnostic log for this launch (issue #3): the only @@ -166,6 +194,12 @@ pub fn run() { // Load accounts (seeds a single `default` on first run / corrupt file). let mut f = accounts::load(handle); dlog::log("startup: account configuration loaded"); + if smoke_mode { + if f.accounts.len() == 1 { + accounts::add(&mut f, "Conta de teste B"); + } + accounts::save(handle, &f)?; + } // Backfill a persisted store_uuid for any non-default account missing one // (older state predating multi-account). Save only if something changed. @@ -253,9 +287,13 @@ pub fn run() { }); } + #[cfg(all(windows, debug_assertions))] + if smoke_mode { + smoke::start(handle); + } Ok(()) }) - .build(tauri::generate_context!()) + .build(context) .expect("error while building whatRust") .run(|_app_handle, _event| { // macOS delivers scheme links (whatsapp://…, issue #23) as an Apple diff --git a/src-tauri/src/links.rs b/src-tauri/src/links.rs index 350c479..f7e0d3e 100644 --- a/src-tauri/src/links.rs +++ b/src-tauri/src/links.rs @@ -87,14 +87,14 @@ pub fn open_from_outside(app: &AppHandle, raw: &str) { crate::window::show_main(app); } -fn active_account_window(app: &AppHandle) -> Option { +fn active_account_window(app: &AppHandle) -> Option { if let Some(active) = app.try_state::() { let label = active.lock().unwrap().clone(); - if let Some(w) = app.get_webview_window(&label) { + if let Some(w) = app.get_webview(&label) { return Some(w); } } - app.webview_windows() + app.webviews() .into_iter() .find(|(label, _)| label.starts_with("wa-")) .map(|(_, w)| w) diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index 229977c..2b4c457 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -20,7 +20,11 @@ impl LockState { /// Whether a window label should be hidden behind the lock screen. Pure, unit-tested. /// The lock window itself is never hidden; everything user-facing (`wa-*` + settings) is. pub fn should_hide(label: &str) -> bool { - label != "lock" && (label.starts_with("wa-") || label == "settings") + label != "lock" + && (label.starts_with("wa-") + || label == "settings" + || label == "main" + || label.starts_with("detached-")) } /// Pure predicate behind the lock-window IPC guard. @@ -28,8 +32,8 @@ pub fn is_lock_label(label: &str) -> bool { label == "lock" } -pub fn is_lock_window(window: &tauri::Window) -> bool { - is_lock_label(window.label()) +pub fn is_lock_window(window: &tauri::Webview) -> bool { + is_lock_label(window.label()) && crate::integration::trusted_local(window) } /// Read the current unlocked flag. Defaults to `true` (unlocked) when the state is @@ -64,7 +68,7 @@ pub fn lock_now(app: &AppHandle) { } *state.unlocked.lock().unwrap() = false; let mut hidden = Vec::new(); - for (label, w) in app.webview_windows() { + for (label, w) in app.windows() { if should_hide(&label) && w.is_visible().unwrap_or(false) { let _ = w.hide(); hidden.push(label); @@ -90,9 +94,13 @@ pub fn unlock(app: &AppHandle) { crate::window::show_active(app); } else { for label in hidden { - crate::window::show_account(app, &label); + if let Some(w) = app.get_window(&label) { + let _ = w.show(); + } } } + #[cfg(windows)] + crate::sessions::restore_pending(app); } /// Create-or-show the dedicated lock window. `content_protected` blocks screen diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 71f4bd3..b85fd3a 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -1,101 +1,211 @@ -use std::collections::HashMap; +//! Account-scoped delivery policy and bounded, content-free session diagnostics. +use serde::Serialize; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::Path; -use std::sync::Mutex; -use std::time::{Duration, Instant}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, +}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager}; -/// Where a message notification came from. Logged (never the content) so a -/// report can tell which path WhatsApp used on that machine (issue #3). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum Source { - /// bridge.js: the page's `Notification` / `showNotification` shims. Page, - /// WebView2's own `NotificationReceived` (Windows): notifications raised - /// where the injected script can't reach, such as a worker. - #[cfg_attr(not(windows), allow(dead_code))] WebView2, - /// The unread count went up and no notification came with it. UnreadFallback, + Visual, + Test, } -/// Recent notifications, per account window, so the same alert arriving by two -/// paths shows once and the unread fallback stays quiet when WhatsApp spoke up. +#[derive(Clone, Serialize)] +pub struct Notice { + pub id: String, + pub account_id: String, + pub source: Source, + pub time: u64, + pub status: String, + pub reason: Option, +} #[derive(Default)] -pub struct Recent(Mutex); - +pub struct Recent(Mutex); #[derive(Default)] -struct RecentInner { - /// Last notification of any kind, per window label. - last: HashMap, - /// Identical title+body seen recently, per window label. +struct Inner { + delivered: HashMap, + coverage: HashMap>, seen: HashMap<(String, String), Instant>, + native: HashSet, + records: VecDeque, + #[cfg(windows)] + toasts: HashMap, } - -/// Window in which an identical alert counts as a duplicate. -const DEDUP: Duration = Duration::from_millis(3500); -/// How long the unread fallback waits for a real notification to arrive. +static NEXT: AtomicU64 = AtomicU64::new(1); +pub fn event_id() -> String { + format!("event-{}", NEXT.fetch_add(1, Ordering::Relaxed)) +} +const DEDUP: Duration = Duration::from_secs(60); pub const FALLBACK_WAIT: Duration = Duration::from_secs(3); -/// A real notification this close before an unread increase also covers it. -const FALLBACK_LOOKBACK: Duration = Duration::from_secs(5); - impl Recent { - /// Record an alert; false if it is a duplicate of one just shown. - fn admit(&self, label: &str, title: &str, body: &str, now: Instant) -> bool { - let mut inner = self.0.lock().unwrap_or_else(|e| e.into_inner()); - inner.seen.retain(|_, t| now.duration_since(*t) <= DEDUP); - let key = (label.to_string(), format!("{title}\u{1f}{body}")); - if inner.seen.contains_key(&key) { + fn reserve(&self, label: &str, identity: &str, now: Instant) -> bool { + let mut i = self.0.lock().unwrap_or_else(|e| e.into_inner()); + i.seen.retain(|_, t| now.duration_since(*t) <= DEDUP); + // Bound both memory and burst bookkeeping. IDs are not message content. + if i.seen.len() >= 2048 { + i.seen.clear(); + } + if i.seen.contains_key(&(label.into(), identity.into())) { return false; } - inner.seen.insert(key, now); - inner.last.insert(label.to_string(), now); + i.seen.insert((label.into(), identity.into()), now); true } - - /// Whether `label` had a notification since `since`. - fn notified_since(&self, label: &str, since: Instant) -> bool { - let inner = self.0.lock().unwrap_or_else(|e| e.into_inner()); - inner.last.get(label).is_some_and(|t| *t >= since) + pub fn notified_since(&self, label: &str, since: Instant) -> bool { + self.0 + .lock() + .unwrap() + .delivered + .get(label) + .is_some_and(|(_, t)| *t >= since) + } + pub fn consume_coverage(&self, label: &str, since: Instant) -> bool { + let mut inner = self.0.lock().unwrap(); + let receipts = inner.coverage.entry(label.into()).or_default(); + while receipts.front().is_some_and(|(_, time)| *time < since) { + receipts.pop_front(); + } + receipts.pop_front().is_some() + } + pub fn native_ready(&self, label: &str) -> bool { + self.0.lock().unwrap().native.contains(label) + } + pub fn set_native(&self, label: &str, ready: bool) { + let mut i = self.0.lock().unwrap(); + if ready { + i.native.insert(label.into()); + } else { + i.native.remove(label); + } + } + pub fn records(&self) -> Vec { + self.0 + .lock() + .unwrap() + .records + .iter() + .rev() + .cloned() + .collect() + } + fn record(&self, label: &str, id: &str, source: Source, status: &str, reason: Option<&str>) { + let mut i = self.0.lock().unwrap(); + i.records.push_back(Notice { + id: id.into(), + account_id: crate::accounts::id_from_label(label).unwrap_or("").into(), + source, + time: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + status: status.into(), + reason: reason.map(str::to_string), + }); + while i.records.len() > 100 { + i.records.pop_front(); + } + } + fn finish(&self, label: &str, id: &str, identity: &str, success: bool) { + let mut i = self.0.lock().unwrap(); + let success = success && !i.records.iter().any(|r| r.id == id && r.status == "failed"); + let real_source = i + .records + .iter() + .find(|r| r.id == id) + .is_none_or(|r| matches!(r.source, Source::Page | Source::WebView2)); + if success && real_source { + let receipts = i.coverage.entry(label.into()).or_default(); + receipts.push_back((id.into(), Instant::now())); + while receipts.len() > 256 { + receipts.pop_front(); + } + i.delivered + .insert(label.into(), (id.into(), Instant::now())); + } else if !success { + if let Some(receipts) = i.coverage.get_mut(label) { + receipts.retain(|(key, _)| key != id); + } + i.seen.remove(&(label.into(), identity.into())); + if i.delivered.get(label).is_some_and(|(last, _)| last == id) { + i.delivered.remove(label); + } + } + if let Some(r) = i.records.iter_mut().find(|r| r.id == id) { + r.status = if success { "submitted" } else { "failed" }.into(); + r.reason = if success { + None + } else { + Some("windows_rejected".into()) + }; + } + } + fn lifecycle(&self, id: &str, status: &str) { + if let Some(r) = self + .0 + .lock() + .unwrap() + .records + .iter_mut() + .find(|r| r.id == id) + { + r.status = status.into(); + } } } -/// Should an unread change trigger the fallback? Only a real increase over a -/// count we already knew, only once the page has settled (a launch or reload -/// takes the title from "WhatsApp" to "(3) WhatsApp" while it syncs, which is -/// not news), and only while the window isn't in front of the user. -pub fn fallback_due(previous: Option, now: u32, window_focused: bool, settled: bool) -> bool { - settled && matches!(previous, Some(p) if now > p) && !window_focused +pub fn fallback_due(previous: Option, now: u32, focused: bool, settled: bool) -> bool { + settled && matches!(previous,Some(p)if now>p) && !focused } - -/// Title and body of the fallback toast. Counts only; WhatsApp's title gives -/// the number of unread chats, and nothing about their content. -pub fn fallback_text(unread_chats: u32) -> (String, String) { - let body = if unread_chats == 1 { - "You have a new message.".to_string() - } else { - format!("You have new messages in {unread_chats} chats.") - }; - ("New message".to_string(), body) +pub fn fallback_text(n: u32) -> (String, String) { + ( + "New message".into(), + if n == 1 { + "You have a new message.".into() + } else { + format!("You have new messages in {n} chats.") + }, + ) } -/// Prefix the account name when more than one account exists, so a toast says -/// which number it is about (e.g. "Work: Alice"). -fn attributed_title(app: &AppHandle, label: &str, title: &str) -> String { - let f = crate::accounts::load(app); - if f.accounts.len() > 1 { - if let Some(id) = crate::accounts::id_from_label(label) { - if let Some(acct) = f.accounts.iter().find(|a| a.id == id) { - return format!("{}: {}", acct.name, title); - } +pub fn suppression( + s: &crate::settings::Settings, + unlocked: bool, + focused: bool, + active: bool, + source: Source, + muted: Option, +) -> Option<&'static str> { + if !unlocked { + return Some("locked"); + } + if !s.notifications { + return Some("notifications_disabled"); + } + if source != Source::Test && focused && !s.notify_when_focused { + return Some("foreground_disabled"); + } + if source != Source::Test && active && focused && !s.notify_active_chat { + return Some("active_chat_disabled"); + } + if source == Source::Visual && s.respect_muted { + match muted { + Some(false) => {} + Some(true) => return Some("muted"), + None => return Some("mute_state_unknown"), } } - title.to_string() + None } -/// A message notification from an account window, by any route. Applies the -/// lock and the Notifications setting, drops duplicates, then shows the toast, -/// with the sender's picture when `icon` (base64 PNG) is given and valid. -/// No message content is logged (PII) — only the route and the outcome. pub fn from_account( app: &AppHandle, label: &str, @@ -104,118 +214,220 @@ pub fn from_account( icon: Option<&str>, source: Source, ) { - crate::dlog::log(&format!("notify: {source:?} notification")); - // While locked, suppress notifications entirely so message previews don't - // leak to the OS notification center / lock screen. The tray unread badge - // still updates via set_unread (a count only, no content). - if !crate::lock::is_unlocked(app) { - crate::dlog::log("notify: suppressed, app is locked"); - return; - } - if !crate::settings::load(app).notifications { - crate::dlog::log("notify: suppressed, notifications are off in settings"); - return; - } - if let Some(recent) = app.try_state::() { - if !recent.admit(label, title, body, Instant::now()) { - crate::dlog::log("notify: duplicate dropped"); - return; - } - } - // Only written once the toast is certain to show (unlocked, enabled, new). - let icon = icon.and_then(|b64| crate::notif_icon::store(app, b64)); - show_account_click( + from_event( app, label, - &attributed_title(app, label, title), + title, body, - icon.as_deref(), + icon, + source, + &event_id(), + false, + None, ); } -/// Account-attributed message toast; activation targets the exact account window. -fn show_account_click(app: &AppHandle, label: &str, title: &str, body: &str, icon: Option<&Path>) { +#[allow(clippy::too_many_arguments)] +pub fn from_event( + app: &AppHandle, + label: &str, + title: &str, + body: &str, + icon: Option<&str>, + source: Source, + identity: &str, + active: bool, + muted: Option, +) { + #[cfg(windows)] + let focused = crate::sessions::account_focused(app, label); + #[cfg(not(windows))] + let focused = app.get_webview(label).is_some_and(|v| { + v.window().is_focused().unwrap_or(false) && v.window().is_visible().unwrap_or(false) + }); + let s = crate::settings::load(app); + let id = event_id(); + let r = app.state::(); + if let Some(reason) = suppression( + &s, + crate::lock::is_unlocked(app), + focused, + active, + source, + muted, + ) { + r.record(label, &id, source, "suppressed", Some(reason)); + crate::dlog::log(&format!("notification: {source:?} suppressed ({reason})")); + #[cfg(windows)] + if source == Source::WebView2 { + crate::window::native_report(app, identity, "discard"); + } + return; + } + if !r.reserve(label, identity, Instant::now()) { + return; + } + r.record(label, &id, source, "pending", None); + let title: String = title.chars().take(256).collect(); + let body: String = body.chars().take(4096).collect(); + let title = attributed_title(app, label, &title); + let icon = icon.and_then(|b64| crate::notif_icon::store(app, b64)); #[cfg(windows)] windows_toast( app, - title, - body, - icon.map(Path::to_path_buf), - Some(label.to_string()), + &title, + &body, + icon, + Some(label.into()), + Some(Delivery { + label: label.into(), + id, + identity: identity.into(), + native: source == Source::WebView2, + }), ); #[cfg(not(windows))] - show_with_icon(app, title, body, icon); + { + use tauri_plugin_notification::NotificationExt; + let mut b = app.notification().builder().title(title).body(body); + if let Some(p) = icon { + b = b.icon(p.to_string_lossy()); + } + r.finish(label, &id, identity, b.show().is_ok()); + } +} +fn attributed_title(app: &AppHandle, label: &str, title: &str) -> String { + let f = crate::accounts::load(app); + if f.accounts.len() > 1 { + if let Some(a) = f + .accounts + .iter() + .find(|a| Some(a.id.as_str()) == crate::accounts::id_from_label(label)) + { + return format!("{}: {title}", a.name); + } + } + title.into() } -/// The unread count of `label` went up while its window wasn't focused. Wait -/// briefly for WhatsApp's own notification; if none came (it raised it where -/// whatRust can't see, e.g. its service worker), say so with a generic toast. -pub fn unread_increased(app: &AppHandle, label: &str, unread_chats: u32) { - let (app, label) = (app.clone(), label.to_string()); +pub fn unread_increased(app: &AppHandle, label: &str, n: u32) { + let app = app.clone(); + let label = label.to_string(); let started = Instant::now(); std::thread::spawn(move || { std::thread::sleep(FALLBACK_WAIT); - let covered = app - .try_state::() - .is_some_and(|r| r.notified_since(&label, started - FALLBACK_LOOKBACK)); - if covered { + if app + .state::() + .notified_since(&label, started - Duration::from_secs(5)) + { return; } - // Read in the meantime: nothing to announce. - let still_unread = crate::accounts::id_from_label(&label).is_some_and(|id| { + let unread = crate::accounts::id_from_label(&label).is_some_and(|id| { app.state::() .lock() .unwrap() .get(id) - .is_some_and(|n| *n >= unread_chats) + .is_some_and(|c| *c >= n) }); - if !still_unread { + if unread { + let (t, b) = fallback_text(n); + from_account(&app, &label, &t, &b, None, Source::UnreadFallback); + } + }); +} + +pub fn visual_signal( + app: &AppHandle, + label: &str, + identity: &str, + active: bool, + muted: Option, +) { + let app = app.clone(); + let label = label.to_string(); + let identity: String = identity.chars().take(180).collect(); + // The queue is bounded by the adapter's batch/rate limit; native delivery + // gets first refusal. No content is taken from the DOM or kept in history. + tauri::async_runtime::spawn(async move { + let start = Instant::now(); + tokio::time::sleep(Duration::from_millis(1800)).await; + if app + .state::() + .consume_coverage(&label, start - Duration::from_millis(300)) + { return; } - let (title, body) = fallback_text(unread_chats); - from_account(&app, &label, &title, &body, None, Source::UnreadFallback); + from_event( + &app, + &label, + "Nova mensagem", + "Você recebeu uma mensagem no WhatsApp.", + None, + Source::Visual, + &format!("visual:{identity}"), + active, + muted, + ); }); } -/// Show a native notification. -/// -/// Windows raises the WinRT toast itself (the way ZapFast does) instead of going -/// through `tauri-plugin-notification`: the plugin fires the toast on a detached -/// task and throws the result away, so a failure there could never be seen -/// (issue #3). Here the result is logged, and clicking the toast brings whatRust -/// forward. No message content is logged (PII) — only that a toast was shown. pub fn show(app: &AppHandle, title: &str, body: &str) { show_with_icon(app, title, body, None); } - -/// Native notification associated with the account that produced it. pub fn show_for_account(app: &AppHandle, label: &str, title: &str, body: &str) { + if !crate::lock::is_unlocked(app) { + return; + } #[cfg(windows)] - windows_toast(app, title, body, None, Some(label.to_string())); + windows_toast(app, title, body, None, Some(label.into()), None); #[cfg(not(windows))] show(app, title, body); } - -/// `show` with an optional picture (a PNG file): round on a Windows toast, the -/// notification icon on Linux. macOS notifications always carry the app icon. pub fn show_with_icon(app: &AppHandle, title: &str, body: &str, icon: Option<&Path>) { #[cfg(windows)] - { - windows_toast(app, title, body, icon.map(Path::to_path_buf), None); - } + windows_toast(app, title, body, icon.map(Path::to_path_buf), None, None); #[cfg(not(windows))] { use tauri_plugin_notification::NotificationExt; - let mut builder = app.notification().builder().title(title).body(body); - if let Some(icon) = icon { - builder = builder.icon(icon.to_string_lossy()); + let mut b = app.notification().builder().title(title).body(body); + if let Some(p) = icon { + b = b.icon(p.to_string_lossy()); } - let r = builder.show(); - crate::dlog::log(&format!( - "notify::show dispatched{} (plugin returned {r:?})", - if icon.is_some() { " with picture" } else { "" } - )); + let _ = b.show(); + } +} +#[derive(Clone)] +#[cfg(windows)] +struct Delivery { + label: String, + id: String, + identity: String, + native: bool, +} + +fn escape_xml(s: &str) -> String { + s.chars().filter(|c| matches!(*c, '\t'|'\n'|'\r'|'\u{20}'..='\u{d7ff}'|'\u{e000}'..='\u{fffd}'|'\u{10000}'..='\u{10ffff}')).collect::().replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(windows)] +pub fn cancel_native(app: &AppHandle, identity: &str) { + use windows::{core::HSTRING, UI::Notifications::ToastNotificationManager}; + if let Ok(history) = ToastNotificationManager::History() { + let _ = history.RemoveGroupedTagWithId( + &HSTRING::from(identity), + &HSTRING::from("messages"), + &HSTRING::from(&app.config().identifier), + ); } + native_report_if_needed(app, identity, "closed"); +} +#[cfg(windows)] +fn native_report_if_needed(app: &AppHandle, id: &str, status: &str) { + crate::window::native_report(app, id, status); } #[cfg(windows)] @@ -224,107 +436,252 @@ fn windows_toast( title: &str, body: &str, icon: Option, - account_label: Option, + account: Option, + delivery: Option, ) { - use tauri_winrt_notification::{IconCrop, Toast}; - // The AUMID aumid.rs registers at startup, so the toast is attributed to - // whatRust and allowed to render. - let aumid = app.config().identifier.clone(); - let (app, title, body) = (app.clone(), title.to_string(), body.to_string()); - // WinRT calls can block briefly; keep them off the UI/IPC thread. + use windows::{ + core::{IInspectable, HSTRING}, + Data::Xml::Dom::XmlDocument, + Foundation::TypedEventHandler, + UI::Notifications::{ + ToastDismissedEventArgs, ToastFailedEventArgs, ToastNotification, + ToastNotificationManager, + }, + }; + let app = app.clone(); + let title = title.to_string(); + let body = body.to_string(); std::thread::spawn(move || { - let click = app.clone(); - let click_label = account_label.clone(); - let mut toast = Toast::new(&aumid).title(&title).text1(&body); - if let Some(icon) = &icon { - toast = toast.icon(icon, IconCrop::Circular, ""); + if !crate::lock::is_unlocked(&app) { + if let Some(d) = &delivery { + app.state::() + .finish(&d.label, &d.id, &d.identity, false); + if d.native { + crate::window::native_report(&app, &d.identity, "discard"); + } + } + return; } - let result = toast - .on_activated(move |_| { - let app = click.clone(); - let account_label = click_label.clone(); - let _ = click.run_on_main_thread(move || { - if let Some(label) = account_label { - crate::window::show_account(&app, &label); + let send = (|| -> windows::core::Result { + let document = XmlDocument::new()?; + let image = icon + .as_ref() + .and_then(|p| tauri::Url::from_file_path(p).ok()) + .map(|u| { + format!( + "", + escape_xml(u.as_str()) + ) + }) + .unwrap_or_default(); + document.LoadXml(&HSTRING::from(format!("{}{}{image}",escape_xml(&title),escape_xml(&body))))?; + let toast = ToastNotification::CreateToastNotification(&document)?; + if let Some(d) = &delivery { + toast.SetTag(&HSTRING::from( + d.identity.chars().take(64).collect::(), + ))?; + toast.SetGroup(&HSTRING::from("messages"))?; + } + let click = app.clone(); + let clicked = delivery.clone(); + toast.Activated(&TypedEventHandler::::new( + move |_, _| { + if let Some(d) = &clicked { + click.state::().lifecycle(&d.id, "activated"); + if d.native { + crate::window::native_report(&click, &d.identity, "clicked"); + } + } + if let Some(label) = &account { + crate::window::show_account(&click, label); } else { - crate::window::show_main(&app); + crate::window::show_main(&click); + } + Ok(()) + }, + ))?; + let dismiss = app.clone(); + let dismissed = delivery.clone(); + toast.Dismissed(&TypedEventHandler::< + ToastNotification, + ToastDismissedEventArgs, + >::new(move |_, _| { + if let Some(d) = &dismissed { + let r = dismiss.state::(); + r.lifecycle(&d.id, "dismissed"); + r.0.lock().unwrap().toasts.remove(&d.id); + if d.native { + crate::window::native_report(&dismiss, &d.identity, "closed"); } - }); + } Ok(()) - }) - .show(); - match result { - Ok(()) if icon.is_some() => crate::dlog::log("notify::show: toast shown with picture"), - Ok(()) => crate::dlog::log("notify::show: toast shown"), - Err(e) => crate::dlog::log(&format!("notify::show: toast FAILED: {e}")), + }))?; + let fail = app.clone(); + let failed = delivery.clone(); + toast.Failed( + &TypedEventHandler::::new(move |_, _| { + if let Some(d) = &failed { + let r = fail.state::(); + r.finish(&d.label, &d.id, &d.identity, false); + r.0.lock().unwrap().toasts.remove(&d.id); + if d.native { + crate::window::native_report(&fail, &d.identity, "discard"); + } + } + crate::dlog::log("notification: Windows reported asynchronous failure"); + Ok(()) + }), + )?; + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from( + &app.config().identifier, + ))? + .Show(&toast)?; + Ok(toast) + })(); + if let Some(d) = &delivery { + let recent = app.state::(); + recent.finish(&d.label, &d.id, &d.identity, send.is_ok()); + if let Ok(toast) = &send { + let mut i = recent.0.lock().unwrap(); + if i.toasts.len() >= 100 { + if let Some(key) = i.toasts.keys().next().cloned() { + i.toasts.remove(&key); + } + } + i.toasts.insert(d.id.clone(), toast.clone()); + } + if d.native { + crate::window::native_report( + &app, + &d.identity, + if send.is_ok() { "shown" } else { "discard" }, + ); + } + } + if let Err(error) = &send { + crate::dlog::log(&format!("notification: HRESULT={:#x}", error.code().0)); } + crate::dlog::log(if send.is_ok() { + "notification: submitted to Windows (banner visibility unconfirmed)" + } else { + "notification: Windows rejected delivery" + }); }); } #[cfg(test)] mod tests { use super::*; - #[test] - fn the_same_alert_by_two_routes_shows_once() { + fn notification_xml_cannot_be_broken_by_message_content() { + assert_eq!(escape_xml("<&\"'\0"), "<&"'"); + } + #[test] + fn each_real_receipt_covers_only_one_visual_signal() { let r = Recent::default(); - let t0 = Instant::now(); - assert!(r.admit("wa-default", "Alice", "hi", t0)); - assert!(!r.admit("wa-default", "Alice", "hi", t0 + Duration::from_secs(1))); - // A different message, or the same text from another account, is new. - assert!(r.admit( - "wa-default", - "Alice", - "hi again", - t0 + Duration::from_secs(1) - )); - assert!(r.admit("wa-acct-2", "Alice", "hi", t0 + Duration::from_secs(1))); - // The same text later is a new message, not a duplicate. - assert!(r.admit( - "wa-default", - "Alice", - "hi", - t0 + DEDUP + Duration::from_millis(1) - )); + let t = Instant::now(); + r.record("wa-a", "one", Source::WebView2, "pending", None); + r.finish("wa-a", "one", "native-1", true); + assert!(r.consume_coverage("wa-a", t)); + assert!(!r.consume_coverage("wa-a", t)); + r.record("wa-a", "visual", Source::Visual, "pending", None); + r.finish("wa-a", "visual", "visual-2", true); + assert!(!r.consume_coverage("wa-a", t)); } - #[test] - fn a_real_notification_covers_the_unread_fallback() { + fn asynchronous_failure_removes_the_delivery_receipt() { let r = Recent::default(); - let t0 = Instant::now(); - assert!(!r.notified_since("wa-default", t0)); - r.admit("wa-default", "Alice", "hi", t0); - assert!(r.notified_since("wa-default", t0)); - assert!(!r.notified_since("wa-acct-2", t0), "per account"); - assert!(!r.notified_since("wa-default", t0 + Duration::from_millis(1))); + let t = Instant::now(); + r.record("wa-a", "one", Source::WebView2, "pending", None); + r.finish("wa-a", "one", "native-1", true); + r.finish("wa-a", "one", "native-1", false); + assert!(!r.consume_coverage("wa-a", t)); + assert!(!r.notified_since("wa-a", t)); + r.finish("wa-a", "one", "native-1", true); + assert!(!r.notified_since("wa-a", t)); } - #[test] - fn only_a_real_increase_while_away_triggers_the_fallback() { - assert!(fallback_due(Some(1), 2, false, true)); - assert!(fallback_due(Some(0), 1, false, true)); - assert!(!fallback_due(Some(2), 2, false, true), "no change"); - assert!(!fallback_due(Some(3), 1, false, true), "messages were read"); - assert!( - !fallback_due(None, 4, false, true), - "first report after launch" + fn failed_delivery_does_not_cover_fallback_and_can_retry() { + let r = Recent::default(); + let t = Instant::now(); + assert!(r.reserve("wa-a", "id", t)); + r.record("wa-a", "receipt", Source::Page, "pending", None); + r.finish("wa-a", "receipt", "id", false); + assert!(!r.notified_since("wa-a", t)); + assert!(r.reserve("wa-a", "id", t)); + } + #[test] + fn identity_is_account_scoped_and_repeated_content_does_not_matter() { + let r = Recent::default(); + let t = Instant::now(); + assert!(r.reserve("wa-a", "msg1", t)); + assert!(!r.reserve("wa-a", "msg1", t)); + assert!(r.reserve("wa-a", "msg2", t)); + assert!(r.reserve("wa-b", "msg1", t)); + } + #[test] + fn success_covers_only_its_account() { + let r = Recent::default(); + let t = Instant::now(); + r.finish("wa-a", "r", "i", true); + assert!(r.notified_since("wa-a", t)); + assert!(!r.notified_since("wa-b", t)); + } + #[test] + fn foreground_and_active_default_to_enabled() { + let s = crate::settings::Settings::default(); + assert_eq!( + suppression(&s, true, true, true, Source::Visual, Some(false)), + None ); - assert!( - !fallback_due(Some(1), 2, true, true), - "the user is looking at it" + } + #[test] + fn silence_lock_and_unknown_are_conservative() { + let s = crate::settings::Settings::default(); + assert_eq!( + suppression(&s, false, true, true, Source::Visual, Some(false)), + Some("locked") ); - assert!( - !fallback_due(Some(0), 3, false, false), - "the page is still syncing after a launch or reload" + assert_eq!( + suppression(&s, true, true, true, Source::Visual, None), + Some("mute_state_unknown") + ); + assert_eq!( + suppression(&s, true, true, true, Source::Visual, Some(true)), + Some("muted") ); } - #[test] - fn the_fallback_says_nothing_about_content() { + fn settings_can_suppress_foreground_or_current_chat() { + let mut s = crate::settings::Settings { + notify_active_chat: false, + ..Default::default() + }; assert_eq!( - fallback_text(1), - ("New message".into(), "You have a new message.".into()) + suppression(&s, true, true, true, Source::Visual, Some(false)), + Some("active_chat_disabled") ); - assert_eq!(fallback_text(3).1, "You have new messages in 3 chats."); + s.notify_when_focused = false; + assert_eq!( + suppression(&s, true, true, false, Source::Page, None), + Some("foreground_disabled") + ); + } + #[test] + fn history_is_bounded_and_has_no_content() { + let r = Recent::default(); + for _ in 0..150 { + r.record("wa-a", "id", Source::Page, "pending", None); + } + assert_eq!(r.records().len(), 100); + let value = serde_json::to_value(r.records()).unwrap(); + assert!(value[0].get("body").is_none()); + } + #[test] + fn old_unread_fallback_requires_settled_background_increase() { + assert!(fallback_due(Some(1), 2, false, true)); + assert!(!fallback_due(None, 2, false, true)); + assert!(!fallback_due(Some(1), 2, true, true)); + assert!(!fallback_due(Some(1), 2, false, false)); } } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs new file mode 100644 index 0000000..d2f3527 --- /dev/null +++ b/src-tauri/src/sessions.rs @@ -0,0 +1,443 @@ +//! Windows account ownership. One live WebView per profile; detach moves it. +use crate::accounts::{self, Account, ActiveAccount}; +use std::collections::HashSet; +use std::sync::Mutex; +use tauri::{ + menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}, + AppHandle, Manager, WebviewBuilder, WebviewUrl, Window, WindowBuilder, +}; + +#[derive(Default)] +pub struct Sessions { + main_active: Mutex>, + fallback: Mutex>, + pending: Mutex>, + panel_parent: Mutex>, +} + +fn state(app: &AppHandle) -> tauri::State<'_, Sessions> { + app.state::() +} + +pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { + if let Some(w) = app.get_window("main") { + return Ok(w); + } + let w = WindowBuilder::new(app, "main") + .title("whatRust") + .inner_size(1100., 800.) + .min_inner_size(560., 480.) + .icon(tauri::image::Image::from_bytes(include_bytes!( + "../icons/128x128.png" + ))?)? + .visible(!hidden) + .build()?; + install_menu(app, &w)?; + let shell = WebviewBuilder::new("shell-main", WebviewUrl::App("shell.html".into())) + .data_directory(app.path().app_data_dir()?.join("management")) + .on_navigation(crate::integration::local_url); + w.add_child( + shell, + tauri::LogicalPosition::new(0., 0.), + tauri::LogicalSize::new(1100., 800.), + )?; + let a = app.clone(); + w.on_window_event(move |event| match event { + tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. } => { + layout(&a, "main") + } + tauri::WindowEvent::Focused(true) => { + if let Some(label) = state(&a).main_active.lock().unwrap().clone() { + *a.state::().lock().unwrap() = label; + } + } + tauri::WindowEvent::CloseRequested { api, .. } => { + if crate::settings::load(&a).close_to_tray { + api.prevent_close(); + if crate::applock::load(&a).lock_on_hide { + crate::lock::lock_now(&a); + } + if let Some(w) = a.get_window("main") { + let _ = w.hide(); + } + } else { + a.exit(0); + } + } + _ => {} + }); + Ok(w) +} + +fn install_menu(app: &AppHandle, w: &Window) -> tauri::Result<()> { + let accounts = MenuItemBuilder::with_id("app.accounts", "Contas") + .accelerator("Ctrl+Shift+A") + .build(app)?; + let settings = MenuItemBuilder::with_id("app.settings", "Configurações do whatRust") + .accelerator("Ctrl+,") + .build(app)?; + let back = MenuItemBuilder::with_id("app.back", "Voltar ao WhatsApp").build(app)?; + let reload = MenuItemBuilder::with_id("app.reload", "Recarregar conta").build(app)?; + let lock = MenuItemBuilder::with_id("app.lock", "Bloquear").build(app)?; + let quit = MenuItemBuilder::with_id("app.quit", "Sair do whatRust").build(app)?; + let submenu = SubmenuBuilder::new(app, "whatRust") + .items(&[&accounts, &settings, &back, &reload, &lock, &quit]) + .build()?; + let menu = MenuBuilder::new(app).item(&submenu).build()?; + w.set_menu(menu)?; + let a = app.clone(); + let parent = w.label().to_string(); + w.on_menu_event(move |_, event| { + let a=a.clone(); let parent=parent.clone(); let id=event.id().as_ref().to_string(); + tauri::async_runtime::spawn_blocking(move || { + match id.as_str() { + "app.accounts" | "app.settings" => { + let _=open_panel(&a,Some(&parent)); + if id=="app.accounts" { if let Some(v)=a.get_webview("settings") { let _=v.eval("window.dispatchEvent(new HashChangeEvent('hashchange'));document.querySelector('a[href=\"#sec-contas\"]').click()"); } } + }, + "app.back"=>close_panel(&a), + "app.reload"=> { if crate::lock::is_unlocked(&a) { if let Some(label)=selected(&a,&parent) { if let Some(v)=a.get_webview(&label) { let _=v.reload(); } } } }, + "app.lock"=> { if crate::applock::load(&a).is_active() { crate::lock::lock_now(&a); } }, + "app.quit"=>a.exit(0), + _=>{} + } + }); + }); + Ok(()) +} + +pub fn selected(app: &AppHandle, parent: &str) -> Option { + if parent == "main" { + return state(app).main_active.lock().unwrap().clone(); + } + app.webviews() + .into_iter() + .find(|(l, v)| l.starts_with("wa-") && v.window().label() == parent) + .map(|(l, _)| l) +} + +pub fn open_account(app: &AppHandle, account: &Account, hidden: bool) -> tauri::Result<()> { + let parent = initialize(app, hidden)?; + let label = accounts::window_label(&account.id); + if app.get_webview(&label).is_none() { + // Reuse every existing download/media/profile hook. Move the view BEFORE + // destroying its temporary native window; never recreate the session. + let _staged = crate::window::build_account_window(app, account, true)?; + let original = app.get_window(&label).ok_or(tauri::Error::WindowNotFound)?; + let view = app + .get_webview(&label) + .ok_or(tauri::Error::WindowNotFound)?; + view.reparent(&parent)?; + original.destroy()?; + let _ = view.hide(); + } + if state(app).main_active.lock().unwrap().is_none() { + *state(app).main_active.lock().unwrap() = Some(label.clone()); + } + if !hidden { + activate(app, &label); + } else { + layout(app, "main"); + } + Ok(()) +} + +pub fn activate(app: &AppHandle, label: &str) { + if !crate::lock::is_unlocked(app) { + *state(app).pending.lock().unwrap() = Some(label.to_string()); + crate::lock::show_lock_window(app); + return; + } + let Some(view) = app.get_webview(label) else { + return; + }; + let parent = view.window(); + if parent.label() == "main" { + *state(app).main_active.lock().unwrap() = Some(label.to_string()); + } + if let Some(panel) = app.get_webview("settings") { + if panel.window().label() == parent.label() { + let _ = panel.hide(); + *state(app).panel_parent.lock().unwrap() = None; + } + } + for (other, v) in app.webviews() { + if other.starts_with("wa-") && v.window().label() == parent.label() && other != label { + let _ = v.hide(); + } + } + *app.state::().lock().unwrap() = label.to_string(); + layout(app, parent.label()); + let name = accounts::id_from_label(label) + .and_then(|id| { + accounts::load(app) + .accounts + .into_iter() + .find(|a| a.id == id) + }) + .map(|a| a.name) + .unwrap_or_else(|| "WhatsApp".into()); + let _ = parent.set_title(&format!("whatRust — {name}")); + let _ = view.show(); + let _ = parent.show(); + let _ = parent.unminimize(); + let _ = parent.set_focus(); + let _ = view.set_focus(); + crate::integration::broadcast(app); +} + +pub fn layout(app: &AppHandle, parent: &str) { + let Some(w) = app.get_window(parent) else { + return; + }; + let Ok(size) = w.inner_size() else { return }; + let scale = w.scale_factor().unwrap_or(1.0); + let size = size.to_logical::(scale); + let active = selected(app, parent); + let integrated = active.as_ref().is_some_and(|l| { + app.state::() + .0 + .lock() + .unwrap() + .get(l) + .is_some_and(|a| a.mounted) + }); + if integrated { + let _ = w.hide_menu(); + } else { + let _ = w.show_menu(); + } + let top = if parent == "main" + && active + .as_ref() + .is_some_and(|l| state(app).fallback.lock().unwrap().contains(l)) + { + 48. + } else { + 0. + }; + for (label, v) in app.webviews() { + if v.window().label() != parent { + continue; + } + let y = if label.starts_with("wa-") { top } else { 0. }; + let _ = v.set_bounds(tauri::Rect { + position: tauri::LogicalPosition::new(0., y).into(), + size: tauri::LogicalSize::new(size.width, (size.height - y).max(1.)).into(), + }); + } +} + +pub fn integration_changed(app: &AppHandle, label: &str, mounted: bool) { + let s = state(app); + let mut f = s.fallback.lock().unwrap(); + if mounted { + f.remove(label); + } else { + f.insert(label.into()); + } + drop(f); + if let Some(v) = app.get_webview(label) { + layout(app, v.window().label()); + } +} + +pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { + crate::lock::require_unlocked(app)?; + let label = accounts::window_label(id); + let v = app.get_webview(&label).ok_or("Conta não está aberta")?; + if v.window().label() != "main" { + activate(app, &label); + return Ok(()); + } + let parent_label = format!("detached-{id}"); + let w = WindowBuilder::new(app, &parent_label) + .title("whatRust — Conta destacada") + .inner_size(1000., 760.) + .min_inner_size(560., 480.) + .visible(false) + .build() + .map_err(|e| e.to_string())?; + install_menu(app, &w).map_err(|e| e.to_string())?; + v.reparent(&w).map_err(|e| e.to_string())?; + let a = app.clone(); + let account = id.to_string(); + let p = parent_label.clone(); + let l = label.clone(); + w.on_window_event(move |event| match event { + tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. } => { + layout(&a, &p) + } + tauri::WindowEvent::Focused(true) => { + *a.state::().lock().unwrap() = l.clone(); + } + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + let a = a.clone(); + let id = account.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _ = attach(&a, &id); + }); + } + _ => {} + }); + choose_remaining(app, Some(&label)); + activate(app, &label); + Ok(()) +} + +pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { + crate::lock::require_unlocked(app)?; + let label = accounts::window_label(id); + let v = app.get_webview(&label).ok_or("Conta não está aberta")?; + let old = v.window(); + if old.label() == "main" { + activate(app, &label); + return Ok(()); + } + if let Some(panel) = app.get_webview("settings") { + if panel.window().label() == old.label() { + let _ = panel.hide(); + panel + .reparent(&initialize(app, true).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + } + } + v.reparent(&initialize(app, true).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let _ = old.destroy(); + activate(app, &label); + Ok(()) +} + +fn choose_remaining(app: &AppHandle, exclude: Option<&str>) { + let current = state(app).main_active.lock().unwrap().clone(); + if let Some(label) = current { + if exclude != Some(label.as_str()) + && app + .get_webview(&label) + .is_some_and(|v| v.window().label() == "main") + { + activate(app, &label); + return; + } + } + + let next = accounts::load(app).accounts.into_iter().find_map(|a| { + let label = accounts::window_label(&a.id); + (exclude != Some(label.as_str()) + && app + .get_webview(&label) + .is_some_and(|v| v.window().label() == "main")) + .then_some(label) + }); + *state(app).main_active.lock().unwrap() = next.clone(); + if let Some(label) = next { + activate(app, &label); + } else if let Some(v) = app.get_webview("shell-main") { + let _ = v.show(); + } +} + +pub fn remove_view(app: &AppHandle, id: &str) -> tauri::Result<()> { + if let Some(v) = app.get_webview(&accounts::window_label(id)) { + let owner = v.window(); + v.close()?; + if owner.label().starts_with("detached-") { + let _ = owner.destroy(); + } + } + choose_remaining(app, None); + Ok(()) +} + +pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { + if !crate::lock::is_unlocked(app) { + crate::lock::show_lock_window(app); + return Ok(()); + } + let owner = parent + .and_then(|p| app.get_window(p)) + .or_else(|| { + let label = app.state::().lock().unwrap().clone(); + app.get_webview(&label).map(|v| v.window()) + }) + .map(Ok) + .unwrap_or_else(|| initialize(app, false))?; + let panel = if let Some(v) = app.get_webview("settings") { + if v.window().label() != owner.label() { + v.reparent(&owner)?; + } + v + } else { + owner.add_child( + WebviewBuilder::new("settings", WebviewUrl::App("index.html".into())) + .data_directory(app.path().app_data_dir()?.join("management")) + .on_navigation(crate::integration::local_url), + tauri::LogicalPosition::new(0., 0.), + tauri::LogicalSize::new(800., 600.), + )? + }; + for (l, v) in app.webviews() { + if l.starts_with("wa-") && v.window().label() == owner.label() { + let _ = v.hide(); + } + } + *state(app).panel_parent.lock().unwrap() = Some(owner.label().into()); + layout(app, owner.label()); + let _ = panel.show(); + let _ = owner.show(); + let _ = owner.unminimize(); + let _ = owner.set_focus(); + let _ = panel.set_focus(); + Ok(()) +} + +pub fn close_panel(app: &AppHandle) { + *state(app).panel_parent.lock().unwrap() = None; + if let Some(v) = app.get_webview("settings") { + let parent = v.window(); + let _ = v.hide(); + if let Some(label) = selected(app, parent.label()) { + activate(app, &label); + } + } +} + +pub fn toggle(app: &AppHandle) { + if !crate::lock::is_unlocked(app) { + crate::lock::show_lock_window(app); + return; + } + let label = app.state::().lock().unwrap().clone(); + if let Some(v) = app.get_webview(&label) { + let w = v.window(); + if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) { + if crate::applock::load(app).lock_on_hide { + crate::lock::lock_now(app); + } else { + let _ = w.hide(); + } + } else { + activate(app, &label); + } + } else { + crate::window::show_active(app); + } +} +pub fn restore_pending(app: &AppHandle) { + let label = state(app).pending.lock().unwrap().take(); + if let Some(label) = label { + activate(app, &label); + } +} + +pub fn account_focused(app: &AppHandle, label: &str) -> bool { + app.get_webview(label).is_some_and(|v| { + let parent = v.window(); + parent.is_visible().unwrap_or(false) + && parent.is_focused().unwrap_or(false) + && selected(app, parent.label()).as_deref() == Some(label) + && state(app).panel_parent.lock().unwrap().as_deref() != Some(parent.label()) + }) +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ef0fff0..139b5f5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -9,6 +9,9 @@ pub struct Settings { pub hotkey_enabled: bool, pub hotkey: String, pub notifications: bool, + pub notify_when_focused: bool, + pub notify_active_chat: bool, + pub respect_muted: bool, /// Display zoom for the WhatsApp webview, 1.0 = the site's own sizing. pub zoom: f64, /// macOS only (issue #44): show the icon in the menu bar. With it off, the @@ -26,6 +29,9 @@ impl Default for Settings { hotkey_enabled: true, hotkey: "CmdOrCtrl+Shift+W".to_string(), notifications: true, + notify_when_focused: true, + notify_active_chat: true, + respect_muted: true, zoom: 1.0, menu_bar_icon: true, } @@ -205,6 +211,13 @@ mod tests { #[cfg(target_os = "linux")] use super::{flatpak_autostart_entry, is_valid_flatpak_id}; + #[test] + fn old_settings_enable_new_notification_preferences() { + let s: Settings = serde_json::from_str(r#"{"notifications":false}"#).unwrap(); + assert!(!s.notifications); + assert!(s.notify_when_focused && s.notify_active_chat && s.respect_muted); + } + #[cfg(target_os = "linux")] #[test] fn flatpak_id_validation_blocks_path_and_desktop_entry_injection() { diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs new file mode 100644 index 0000000..7c8c0b5 --- /dev/null +++ b/src-tauri/src/smoke.rs @@ -0,0 +1,96 @@ +//! Native integration smoke, debug-only, isolated identifier and synthetic accounts. +use serde_json::{json, Value}; +use std::time::Duration; +use tauri::{AppHandle, Manager}; +fn eval(app: &AppHandle, label: &str, script: &str) -> Result { + let v = app.get_webview(label).ok_or("missing webview")?; + let (tx, rx) = std::sync::mpsc::channel(); + v.eval_with_callback(script.to_string(), move |s| { + let _ = tx.send(s); + }) + .map_err(|e| e.to_string())?; + rx.recv_timeout(Duration::from_secs(8)) + .map_err(|e| e.to_string()) +} +pub fn start(app: &AppHandle) { + let app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(8)); + let mut result: Vec = vec![]; + let run = (|| -> Result<(), String> { + let f = crate::accounts::load(&app); + let a = &f.accounts[0]; + let b = &f.accounts[1]; + let label = crate::accounts::window_label(&a.id); + result.push(json!({"test":"two_live_profiles","pass":app.get_webview(&label).is_some()&&app.get_webview(&crate::accounts::window_label(&b.id)).is_some()})); + eval(&app, &label, "window.__whatrustSmokeToken='same-live-view'")?; + crate::sessions::detach(&app, &a.id)?; + result.push(json!({"test":"detach_without_recreate","pass":app.get_webview(&label).unwrap().window().label().starts_with("detached-")&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); + crate::sessions::attach(&app, &a.id)?; + result.push(json!({"test":"reattach_preserves_view","pass":app.get_webview(&label).unwrap().window().label()=="main"&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); + crate::sessions::open_panel(&app, Some("main")).map_err(|e| e.to_string())?; + std::thread::sleep(Duration::from_secs(2)); + result.push(json!({"test":"local_panel_same_window","pass":app.get_webview("settings").is_some_and(|v|v.window().label()=="main"&&crate::integration::trusted_local(&v))})); + result.push(json!({"test":"local_panel_loaded","pass":eval(&app,"settings","document.querySelectorAll('.group').length>=7")?=="true"})); + crate::sessions::close_panel(&app); + crate::sessions::activate(&app, &label); + eval(&app,&label,"window.__smokeDenied=null;window.__TAURI__.core.invoke('get_settings').then(()=>window.__smokeDenied=false,()=>window.__smokeDenied=true)")?; + for _ in 0..20 { + if eval(&app, &label, "window.__smokeDenied===true")? == "true" { + break; + } + std::thread::sleep(Duration::from_millis(250)); + } + result.push(json!({"test":"remote_cannot_read_settings","pass":eval(&app,&label,"window.__smokeDenied===true")?=="true"})); + crate::sessions::close_panel(&app); + app.get_window("main") + .unwrap() + .set_size(tauri::LogicalSize::new(900., 650.)) + .map_err(|e| e.to_string())?; + std::thread::sleep(Duration::from_millis(500)); + let bounds = app + .get_webview(&label) + .unwrap() + .bounds() + .map_err(|e| e.to_string())?; + result.push(json!({"test":"native_resize","pass":bounds.size.to_logical::(app.get_window("main").unwrap().scale_factor().unwrap_or(1.)).width>500.})); + crate::notify::from_account( + &app, + &label, + "Teste isolado do whatRust", + "Verificação do envio ao Windows; nenhum contato recebeu mensagem.", + None, + crate::notify::Source::Test, + ); + for _ in 0..20 { + if app + .state::() + .records() + .iter() + .any(|n| n.source == crate::notify::Source::Test && n.status != "pending") + { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + result.push(json!({"test":"windows_notification_submission","pass":app.state::().records().iter().any(|n|n.source==crate::notify::Source::Test && n.status=="submitted")})); + crate::lock::lock_now(&app); + std::thread::sleep(Duration::from_millis(300)); + result.push(json!({"test":"lock_hides_host","pass":!app.get_window("main").unwrap().is_visible().unwrap_or(true)&&!crate::lock::is_unlocked(&app)})); + crate::lock::unlock(&app); + result.push(json!({"test":"unlock_restores_host","pass":crate::lock::is_unlocked(&app)&&app.get_window("main").unwrap().is_visible().unwrap_or(false)})); + Ok(()) + })(); + if let Err(e) = run { + result.push(json!({"test":"native_smoke","pass":false,"error":e})); + } + let passed = result.iter().all(|r| r["pass"] == true); + if let Some(path) = std::env::var_os("WHATRUST_SMOKE_REPORT") { + let _ = std::fs::write( + path, + serde_json::to_string_pretty(&json!({"passed":passed,"checks":result})).unwrap(), + ); + } + app.exit(if passed { 0 } else { 1 }); + }); +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 3cf98d3..381fe82 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -65,7 +65,7 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { "reload" => { if let Some(active) = app.try_state::() { let label = active.lock().unwrap().clone(); - if let Some(w) = app.get_webview_window(&label) { + if let Some(w) = app.get_webview(&label) { let _ = w.eval("window.location.reload()"); } } diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index ef6238f..da86659 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -60,11 +60,23 @@ fn safari_ua(macos_major: u64) -> String { const BRIDGE_JS: &str = include_str!("../resources/bridge.js"); const APP_ICON: &[u8] = include_bytes!("../icons/128x128.png"); +/// Platform routing keeps the existing non-Windows account-window flow. +pub fn open_account_window(app: &AppHandle, account: &Account, hidden: bool) -> tauri::Result<()> { + #[cfg(windows)] + { + crate::sessions::open_account(app, account, hidden) + } + #[cfg(not(windows))] + { + build_account_window(app, account, hidden).map(|_| ()) + } +} + /// Open (or focus, if it already exists) the window for `account`. The label is /// `wa-`; everything the single-account window carried is preserved (user agent, /// `bridge.js`, app icon, sizes, close-to-tray), plus per-account session isolation /// for non-default accounts. -pub fn open_account_window( +pub(crate) fn build_account_window( app: &AppHandle, account: &Account, start_hidden: bool, @@ -85,6 +97,12 @@ pub fn open_account_window( .min_inner_size(560.0, 480.0) .icon(icon)? .user_agent(&user_agent()) + .initialization_script(if cfg!(windows) { + "window.__WHATRUST_WINDOWS__ = true;" + } else { + "" + }) + .initialization_script(include_str!("../resources/integration.js")) .initialization_script(BRIDGE_JS) // Drag-and-drop is done by capturing the OS drop in Rust and streaming the file // into the page (see `register_drop_handler` + bridge.js `__whatrustDropFeed`). @@ -166,6 +184,9 @@ pub fn open_account_window( .is_ok(); // Nobody is looking at the window (hidden to the tray, or in // the background): say it with a system notification too. + #[cfg(windows)] + let focused = crate::sessions::account_focused(app, webview.label()); + #[cfg(not(windows))] let focused = webview.window().is_focused().unwrap_or(false); if !shown || !focused { if success { @@ -255,16 +276,18 @@ fn choose_download_destination( for (slot, unit) in buffer.iter_mut().zip(suggested_name.encode_utf16()) { *slot = unit; } - let filter: Vec = "All files\0*.*\0\0".encode_utf16().collect(); - let title: Vec = "Save downloaded file\0".encode_utf16().collect(); - let mut dialog = OPENFILENAMEW::default(); - dialog.lStructSize = std::mem::size_of::() as u32; - dialog.hwndOwner = owner.unwrap_or_default(); - dialog.lpstrFilter = PCWSTR(filter.as_ptr()); - dialog.lpstrFile = PWSTR(buffer.as_mut_ptr()); - dialog.nMaxFile = buffer.len() as u32; - dialog.lpstrTitle = PCWSTR(title.as_ptr()); - dialog.Flags = OFN_EXPLORER | OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR; + let filter: Vec = "Todos os arquivos\0*.*\0\0".encode_utf16().collect(); + let title: Vec = "Salvar arquivo\0".encode_utf16().collect(); + let mut dialog = OPENFILENAMEW { + lStructSize: std::mem::size_of::() as u32, + hwndOwner: owner.unwrap_or_default(), + lpstrFilter: PCWSTR(filter.as_ptr()), + lpstrFile: PWSTR(buffer.as_mut_ptr()), + nMaxFile: buffer.len() as u32, + lpstrTitle: PCWSTR(title.as_ptr()), + Flags: OFN_EXPLORER | OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR, + ..Default::default() + }; if !unsafe { GetSaveFileNameW(&mut dialog) }.as_bool() { return None; } @@ -297,7 +320,7 @@ fn route_new_window(app: &AppHandle, label: &str, url: tauri::Url) { match new_window_route(&url) { NewWindowRoute::InApp(target) => { crate::dlog::log("links: WhatsApp link clicked, opening in-app"); - if let Some(w) = app.get_webview_window(label) { + if let Some(w) = app.get_webview(label) { let _ = w.navigate(target); } } @@ -324,8 +347,8 @@ fn apply_zoom(win: &WebviewWindow, zoom: f64) { /// setting takes effect immediately instead of at the next launch. pub fn apply_zoom_all(app: &AppHandle, zoom: f64) { for a in accounts::load(app).accounts { - if let Some(w) = app.get_webview_window(&accounts::window_label(&a.id)) { - apply_zoom(&w, zoom); + if let Some(w) = app.get_webview(&accounts::window_label(&a.id)) { + let _ = w.set_zoom(crate::settings::sanitize_zoom(zoom)); } } } @@ -1101,6 +1124,51 @@ fn enable_webview_media(win: &WebviewWindow) { } } +#[cfg(windows)] +struct NativeNotification { + notification: webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Notification, + shown: bool, + clicked: bool, + closed: bool, +} +#[cfg(windows)] +thread_local! { static NATIVE_NOTIFICATIONS: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); } +#[cfg(windows)] +pub fn native_report(app: &AppHandle, id: &str, action: &str) { + let id = id.to_string(); + let action = action.to_string(); + let _ = app.run_on_main_thread(move || { + NATIVE_NOTIFICATIONS.with(|items| { + let mut items = items.borrow_mut(); + if action == "discard" { + items.remove(&id); + return; + } + if let Some(n) = items.get_mut(&id) { + unsafe { + if action == "shown" && !n.shown && n.notification.ReportShown().is_ok() { + n.shown = true; + } + if action == "clicked" { + n.clicked = true; + } + if action == "closed" { + n.closed = true; + } + if n.shown && n.clicked { + let _ = n.notification.ReportClicked(); + n.clicked = false; + } + if n.shown && n.closed { + let _ = n.notification.ReportClosed(); + items.remove(&id); + } + } + } + }) + }); +} + /// Windows (WebView2): auto-allow microphone/camera permission requests so WhatsApp /// voice messages and calls work without a prompt (and can't be wedged by a prior "Block"). /// @@ -1155,6 +1223,8 @@ fn enable_media_windows(webview: tauri::webview::PlatformWebview, app: AppHandle crate::dlog::log("webview2: NotificationReceived unavailable (runtime too old)"); return; }; + let native_label = label.clone(); + let native_app = app.clone(); let handler = NotificationReceivedEventHandler::create(Box::new( move |_wv: Option, args: Option| @@ -1171,21 +1241,56 @@ fn enable_media_windows(webview: tauri::webview::PlatformWebview, app: AppHandle ); // Ours to show: WebView2 draws nothing itself. args.SetHandled(true)?; - let _ = notification.ReportShown(); - crate::notify::from_account( + let identity = crate::notify::event_id(); + let close_app = app.clone(); + let close_identity = identity.clone(); + let close = webview2_com::NotificationCloseRequestedEventHandler::create(Box::new( + move |_, _| { + crate::notify::cancel_native(&close_app, &close_identity); + Ok(()) + }, + )); + let mut close_token = 0; + let _ = notification.add_CloseRequested(&close, &mut close_token); + NATIVE_NOTIFICATIONS.with(|items| { + let mut items = items.borrow_mut(); + if items.len() >= 256 { + if let Some(key) = items.keys().next().cloned() { + items.remove(&key); + } + } + items.insert( + identity.clone(), + NativeNotification { + notification, + shown: false, + clicked: false, + closed: false, + }, + ); + }); + crate::notify::from_event( &app, - &label, + &native_label, &title, &body, None, crate::notify::Source::WebView2, + &identity, + false, + None, ); Ok(()) }, )); let mut token: i64 = 0; match core24.add_NotificationReceived(&handler, &mut token) { - Ok(()) => crate::dlog::log("webview2: NotificationReceived handler installed"), + Ok(()) => { + native_app + .state::() + .set_native(&label, true); + crate::dlog::log("webview2: NotificationReceived handler installed"); + } Err(e) => crate::dlog::log(&format!("webview2: NotificationReceived failed: {e}")), } } @@ -1193,6 +1298,16 @@ fn enable_media_windows(webview: tauri::webview::PlatformWebview, app: AppHandle /// Show + unminimize + focus an account window by its `wa-` label. pub fn show_account(app: &AppHandle, label: &str) { + #[cfg(windows)] + { + crate::sessions::activate(app, label); + } + #[cfg(not(windows))] + show_account_legacy(app, label); +} + +#[cfg(not(windows))] +fn show_account_legacy(app: &AppHandle, label: &str) { if !crate::lock::is_unlocked(app) { crate::lock::show_lock_window(app); return; @@ -1215,14 +1330,14 @@ pub fn show_active(app: &AppHandle) { } if let Some(active) = app.try_state::() { let label = active.lock().unwrap().clone(); - if app.get_webview_window(&label).is_some() { + if app.get_webview(&label).is_some() { show_account(app, &label); return; } } // Fall back to any account window. if let Some(label) = app - .webview_windows() + .webviews() .keys() .find(|l| l.starts_with("wa-")) .cloned() @@ -1244,6 +1359,7 @@ pub fn show_main(app: &AppHandle) { } /// What a toggle should do given the active window's visibility. +#[cfg(any(not(windows), test))] #[derive(Debug, PartialEq, Eq)] pub enum ToggleAct { Hide, @@ -1252,6 +1368,7 @@ pub enum ToggleAct { /// Pure toggle decision: a visible active window is hidden; anything else (a hidden /// window, or no active window at all → `None`) is shown. +#[cfg(any(not(windows), test))] pub fn toggle_decision(active_visible: Option) -> ToggleAct { match active_visible { Some(true) => ToggleAct::Hide, @@ -1266,6 +1383,16 @@ pub fn toggle_decision(active_visible: Option) -> ToggleAct { /// while locked. The "hide" path only triggers when an account window is visible, /// which cannot happen while locked. pub fn toggle_active(app: &AppHandle) { + #[cfg(windows)] + { + crate::sessions::toggle(app); + } + #[cfg(not(windows))] + toggle_active_legacy(app); +} + +#[cfg(not(windows))] +fn toggle_active_legacy(app: &AppHandle) { let label = app .try_state::() .map(|a| a.lock().unwrap().clone()); @@ -1298,6 +1425,18 @@ pub fn open_settings_window(app: &AppHandle) { } fn open_settings_window_inner(app: &AppHandle) { + #[cfg(windows)] + { + if let Err(e) = crate::sessions::open_panel(app, None) { + crate::dlog::log(&format!("panel: {e}")); + } + } + #[cfg(not(windows))] + open_settings_legacy(app); +} + +#[cfg(not(windows))] +fn open_settings_legacy(app: &AppHandle) { if !crate::lock::is_unlocked(app) { crate::lock::show_lock_window(app); return; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5a16eb6..9b404be 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.8", + "version": "0.6.9-local.1", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From 4825101ceb4a7cc9bb1d1cdf815d8c1f19944143 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 11:49:02 -0300 Subject: [PATCH 05/22] Fix Windows resize deadlock and first-login workspace --- docs/windows-usabilidade.md | 12 +- settings-ui/shell.html | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/accounts.rs | 29 ++++ src-tauri/src/integration.rs | 3 + src-tauri/src/sessions.rs | 96 ++++++++---- src-tauri/src/smoke.rs | 75 +++++++++ src-tauri/src/window.rs | 288 ++++++++++++++++++++--------------- src-tauri/tauri.conf.json | 2 +- 10 files changed, 353 insertions(+), 158 deletions(-) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index 5fa7149..9d96da6 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -42,10 +42,20 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.1`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.2`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. ## Recuperação O PR #26 e sua branch foram preservados. Antes das mudanças foi criado um bundle Git completo fora do checkout. A versão anterior do instalador permanece na pasta de entrega. Para voltar à interface anterior, reinstale a versão anterior sem selecionar exclusão de dados do aplicativo. A implementação não migra os diretórios de sessão para outro formato. + +## Correção da inicialização instalada (local.2) + +O layout não altera menus nativos durante eventos de redimensionamento: `SetMenu` dispara `WM_SIZE` e o bloqueio interno de menu do Tauri pode ser acessado novamente. A visibilidade do menu muda somente quando muda a integração/conta ativa, antes de calcular o layout. + +As contas Windows agora nascem como WebViews filhas, sem uma janela temporária. Isso evita a lógica de redimensionamento automático de uma WebviewWindow movida. Os limites usam pixels físicos da área cliente atual. O shell de recuperação ocupa somente sua própria faixa, fica oculto quando os controles integrados estão disponíveis e usa a janela completa apenas quando não existe visualização ativa. + +Na primeira execução, o perfil `default` abre a página oficial de vinculação do WhatsApp; um perfil cadastrado não significa uma sessão autenticada. Uma configuração explicitamente vazia recebe um perfil inicial, sem apagar nem modificar outros diretórios. QR Code e login por telefone permanecem no WhatsApp. Não se lê estado privado de autenticação. + +A regressão nativa alterna integração/recuperação via IPC real, confere o tamanho físico até o rodapé, redimensiona repetidamente e abre o painel antes de vincular contas. Os cenários usam perfis isolados sem login e não desconectam contas do usuário. diff --git a/settings-ui/shell.html b/settings-ui/shell.html index d6dad04..0ba33e4 100644 --- a/settings-ui/shell.html +++ b/settings-ui/shell.html @@ -1 +1 @@ -whatRust
whatRust

As contas estão sendo carregadas. Se todas estiverem destacadas, use o menu Contas para retornar.

\ No newline at end of file +whatRust
whatRust

Conecte uma conta usando o QR Code ou o número de telefone na tela do WhatsApp. Contas e configurações permanecem acessíveis antes do login. Se todas as janelas estiverem destacadas, use o seletor acima para retornar.

\ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c3101f1..cc53488 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.1" +version = "0.6.9-local.2" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2dc0e65..cbc4551 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.1" +version = "0.6.9-local.2" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 1c277b8..a043d51 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -193,9 +193,38 @@ pub fn load(app: &AppHandle) -> AccountsFile { .ok() .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|s| serde_json::from_str(&s).ok()) + .map(ensure_account) .unwrap_or_default() } +/// An empty configuration still needs one profile to display WhatsApp's login. +/// This creates no authenticated session and never deletes existing profiles. +fn ensure_account(mut file: AccountsFile) -> AccountsFile { + if file.accounts.is_empty() { + file.accounts = AccountsFile::default().accounts; + file.startup_account = None; + file.next_seq = file.next_seq.max(1); + } + file +} + +#[cfg(test)] +mod empty_account_tests { + use super::*; + #[test] + fn empty_configuration_opens_first_login_profile() { + let file = ensure_account(AccountsFile { + accounts: vec![], + next_seq: 7, + startup_account: Some("gone".into()), + }); + assert_eq!(file.accounts.len(), 1); + assert_eq!(startup_account(&file).unwrap().id, "default"); + assert_eq!(file.next_seq, 7); + assert_eq!(file.startup_account, None); + } +} + pub fn save(app: &AppHandle, f: &AccountsFile) -> tauri::Result<()> { let path = accounts_path(app)?; let json = serde_json::to_string_pretty(f).expect("serialize accounts"); diff --git a/src-tauri/src/integration.rs b/src-tauri/src/integration.rs index 5a6f883..33e7ec9 100644 --- a/src-tauri/src/integration.rs +++ b/src-tauri/src/integration.rs @@ -130,6 +130,9 @@ pub fn report_integration( }); #[cfg(windows)] crate::sessions::integration_changed(&app, window.label(), mounted); + crate::dlog::log(&format!( + "integration: layout completed controls_mounted={mounted} detector={detector}" + )); Ok(()) } #[derive(Deserialize)] diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index d2f3527..2b745db 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -1,6 +1,6 @@ //! Windows account ownership. One live WebView per profile; detach moves it. use crate::accounts::{self, Account, ActiveAccount}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; use tauri::{ menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}, @@ -13,6 +13,7 @@ pub struct Sessions { fallback: Mutex>, pending: Mutex>, panel_parent: Mutex>, + menus_hidden: Mutex>, } fn state(app: &AppHandle) -> tauri::State<'_, Sessions> { @@ -25,6 +26,7 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { } let w = WindowBuilder::new(app, "main") .title("whatRust") + .background_color(tauri::utils::config::Color(22, 23, 23, 255)) .inner_size(1100., 800.) .min_inner_size(560., 480.) .icon(tauri::image::Image::from_bytes(include_bytes!( @@ -120,15 +122,7 @@ pub fn open_account(app: &AppHandle, account: &Account, hidden: bool) -> tauri:: let parent = initialize(app, hidden)?; let label = accounts::window_label(&account.id); if app.get_webview(&label).is_none() { - // Reuse every existing download/media/profile hook. Move the view BEFORE - // destroying its temporary native window; never recreate the session. - let _staged = crate::window::build_account_window(app, account, true)?; - let original = app.get_window(&label).ok_or(tauri::Error::WindowNotFound)?; - let view = app - .get_webview(&label) - .ok_or(tauri::Error::WindowNotFound)?; - view.reparent(&parent)?; - original.destroy()?; + let view = crate::window::build_account_view(app, &parent, account)?; let _ = view.hide(); } if state(app).main_active.lock().unwrap().is_none() { @@ -167,6 +161,7 @@ pub fn activate(app: &AppHandle, label: &str) { } } *app.state::().lock().unwrap() = label.to_string(); + sync_menu(app, parent.label()); layout(app, parent.label()); let name = accounts::id_from_label(label) .and_then(|id| { @@ -192,21 +187,7 @@ pub fn layout(app: &AppHandle, parent: &str) { }; let Ok(size) = w.inner_size() else { return }; let scale = w.scale_factor().unwrap_or(1.0); - let size = size.to_logical::(scale); let active = selected(app, parent); - let integrated = active.as_ref().is_some_and(|l| { - app.state::() - .0 - .lock() - .unwrap() - .get(l) - .is_some_and(|a| a.mounted) - }); - if integrated { - let _ = w.hide_menu(); - } else { - let _ = w.show_menu(); - } let top = if parent == "main" && active .as_ref() @@ -220,11 +201,56 @@ pub fn layout(app: &AppHandle, parent: &str) { if v.window().label() != parent { continue; } - let y = if label.starts_with("wa-") { top } else { 0. }; + // Use the current physical client size: no DPI rounding gap, and never + // mutate the native menu from a resize callback (SetMenu emits WM_SIZE). + let offset = (top * scale).round() as u32; + let is_account = label.starts_with("wa-"); + let y = if is_account { offset } else { 0 }; + let shell = label.starts_with("shell-"); + let height = if shell && active.is_some() { + offset.max(1) + } else { + size.height.saturating_sub(y).max(1) + }; let _ = v.set_bounds(tauri::Rect { - position: tauri::LogicalPosition::new(0., y).into(), - size: tauri::LogicalSize::new(size.width, (size.height - y).max(1.)).into(), + position: tauri::PhysicalPosition::new(0, y as i32).into(), + size: tauri::PhysicalSize::new(size.width.max(1), height).into(), }); + if shell { + let panel_open = state(app).panel_parent.lock().unwrap().as_deref() == Some(parent); + if !panel_open && (active.is_none() || offset > 0) { + let _ = v.show(); + } else { + let _ = v.hide(); + } + } + } +} + +// Tauri holds its menu mutex while SetMenu synchronously dispatches WM_SIZE. +// Changing menus inside layout re-enters that mutex and freezes the UI thread. +// Record transitions before invoking native APIs; resize only changes bounds. +fn sync_menu(app: &AppHandle, parent: &str) { + let hidden = selected(app, parent).is_some_and(|label| { + app.state::() + .0 + .lock() + .unwrap() + .get(&label) + .is_some_and(|adapter| adapter.mounted) + }); + let changed = { + let s = state(app); + let mut menus = s.menus_hidden.lock().unwrap(); + menus.insert(parent.into(), hidden) != Some(hidden) + }; + if changed { + if let Some(w) = app.get_window(parent) { + let result = if hidden { w.hide_menu() } else { w.show_menu() }; + if result.is_err() { + state(app).menus_hidden.lock().unwrap().remove(parent); + } + } } } @@ -238,7 +264,11 @@ pub fn integration_changed(app: &AppHandle, label: &str, mounted: bool) { } drop(f); if let Some(v) = app.get_webview(label) { - layout(app, v.window().label()); + let parent = v.window(); + if selected(app, parent.label()).as_deref() == Some(label) { + sync_menu(app, parent.label()); + layout(app, parent.label()); + } } } @@ -253,6 +283,7 @@ pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { let parent_label = format!("detached-{id}"); let w = WindowBuilder::new(app, &parent_label) .title("whatRust — Conta destacada") + .background_color(tauri::utils::config::Color(22, 23, 23, 255)) .inner_size(1000., 760.) .min_inner_size(560., 480.) .visible(false) @@ -305,6 +336,7 @@ pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { } v.reparent(&initialize(app, true).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; + state(app).menus_hidden.lock().unwrap().remove(old.label()); let _ = old.destroy(); activate(app, &label); Ok(()) @@ -334,8 +366,12 @@ fn choose_remaining(app: &AppHandle, exclude: Option<&str>) { *state(app).main_active.lock().unwrap() = next.clone(); if let Some(label) = next { activate(app, &label); - } else if let Some(v) = app.get_webview("shell-main") { - let _ = v.show(); + } else { + sync_menu(app, "main"); + layout(app, "main"); + if let Some(v) = app.get_webview("shell-main") { + let _ = v.show(); + } } } diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index 7c8c0b5..0046f07 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -23,6 +23,49 @@ pub fn start(app: &AppHandle) { let b = &f.accounts[1]; let label = crate::accounts::window_label(&a.id); result.push(json!({"test":"two_live_profiles","pass":app.get_webview(&label).is_some()&&app.get_webview(&crate::accounts::window_label(&b.id)).is_some()})); + // Reproduce the installed-app regression: the page reports no + // anchor while loading/QR login, then mounts/unmounts its header. + crate::sessions::activate(&app, &label); + let login_script = "!!document.body && !document.getElementById('pane-side') && (/QR|scan|código|vincular|link devices|phone number|número de telefone|WhatsApp on your|WhatsApp no seu/i.test(document.body.innerText))"; + let mut login_ready = false; + for _ in 0..40 { + if eval(&app, &label, login_script)? == "true" { + login_ready = true; + break; + } + std::thread::sleep(Duration::from_millis(300)); + } + result.push(json!({"test":"real_whatsapp_login_screen_without_linked_account","pass":login_ready})); + for (step, mounted) in [false, true, false, true, false].into_iter().enumerate() { + eval(&app, &label, &format!("window.__menuStep=-1;window.__TAURI__.core.invoke('report_integration',{{mounted:{mounted},detector:false}}).then(()=>window.__menuStep={step})"))?; + for _ in 0..20 { + if eval(&app, &label, &format!("window.__menuStep==={step}"))? == "true" { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if eval(&app, &label, &format!("window.__menuStep==={step}"))? != "true" { + return Err("menu transition stalled".into()); + } + let w = app.get_window("main").unwrap(); + let client = w.inner_size().map_err(|e| e.to_string())?; + let bounds = app + .get_webview(&label) + .unwrap() + .bounds() + .map_err(|e| e.to_string())?; + let scale = w.scale_factor().unwrap_or(1.); + let origin = bounds.position.to_physical::(scale); + let size = bounds.size.to_physical::(scale); + if origin.y as u32 + size.height != client.height || size.width != client.width { + return Err(format!("webview bounds mismatch step={step} mounted={mounted} scale={scale} client={client:?} origin={origin:?} size={size:?}")); + } + } + result.push(json!({"test":"login_header_transitions_do_not_deadlock","pass":true})); + result.push(json!({"test":"physical_bounds_have_no_bottom_gap","pass":true})); + // These profiles are isolated from the user's sessions. A logged-out + // WhatsApp view must still allow opening the local account panel. + result.push(json!({"test":"logged_out_profile_has_management_access","pass":app.get_webview("shell-main").is_some()&&crate::accounts::startup_account(&f).is_some()})); eval(&app, &label, "window.__whatrustSmokeToken='same-live-view'")?; crate::sessions::detach(&app, &a.id)?; result.push(json!({"test":"detach_without_recreate","pass":app.get_webview(&label).unwrap().window().label().starts_with("detached-")&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); @@ -54,6 +97,38 @@ pub fn start(app: &AppHandle) { .bounds() .map_err(|e| e.to_string())?; result.push(json!({"test":"native_resize","pass":bounds.size.to_logical::(app.get_window("main").unwrap().scale_factor().unwrap_or(1.)).width>500.})); + let w = app.get_window("main").unwrap(); + for (width, height) in [(560., 480.), (1100., 800.), (900., 650.)] { + w.set_size(tauri::LogicalSize::new(width, height)) + .map_err(|e| e.to_string())?; + std::thread::sleep(Duration::from_millis(200)); + let scale = w.scale_factor().unwrap_or(1.); + let client = w.inner_size().map_err(|e| e.to_string())?; + let b = app + .get_webview(&label) + .unwrap() + .bounds() + .map_err(|e| e.to_string())?; + let origin = b.position.to_physical::(scale); + let size = b.size.to_physical::(scale); + if size.width != client.width || origin.y as u32 + size.height != client.height { + return Err("resize leaves client gap".into()); + } + } + result.push(json!({"test":"repeated_resize_fills_client_area","pass":true})); + w.maximize().map_err(|e| e.to_string())?; + std::thread::sleep(Duration::from_millis(350)); + let scale = w.scale_factor().unwrap_or(1.); + let client = w.inner_size().map_err(|e| e.to_string())?; + let b = app + .get_webview(&label) + .unwrap() + .bounds() + .map_err(|e| e.to_string())?; + let origin = b.position.to_physical::(scale); + let size = b.size.to_physical::(scale); + result.push(json!({"test":"maximized_view_has_no_client_gap","pass":size.width==client.width&&origin.y as u32+size.height==client.height})); + w.unmaximize().map_err(|e| e.to_string())?; crate::notify::from_account( &app, &label, diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index da86659..7a8aee1 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -72,10 +72,140 @@ pub fn open_account_window(app: &AppHandle, account: &Account, hidden: bool) -> } } +// Keep navigation, injection and download hooks identical across platform builders. +macro_rules! account_webview { + ($builder:expr, $app:ident, $label:ident) => { + $builder + .user_agent(&user_agent()) + .initialization_script(if cfg!(windows) { + "window.__WHATRUST_WINDOWS__ = true;" + } else { + "" + }) + .initialization_script(include_str!("../resources/integration.js")) + .initialization_script(BRIDGE_JS) + // Drag-and-drop is done by capturing the OS drop in Rust and streaming the file + // into the page (see `register_drop_handler` + bridge.js `__whatrustDropFeed`). + // We deliberately KEEP Tauri's drag-drop handler enabled (except on Windows, see + // below): on Linux/webkit2gtk the + // native webview never delivers a file drop into the page DOM (broken on Wayland; + // on X11 the GTK drop is accepted — the "+" cursor shows — but it still never + // reaches WhatsApp Web), so relying on in-page HTML5 drop simply does not work + // there. The handler is what hands us the dropped paths via `WindowEvent::DragDrop`. + // Belt-and-braces: cancel any `file://` navigation so a stray drop can never + // navigate the window away and tear down the live WhatsApp session. + .on_navigation(|url| url.scheme() != "file") + // Links (issue #30). WhatsApp opens every chat link in a new tab + // (`target="_blank"` / `window.open`). Unanswered, the system webview drops + // that request (WKWebView, WebKitGTK) or opens nothing useful, so a click + // did nothing. Answer it: WhatsApp links load in this window, web/mail/phone + // links go to the system's default handler, and no popup window is created. + .on_new_window({ + let app = $app.clone(); + let label = $label.clone(); + move |url, _features| { + route_new_window(&app, &label, url); + tauri::webview::NewWindowResponse::Deny + } + }) + // Downloads: with NO handler registered, wry never wires up the platform's + // download machinery at all — on Linux nobody answers WebKit's + // `decide-destination` and the engine cancels every download, so WhatsApp's + // "Download" button (videos, images, documents) silently did nothing. + // Let the user choose each Windows destination. Other platforms retain + // their existing Downloads-folder flow. + .on_download(|webview, event| { + let app = webview.app_handle(); + let downloads = app.state::(); + match event { + tauri::webview::DownloadEvent::Requested { url, destination } => { + #[cfg(windows)] + { + let suggested = destination + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("download"); + let Some(path) = choose_download_destination( + suggested, + webview.window().hwnd().ok(), + ) else { + crate::dlog::log("download: save dialog cancelled"); + return false; + }; + *destination = path; + } + #[cfg(not(windows))] + ensure_download_destination(destination, app.path().download_dir().ok()); + downloads.requested(url.as_str(), destination); + // Log routing only — never the file name (matches dlog's no-PII rule). + crate::dlog::log(&format!( + "download: requested scheme={} dest_abs={}", + url.scheme(), + destination.is_absolute() + )); + } + tauri::webview::DownloadEvent::Finished { url, path, success } => { + crate::dlog::log(&format!( + "download: finished success={success} path_known={}", + path.is_some() + )); + // Issue #21: confirm in the window itself, with Open / Show in + // folder, like a browser. macOS reports no path; the + // destination recorded at request time stands in for it. + let done = if success { + downloads.finished(url.as_str(), path) + } else { + downloads.failed(url.as_str()); + None + }; + let shown = webview + .eval(crate::downloads::toast_js( + done.as_ref().map(|(id, p)| (*id, p.as_path())), + )) + .is_ok(); + // Nobody is looking at the window (hidden to the tray, or in + // the background): say it with a system notification too. + #[cfg(windows)] + let focused = crate::sessions::account_focused(app, webview.label()); + #[cfg(not(windows))] + let focused = webview.window().is_focused().unwrap_or(false); + if !shown || !focused { + if success { + let body = match done.as_ref().and_then(|(_, p)| p.file_name()) { + Some(n) => format!( + "{} — saved to your chosen location.", + n.to_string_lossy() + ), + None => "Saved to your chosen location.".to_string(), + }; + crate::notify::show_for_account( + app, + webview.label(), + "Download complete", + &body, + ); + } else { + crate::notify::show_for_account( + app, + webview.label(), + "Download failed", + "The file could not be downloaded. Please try again.", + ); + } + } + } + _ => {} + } + true + }) + }; +} + /// Open (or focus, if it already exists) the window for `account`. The label is /// `wa-`; everything the single-account window carried is preserved (user agent, /// `bridge.js`, app icon, sizes, close-to-tray), plus per-account session isolation /// for non-default accounts. +#[cfg_attr(windows, allow(dead_code))] pub(crate) fn build_account_window( app: &AppHandle, account: &Account, @@ -95,129 +225,8 @@ pub(crate) fn build_account_window( .title(format!("whatRust — {}", account.name)) .inner_size(1100.0, 800.0) .min_inner_size(560.0, 480.0) - .icon(icon)? - .user_agent(&user_agent()) - .initialization_script(if cfg!(windows) { - "window.__WHATRUST_WINDOWS__ = true;" - } else { - "" - }) - .initialization_script(include_str!("../resources/integration.js")) - .initialization_script(BRIDGE_JS) - // Drag-and-drop is done by capturing the OS drop in Rust and streaming the file - // into the page (see `register_drop_handler` + bridge.js `__whatrustDropFeed`). - // We deliberately KEEP Tauri's drag-drop handler enabled (except on Windows, see - // below): on Linux/webkit2gtk the - // native webview never delivers a file drop into the page DOM (broken on Wayland; - // on X11 the GTK drop is accepted — the "+" cursor shows — but it still never - // reaches WhatsApp Web), so relying on in-page HTML5 drop simply does not work - // there. The handler is what hands us the dropped paths via `WindowEvent::DragDrop`. - // Belt-and-braces: cancel any `file://` navigation so a stray drop can never - // navigate the window away and tear down the live WhatsApp session. - .on_navigation(|url| url.scheme() != "file") - // Links (issue #30). WhatsApp opens every chat link in a new tab - // (`target="_blank"` / `window.open`). Unanswered, the system webview drops - // that request (WKWebView, WebKitGTK) or opens nothing useful, so a click - // did nothing. Answer it: WhatsApp links load in this window, web/mail/phone - // links go to the system's default handler, and no popup window is created. - .on_new_window({ - let app = app.clone(); - let label = label.clone(); - move |url, _features| { - route_new_window(&app, &label, url); - tauri::webview::NewWindowResponse::Deny - } - }) - // Downloads: with NO handler registered, wry never wires up the platform's - // download machinery at all — on Linux nobody answers WebKit's - // `decide-destination` and the engine cancels every download, so WhatsApp's - // "Download" button (videos, images, documents) silently did nothing. - // Let the user choose each Windows destination. Other platforms retain - // their existing Downloads-folder flow. - .on_download(|webview, event| { - let app = webview.app_handle(); - let downloads = app.state::(); - match event { - tauri::webview::DownloadEvent::Requested { url, destination } => { - #[cfg(windows)] - { - let suggested = destination - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("download"); - let Some(path) = - choose_download_destination(suggested, webview.window().hwnd().ok()) - else { - crate::dlog::log("download: save dialog cancelled"); - return false; - }; - *destination = path; - } - #[cfg(not(windows))] - ensure_download_destination(destination, app.path().download_dir().ok()); - downloads.requested(url.as_str(), destination); - // Log routing only — never the file name (matches dlog's no-PII rule). - crate::dlog::log(&format!( - "download: requested scheme={} dest_abs={}", - url.scheme(), - destination.is_absolute() - )); - } - tauri::webview::DownloadEvent::Finished { url, path, success } => { - crate::dlog::log(&format!( - "download: finished success={success} path_known={}", - path.is_some() - )); - // Issue #21: confirm in the window itself, with Open / Show in - // folder, like a browser. macOS reports no path; the - // destination recorded at request time stands in for it. - let done = if success { - downloads.finished(url.as_str(), path) - } else { - downloads.failed(url.as_str()); - None - }; - let shown = webview - .eval(crate::downloads::toast_js( - done.as_ref().map(|(id, p)| (*id, p.as_path())), - )) - .is_ok(); - // Nobody is looking at the window (hidden to the tray, or in - // the background): say it with a system notification too. - #[cfg(windows)] - let focused = crate::sessions::account_focused(app, webview.label()); - #[cfg(not(windows))] - let focused = webview.window().is_focused().unwrap_or(false); - if !shown || !focused { - if success { - let body = match done.as_ref().and_then(|(_, p)| p.file_name()) { - Some(n) => format!( - "{} — saved to your chosen location.", - n.to_string_lossy() - ), - None => "Saved to your chosen location.".to_string(), - }; - crate::notify::show_for_account( - app, - webview.label(), - "Download complete", - &body, - ); - } else { - crate::notify::show_for_account( - app, - webview.label(), - "Download failed", - "The file could not be downloaded. Please try again.", - ); - } - } - } - _ => {} - } - true - }) - .visible(!start_hidden); + .icon(icon)?; + let builder = account_webview!(builder, app, label).visible(!start_hidden); // Windows (issue #31): WebView2 is Chromium and delivers OS file drops to the // page natively, so WhatsApp's own drop zone works exactly as in Chrome. With @@ -258,6 +267,39 @@ pub(crate) fn build_account_window( Ok(win) } +/// Windows views are children from birth. Reparenting a WebviewWindow does +/// not change Wry's non-child sizing behavior and can leave stale client bounds. +#[cfg(windows)] +pub(crate) fn build_account_view( + app: &AppHandle, + parent: &tauri::Window, + account: &Account, +) -> tauri::Result { + let label = accounts::window_label(&account.id); + let builder = tauri::WebviewBuilder::new( + &label, + WebviewUrl::External("https://web.whatsapp.com/".parse().expect("valid url")), + ); + let mut builder = account_webview!(builder, app, label).disable_drag_drop_handler(); + if account.id != "default" { + builder = builder.data_directory(accounts::profile_dir(app, &account.id)?); + } + let view = parent.add_child( + builder, + tauri::LogicalPosition::new(0., 0.), + tauri::LogicalSize::new(1100., 800.), + )?; + view.set_auto_resize(false)?; + let _ = view.set_zoom(crate::settings::sanitize_zoom( + crate::settings::load(app).zoom, + )); + let handle = app.clone(); + let view_label = label.clone(); + view.with_webview(move |platform| enable_media_windows(platform, handle, view_label))?; + crate::dlog::log("account view: native child created"); + Ok(view) +} + /// Native Windows Save As dialog. Returning `None` means the user cancelled, /// which the WebView download callback treats as a cancellation. #[cfg(windows)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9b404be..a7f1f3e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.1", + "version": "0.6.9-local.2", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From d6bdb94899b4e2d7aa2e372903006d1645714261 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 15:40:22 -0300 Subject: [PATCH 06/22] Fit controls into WhatsApp header and align app styling --- docs/windows-usabilidade.md | 10 ++- scripts/adapter-check.template.cjs | 32 ++++++++- settings-ui/dialog.css | 8 +-- settings-ui/index.html | 4 +- settings-ui/main.js | 6 +- settings-ui/shell.html | 2 +- settings-ui/style.css | 57 ++++++++++------ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/resources/integration.js | 101 +++++++++++++++++++++++++---- src-tauri/src/integration.rs | 3 + src-tauri/src/sessions.rs | 14 ++++ src-tauri/tauri.conf.json | 2 +- 13 files changed, 194 insertions(+), 49 deletions(-) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index 9d96da6..1da3db0 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -42,7 +42,7 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.2`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.3`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. @@ -59,3 +59,11 @@ As contas Windows agora nascem como WebViews filhas, sem uma janela temporária. Na primeira execução, o perfil `default` abre a página oficial de vinculação do WhatsApp; um perfil cadastrado não significa uma sessão autenticada. Uma configuração explicitamente vazia recebe um perfil inicial, sem apagar nem modificar outros diretórios. QR Code e login por telefone permanecem no WhatsApp. Não se lê estado privado de autenticação. A regressão nativa alterna integração/recuperação via IPC real, confere o tamanho físico até o rodapé, redimensiona repetidamente e abre o painel antes de vincular contas. Os cenários usam perfis isolados sem login e não desconectam contas do usuário. + +## Controles e estilo (local.3) + +A inspeção do WhatsApp real encontrou uma barra de navegação vertical implementada como `header` com 64 px de largura antes do cabeçalho da lista. O adaptador agora verifica geometria, relação com `#side`, orientação horizontal e espaço livre. Cabeçalhos aninhados usam a linha interna de ações. Os botões próprios têm 40 px e SVGs de 24 px; não encolhem nem alteram CSS do WhatsApp. Se não couberem, usa-se o acesso nativo de recuperação. `ResizeObserver` acompanha somente a lateral relevante e a observação de tema fica limitada à raiz e ao corpo. + +O menu de contas abre próximo ao botão, respeita os limites da janela e fecha por Esc ou clique externo. O tema claro/escuro resolvido pelo adaptador é transmitido apenas como um valor validado (`light`/`dark`) e aplicado à visualização local quando ela abre. Isso alinha painel, menus, diálogos e controles mesmo quando WhatsApp e Windows usam temas diferentes. A identidade do whatRust permanece indicada no painel. + +As regressões incluem navegação vertical antes do cabeçalho correto, cabeçalhos aninhados, ações originais ocupando a largura, telas estreitas, recuperação e mudança de tema. O recebimento real de mensagens continua dependendo de validação pelo usuário. diff --git a/scripts/adapter-check.template.cjs b/scripts/adapter-check.template.cjs index c5cec0b..72a3796 100644 --- a/scripts/adapter-check.template.cjs +++ b/scripts/adapter-check.template.cjs @@ -33,11 +33,39 @@ async (page) => { check('missing_header_activates_recovery',await page.evaluate(()=>window.calls.some(c=>c.cmd==='report_integration'&&c.args.mounted===false))); await page.evaluate(()=>{const h=document.createElement('header');h.innerHTML='

WhatsApp

';document.getElementById('app').prepend(h);});await page.clock.runFor(300); check('header_replacement_remounts_once',await page.locator('#__whatrust_controls').count()===1); - await page.getByRole('button',{name:'Matheus · 2',exact:true}).click(); + await page.getByRole('button',{name:'Trocar conta: Matheus · 2 conversas não lidas',exact:true}).click(); await page.evaluate(()=>window.__whatrustState({accounts:[{id:'default',name:'Matheus',unread:3}],current_id:'default',native_notifications:true})); check('live_unread_update_preserves_menu_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.dataset.item==='default')); await page.keyboard.press('Escape'); - check('escape_restores_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.title==='Trocar conta do WhatsApp')); + check('escape_restores_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.title==='Conta: Matheus — Trocar conta')); for(const width of [560,900,1920]){await page.setViewportSize({width,height:800});const box=await page.locator('#__whatrust_controls').boundingBox();check('header_fits_'+width,box.x>=0&&box.x+box.width<=width);} + // Regression from the real WhatsApp DOM: the 64px navigation rail is + // itself a header, before two nested list headers and a full-width action row. + await page.evaluate(()=>{ + document.body.innerHTML=`

WhatsApp

Conversa
`; + });await page.clock.runFor(300); + check('rejects_vertical_navigation_header',await page.locator('#actions>#__whatrust_controls').count()===1); + for(const width of [560,900,1920]){ + await page.setViewportSize({width,height:800});await page.waitForTimeout(30);await page.clock.runFor(300); + const fit=await page.locator('#__whatrust_controls').evaluate(host=>{ + const r=host.getBoundingClientRect(),header=host.closest('header').getBoundingClientRect(); + const buttons=Array.from(host.shadowRoot.querySelectorAll('.bar>button')).map(e=>e.getBoundingClientRect()); + const native=Array.from(document.querySelectorAll('#native button')).map(e=>e.getBoundingClientRect()); + return r.left>=header.left&&r.right<=header.right+1&&r.bottom<=header.bottom+1&&buttons.every(b=>b.width>=40&&b.height>=40)&&native.every(b=>b.right<=r.left||b.left>=r.right); + });if(!fit)throw Error('header geometry '+JSON.stringify(await page.locator('#__whatrust_controls').evaluate(h=>({host:h.getBoundingClientRect().toJSON(),head:h.closest('header').getBoundingClientRect().toJSON(),buttons:Array.from(h.shadowRoot.querySelectorAll('.bar>button')).map(e=>e.getBoundingClientRect().toJSON()),native:Array.from(document.querySelectorAll('#native button')).map(e=>e.getBoundingClientRect().toJSON())}))));check('realistic_header_controls_fit_'+width,fit); + } + await page.evaluate(()=>document.getElementById('list').style.width='180px');await page.waitForTimeout(40);await page.clock.runFor(300); + check('unsafe_narrow_header_uses_recovery',await page.locator('#__whatrust_controls').count()===0&&await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.mounted===false)); + await page.evaluate(()=>document.getElementById('list').style.width='');await page.waitForTimeout(40);await page.clock.runFor(300); + check('safe_width_restores_controls_once',await page.locator('#actions>#__whatrust_controls').count()===1); + check('reports_whatsapp_dark_theme',await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.theme==='dark')); + await page.evaluate(()=>document.body.style.color='rgb(10,10,10)');await page.clock.runFor(300); + check('reports_whatsapp_light_theme_change',await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.theme==='light')); return {passed:true,checks}; } \ No newline at end of file diff --git a/settings-ui/dialog.css b/settings-ui/dialog.css index b941f22..e43bd95 100644 --- a/settings-ui/dialog.css +++ b/settings-ui/dialog.css @@ -10,12 +10,12 @@ .dlg { width: 100%; max-width: 340px; padding: 20px; border-radius: 12px; box-shadow: 0 14px 44px #0007; - background: #fff; color: #111b21; + background: var(--card,#fff); color: var(--fg,#0a0a0a); font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; text-align: left; } @media (prefers-color-scheme: dark) { - .dlg { background: #202c33; color: #e9edef; } + .dlg { background: var(--card,#232626); color: var(--fg,#fafafa); } } .dlg h2 { font-size: 15px; font-weight: 600; margin: 0 0 8px; } .dlg-message { margin: 0; line-height: 1.45; white-space: pre-line; } @@ -30,8 +30,8 @@ .dlg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } .dlg-actions button { padding: 8px 16px; font-size: 14px; font-weight: 600; - border: 0; border-radius: 8px; cursor: pointer; - background: #25d366; color: #042f1a; + border: 0; border-radius: 20px; cursor: pointer; + background: var(--green,#21c063); color: var(--on-green,#0a0a0a); } .dlg-actions button.dlg-secondary { background: transparent; color: inherit; border: 1px solid #8886; } .dlg-actions button.dlg-danger { background: #e5484d; color: #fff; } diff --git a/settings-ui/index.html b/settings-ui/index.html index 2ca78dd..9ac375f 100644 --- a/settings-ui/index.html +++ b/settings-ui/index.html @@ -8,9 +8,9 @@ -
ConfiguraçõeswhatRust
+
ConfiguraçõeswhatRust
-
diff --git a/settings-ui/main.js b/settings-ui/main.js index a555759..4d53dd3 100644 --- a/settings-ui/main.js +++ b/settings-ui/main.js @@ -206,8 +206,8 @@ function wireLock() { const current = await Dlg.prompt("", { title: "Senha atual", password: true, - okLabel: "Continue", - requiredMessage: "Enter your current password.", + okLabel: "Continuar", + requiredMessage: "Informe sua senha atual.", }); if (current === null) return; const next = await Dlg.prompt("Pelo menos 4 caracteres.", { @@ -229,7 +229,7 @@ function wireLock() { password: true, okLabel: "Disable", danger: true, - requiredMessage: "Enter your current password.", + requiredMessage: "Informe sua senha atual.", }); if (current === null) return; try { diff --git a/settings-ui/shell.html b/settings-ui/shell.html index 0ba33e4..58a6b7b 100644 --- a/settings-ui/shell.html +++ b/settings-ui/shell.html @@ -1 +1 @@ -whatRust
whatRust

Conecte uma conta usando o QR Code ou o número de telefone na tela do WhatsApp. Contas e configurações permanecem acessíveis antes do login. Se todas as janelas estiverem destacadas, use o seletor acima para retornar.

\ No newline at end of file +whatRust
whatRust

Conecte uma conta usando o QR Code ou o número de telefone na tela do WhatsApp. Contas e configurações permanecem acessíveis antes do login. Se todas as janelas estiverem destacadas, use o seletor acima para retornar.

\ No newline at end of file diff --git a/settings-ui/style.css b/settings-ui/style.css index d82c592..26087cd 100644 --- a/settings-ui/style.css +++ b/settings-ui/style.css @@ -1,19 +1,28 @@ /* Local management UI, matching WhatsApp desktop surfaces and spacing. */ :root { color-scheme: light dark; - --bg: #fff; --card: #fff; --fg: #111b21; --dim: #667781; - --green: #008069; --on-green: #fff; --border: #e9edef; - --line: #e9edef; --hover: #f0f2f5; --switch-off: #b7bec2; - --danger: #c01c28; --warn: #996600; + --bg: #fff; --card: #fff; --panel: #f7f8fa; --fg: #0a0a0a; --dim: #626262; + --green: #1daa61; --on-green: #0a0a0a; --border: #e9ebeb; + --line: #e9ebeb; --hover: #f1f2f2; --switch-off: #b8bdbb; + --danger: #c52c3a; --warn: #996600; } @media (prefers-color-scheme: dark) { - :root { - --bg: #161717; --card: #161717; --fg: #e9edef; --dim: #aebac1; - --green: #00a884; --on-green: #111b21; --border: #303334; - --line: #303334; --hover: #252728; --switch-off: #596268; - --danger: #ff7b81; --warn: #e3b341; + :root:not([data-theme="light"]) { + color-scheme: dark; + --bg: #161717; --card: #232626; --panel: #1d1f1f; --fg: #fafafa; --dim: #a1a5a5; + --green: #21c063; --on-green: #0a0a0a; --border: #2c3030; + --line: #2c3030; --hover: #2a2d2d; --switch-off: #626767; + --danger: #ff707a; --warn: #e3b341; } } +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #161717; --card: #232626; --panel: #1d1f1f; --fg: #fafafa; --dim: #a1a5a5; + --green: #21c063; --on-green: #0a0a0a; --border: #2c3030; + --line: #2c3030; --hover: #2a2d2d; --switch-off: #626767; + --danger: #ff707a; --warn: #e3b341; +} +:root[data-theme="light"] { color-scheme:light; } * { box-sizing: border-box; } html, body { height: 100%; } body { @@ -28,19 +37,19 @@ button:disabled { opacity: .45; cursor: default; } button.secondary { background: transparent; color: var(--green); } button:hover:not(:disabled) { filter: brightness(.95); } button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } -.app-header { padding: 18px 24px; min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); } +.app-header { padding: 12px 24px; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); } .app-header > div { display: flex; align-items: baseline; gap: 12px; } -.app-header strong { font-size: 22px; font-weight: 600; } +.app-header strong { font-size: 20px; font-weight: 600; } .app-header span { font-size: 12px; color: var(--dim); } .app-header button { color: var(--dim); font-weight: 400; } .settings-layout { display: flex; flex: 1; min-height: 0; overflow: hidden; } -.settings-nav { flex: 0 0 280px; padding: 16px 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); } -.settings-nav a { display: flex; align-items: center; gap: 24px; padding: 17px 24px; font-size: 15px; color: inherit; text-decoration: none; } +.settings-nav { flex: 0 0 300px; padding: 12px 8px; display: flex; flex-direction: column; border-right: 1px solid var(--border); } +.settings-nav a { display: flex; align-items: center; gap: 24px; padding: 16px 20px; border-radius:8px; min-height:56px; font-size: 15px; color: inherit; text-decoration: none; } .settings-nav a:hover, .settings-nav a[aria-current=page] { background: var(--hover); } .settings-nav svg { width: 22px; height: 22px; stroke: var(--dim); fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; } -.prefs { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 40px; scroll-behavior: smooth; } +.prefs { background:var(--panel); flex: 1; min-width: 0; overflow-y: auto; padding: 30px 40px; scroll-behavior: smooth; } .group { max-width: 740px; margin: 0 auto; } -.group-title { font-size: 21px; font-weight: 500; margin: 0 0 24px; } +.group-title { font-size: 22px; font-weight: 600; margin: 0 0 24px; } .group-note { font-size: 13px; line-height: 1.55; margin: 16px 0; color: var(--dim); } .group-note code { font: inherit; background: var(--hover); padding: 2px 4px; } .group-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } @@ -62,19 +71,20 @@ select option { background: var(--bg); color: var(--fg); } .switch::before { content: ""; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; } .switch:checked { background: var(--green); } .switch:checked::before { transform: translateX(16px); } -.account-row { display: flex; align-items: center; min-height: 88px; gap: 15px; padding: 14px 0; } +.account-row { border-radius:8px; display: flex; align-items: center; min-height: 88px; gap: 15px; padding: 14px 0; } +.account-row:hover { background:var(--hover); } .account-avatar { height: 48px; width: 48px; border-radius: 50%; background: var(--hover); color: var(--dim); display: grid; place-items: center; font-size: 17px; flex: 0 0 48px; } .account-open { display: flex; flex: 1; min-width: 0; align-items: flex-start; flex-direction: column; gap: 5px; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; text-align: left; } .acct-name { font-size: 16px; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .acct-subtitle { font-size: 13px; color: var(--dim); font-weight: 400; } .account-more { font-size: 26px; line-height: 1; width: 36px; height: 36px; border-radius: 50%; padding: 0; color: var(--dim); background: transparent; } -.acct-unread { background: #25d366; color: #111b21; font-size: 12px; padding: 2px 7px; border-radius: 10px; } -.account-actions { position: fixed; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); box-shadow: 0 3px 18px #0004; padding: 6px 0; width: 245px; margin: 0; max-height: calc(100vh - 24px); overflow-y: auto; } +.acct-unread { background: var(--green); color: var(--on-green); font-size: 12px; padding: 2px 7px; border-radius: 10px; } +.account-actions { position: fixed; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--fg); box-shadow: 0 2px 12px #0004; padding: 6px 0; width: 245px; margin: 0; max-height: calc(100vh - 24px); overflow-y: auto; } .account-actions button { display: block; width: 100%; border-radius: 0; background: transparent; color: inherit; text-align: left; font-size: 14px; font-weight: 400; padding: 12px 22px; } .account-actions button:hover, .account-actions button:focus-visible { background: var(--hover); } .account-actions .danger { color: var(--danger); } .add-row { display: flex; gap: 10px; margin-top: 20px; } -.add-row input { min-width: 0; flex: 1; background: var(--hover); border: 0; padding: 12px 14px; } +.add-row input { border-radius:24px; min-width: 0; flex: 1; background: var(--hover); border: 0; padding: 12px 14px; } .hotkey-row { display: flex; gap: 8px; flex-wrap: wrap; } .hotkey-row input { min-width: 0; width: 190px; } .recording { outline: 2px dashed var(--green); } @@ -90,3 +100,12 @@ select option { background: var(--bg); color: var(--fg); } .account-row { gap: 10px; } .account-avatar { width: 40px; height: 40px; flex-basis: 40px; } .group-title { font-size: 19px; } } @media(max-width:480px) { .app-header span { display: none; } } + +/* Same icon hit area as the WhatsApp toolbar. */ +.back-button { display:inline-flex;align-items:center;gap:10px;min-height:40px;padding:8px 12px!important;color:var(--fg)!important; } +.back-button svg { width:24px;height:24px;stroke:currentColor;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round;flex:none; } +.back-button:hover { background:var(--hover); } +.footer { background:var(--panel); } +@media(max-width:640px){.settings-nav{padding:12px 4px;}.settings-nav a{padding:16px;}.back-button span{display:none;}.back-button{width:40px;padding:8px!important;}} + +.back-button span { font-size:14px;color:inherit; } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cc53488..4ed11ad 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.2" +version = "0.6.9-local.3" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cbc4551..3ecd91c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.2" +version = "0.6.9-local.3" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/integration.js b/src-tauri/resources/integration.js index a8b53a5..b9d973c 100644 --- a/src-tauri/resources/integration.js +++ b/src-tauri/resources/integration.js @@ -2,45 +2,113 @@ "use strict"; if (!window.__WHATRUST_WINDOWS__ || location.origin !== "https://web.whatsapp.com") return; var state = { accounts: [], current_id: "", native_notifications: false }; - var host, root, menu, accountButton, status, lastReport = "", observer, scheduled; + var host, root, menu, accountButton, accountName, accountBadge, status, lastReport = "", observer, scheduled, resizeObserver, resizeTarget, themeObserver; function call(cmd, args) { var t=window.__TAURI__;if(!t||!t.core)return Promise.resolve(null);return t.core.invoke(cmd, args).catch(function () { return null; }); } function button(text, fn) { var b=document.createElement("button");b.type="button";b.textContent=text;b.addEventListener("click",function(e){e.stopPropagation();fn();});return b; } function openPanel() { if(menu.open)menu.close();call("open_app_panel"); } + function positionMenu(){var r=accountButton.getBoundingClientRect();menu.style.top=Math.min(r.bottom+8,innerHeight-menu.offsetHeight-12)+"px";menu.style.left=Math.max(12,Math.min(r.left,innerWidth-menu.offsetWidth-12))+"px";} function draw() { if(!accountButton)return; var focusKey=root.activeElement && root.activeElement.dataset.item; var keepFocus=menu.open && !!focusKey; var own=state.accounts.find(function(a){return a.id===state.current_id;}); - accountButton.textContent=own ? own.name + (own.unread ? " · "+own.unread : "") : "Contas"; - accountButton.title="Trocar conta do WhatsApp"; + accountName.textContent=own ? own.name : "Contas"; + accountBadge.textContent=own && own.unread ? String(own.unread>99?"99+":own.unread) : ""; + accountBadge.hidden=!accountBadge.textContent; + accountButton.setAttribute("aria-label","Trocar conta"+(own?": "+own.name:"")+(own&&own.unread?" · "+own.unread+" conversas não lidas":"")); + accountButton.title=own ? "Conta: "+own.name+" — Trocar conta" : "Trocar conta do WhatsApp"; menu.replaceChildren(); var title=document.createElement("h2");title.textContent="Suas contas";menu.appendChild(title); state.accounts.forEach(function(a){var b=button(a.name+(a.unread?" · "+a.unread+" conversas não lidas":""),function(){menu.close();call("switch_account",{id:a.id});});b.setAttribute("aria-current",String(a.id===state.current_id));b.dataset.item=a.id;menu.appendChild(b);}); var manage=button("Gerenciar contas e configurações",openPanel);manage.dataset.item="manage";menu.appendChild(manage); var close=button("Fechar",function(){menu.close();});close.dataset.item="close";menu.appendChild(close); - status=document.createElement("p");status.setAttribute("role","status");status.textContent="Controles do whatRust";menu.appendChild(status); + status=document.createElement("p");status.setAttribute("role","status");status.textContent=own?"Conta atual: "+own.name:"Escolha uma conta";menu.appendChild(status); if(keepFocus){var restore=Array.from(menu.querySelectorAll("button")).find(function(b){return b.dataset.item===focusKey;})||menu.querySelector("button");if(restore)restore.focus();} } window.__whatrustState=function(next){if(!next)return;state=next;window.__whatrustPreferNative=!!next.native_notifications;draw();}; function create(){ host=document.createElement("span");host.id="__whatrust_controls";host.setAttribute("data-whatrust","controls"); - host.style.cssText="display:inline-flex;align-items:center;flex:0 1 auto;min-width:40px;max-width:190px;margin-inline-start:8px"; + host.style.cssText="display:inline-flex;align-items:center;flex:0 0 auto;min-width:84px;max-width:200px;margin-inline-start:8px;align-self:center"; root=host.attachShadow({mode:"open"}); - var style=document.createElement("style");style.textContent=":host{color:inherit;font:13px system-ui}*{box-sizing:border-box}button{font:inherit;cursor:pointer;border:0;border-radius:8px;padding:7px 8px;background:transparent;color:inherit;min-height:36px}button:hover{background:#00806933}button:focus-visible{outline:2px solid #00a884;outline-offset:2px}.bar{display:flex;align-items:center;gap:5px;min-width:0}.account{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px;font-weight:500}svg{width:21px;height:21px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}@media(max-width:720px){.account{max-width:68px}}dialog{color-scheme:light dark;color:light-dark(#182229,#e9edef);background:light-dark(#fff,#202c33);border:1px solid #8884;border-radius:16px;box-shadow:0 12px 50px #0006;width:330px;max-width:calc(100vw - 32px);padding:16px}dialog::backdrop{background:#0004}dialog button{display:block;width:100%;text-align:left;margin:5px 0;border-radius:9px}h2{font-size:17px;margin:0 0 12px}p{font-size:12px;opacity:.7;margin:12px 0 0}[aria-current=true]{border-color:#00a884}"; + var style=document.createElement("style");style.textContent=` + :host{color:inherit;font:13px system-ui}*{box-sizing:border-box} + button{font:inherit;cursor:pointer;border:0;background:transparent;color:inherit} + button:hover{background:light-dark(#0a0a0a0a,#ffffff14)}button:focus-visible{outline:2px solid #21c063;outline-offset:2px} + .bar{display:flex;align-items:center;gap:4px;flex:none;height:40px} + .bar>button{display:inline-flex;align-items:center;justify-content:center;flex:none;height:40px;min-width:40px;padding:8px;border-radius:50%} + .bar>.account{position:relative;gap:6px;border-radius:20px} + .account-name{max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500} + :host([data-compact="true"]) .account-name{display:none} + .badge{flex:none;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:#21c063;color:#0a0a0a;font:10px/16px system-ui;text-align:center} + :host([data-compact="true"]) .badge{position:absolute;inset-inline-end:0;top:0} + [hidden]{display:none!important}svg{width:24px;height:24px;flex:none;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} + dialog{color-scheme:light dark;color:light-dark(#0a0a0a,#fafafa);background:light-dark(#fff,#232626);border:1px solid #8882;border-radius:8px;box-shadow:0 2px 12px #0005;width:320px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);overflow:auto;padding:12px 8px;inset:auto;margin:0} + dialog::backdrop{background:transparent}dialog button{display:block;width:100%;text-align:left;margin:5px 0;padding:10px 12px;border-radius:8px} + h2{font-size:16px;font-weight:600;margin:4px 12px 10px}p{font-size:12px;opacity:.65;margin:12px 12px 4px}dialog [aria-current=true]{background:#8696a01f;font-weight:600} + `; var bar=document.createElement("span");bar.className="bar"; - accountButton=button("Contas",function(){draw();if(!menu.open)menu.showModal();accountButton.setAttribute("aria-expanded","true");var b=menu.querySelector("button");if(b)b.focus();});accountButton.className="account";accountButton.setAttribute("aria-haspopup","dialog");accountButton.setAttribute("aria-expanded","false"); + accountButton=button("Contas",function(){draw();if(!menu.open)menu.showModal();positionMenu();accountButton.setAttribute("aria-expanded","true");var b=menu.querySelector("button");if(b)b.focus();});accountButton.className="account";accountButton.textContent=""; + var people=document.createElementNS("http://www.w3.org/2000/svg","svg"),peoplePath=document.createElementNS("http://www.w3.org/2000/svg","path");people.setAttribute("viewBox","0 0 24 24");people.setAttribute("aria-hidden","true");peoplePath.setAttribute("d","M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M16 3a4 4 0 0 1 0 8 M22 21v-2a4 4 0 0 0-3-3.87 M13 7a4 4 0 1 1-8 0a4 4 0 0 1 8 0");people.appendChild(peoplePath); + accountName=document.createElement("span");accountName.className="account-name"; + accountBadge=document.createElement("span");accountBadge.className="badge";accountBadge.setAttribute("aria-hidden","true"); + accountButton.append(people,accountName,accountBadge);accountButton.setAttribute("aria-haspopup","dialog");accountButton.setAttribute("aria-expanded","false"); var gear=button("⚙",openPanel);gear.setAttribute("aria-label","Configurações do whatRust");gear.title="Configurações do whatRust"; gear.textContent=""; - var svg=document.createElementNS("http://www.w3.org/2000/svg","svg"),path=document.createElementNS("http://www.w3.org/2000/svg","path");svg.setAttribute("viewBox","0 0 24 24");svg.setAttribute("aria-hidden","true");path.setAttribute("d","M9 3h6l1 3l3 1l2 5l-2 5l-3 1l-1 3H9l-1-3l-3-1l-2-5l2-5l3-1z M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0");svg.appendChild(path);gear.appendChild(svg); - menu=document.createElement("dialog");menu.setAttribute("aria-label","Contas do whatRust");menu.addEventListener("close",function(){accountButton.setAttribute("aria-expanded","false");accountButton.focus();}); + var svg=document.createElementNS("http://www.w3.org/2000/svg","svg"),path=document.createElementNS("http://www.w3.org/2000/svg","path");svg.setAttribute("viewBox","0 0 24 24");svg.setAttribute("aria-hidden","true");path.setAttribute("d","M9.5 3h5l.6 2.4 2 1.2 2.4-.7 2.5 4.3-1.8 1.8v2.3l1.8 1.8-2.5 4.3-2.4-.7-2 1.2-.6 2.1h-5l-.6-2.1-2-1.2-2.4.7L2 16.1l1.8-1.8V12L2 10.2l2.5-4.3 2.4.7 2-1.2z M15.5 12.5a3.5 3.5 0 1 1-7 0a3.5 3.5 0 0 1 7 0");svg.appendChild(path);gear.appendChild(svg); + menu=document.createElement("dialog");menu.setAttribute("aria-label","Contas do whatRust");menu.addEventListener("click",function(e){var r=menu.getBoundingClientRect();if(e.target===menu&&(e.clientXr.right||e.clientYr.bottom))menu.close();});menu.addEventListener("close",function(){accountButton.setAttribute("aria-expanded","false");accountButton.focus();}); bar.append(accountButton,gear);root.append(style,bar,menu);draw(); } - function report(mounted,detector){var value=mounted+":"+detector;if(value===lastReport)return;lastReport=value;var t=window.__TAURI__;if(!t||!t.core){lastReport="";return;}t.core.invoke("report_integration",{mounted:mounted,detector:detector}).catch(function(){lastReport="";});} - function anchor(){return document.querySelector("#side header") || Array.from(document.querySelectorAll("#app header, #app [role=banner]")).find(function(e){return !e.closest("#main");}) || null;} + function resolvedTheme(){ + var header=host&&host.closest("header"),c=header&&getComputedStyle(header).color; + var rgb=c&&c.match(/[\d.]+/g); + if(rgb&&rgb.length>=3)return .2126*+rgb[0]+.7152*+rgb[1]+.0722*+rgb[2]>140?"dark":"light"; + return matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"; + } + function report(mounted,detector){var theme=resolvedTheme();if(root){root.host.style.colorScheme=theme;}var value=mounted+":"+detector+":"+theme;if(value===lastReport)return;lastReport=value;if(mounted&&host){var h=host.closest("header").getBoundingClientRect(),r=host.getBoundingClientRect();call("dlog",{msg:"controls: safe horizontal header="+Math.round(h.width)+"x"+Math.round(h.height)+" controls="+Math.round(r.width)+"x"+Math.round(r.height)});}var t=window.__TAURI__;if(!t||!t.core){lastReport="";return;}t.core.invoke("report_integration",{mounted:mounted,detector:detector,theme:theme}).catch(function(){lastReport="";});} + function horizontal(e){ + var r=e.getBoundingClientRect(),style=getComputedStyle(e); + return r.width>=260 && r.height>=36 && r.height<=100 && r.width>r.height*2 && style.visibility!=="hidden" && style.display!=="none"; + } + function anchor(){ + var side=document.getElementById("side")||document.getElementById("pane-side"),sideBox=side&&side.getBoundingClientRect(); + var candidates=Array.from(document.querySelectorAll("#app header,#app [role=banner]")) + .filter(function(e){ + if(e.closest("#main")||!horizontal(e))return false; + var r=e.getBoundingClientRect(); + // In current WhatsApp the conversation-list header is a sibling of #side. + // Its navigation rail is ALSO a header. Never select by document order. + if(sideBox && sideBox.width>0)return Math.abs(r.left-sideBox.left)<=24 && Math.abs(r.width-sideBox.width)<=48 && (side.contains(e)||(r.bottom<=sideBox.top+4 && sideBox.top-r.bottom<=100)); + return !!e.querySelector("h1,h2,[role=heading]"); + }); + var header=candidates.find(function(e){return !candidates.some(function(other){return other!==e&&e.contains(other);});}); + if(!header)return null; + var width=header.getBoundingClientRect().width; + // Use the existing horizontal action row, not an outer header whose child + // already occupies all available width. Do not alter WhatsApp's CSS. + var rows=Array.from(header.querySelectorAll("div,section")).slice(0,32).filter(function(e){ + var c=getComputedStyle(e),r=e.getBoundingClientRect();return horizontal(e)&&c.display==="flex"&&c.flexDirection==="row"&&r.width>=width-48&&!!e.querySelector("button,[role=button]"); + }); + var row=rows.find(function(e){return !rows.some(function(other){return other!==e&&e.contains(other);});}); + if(!row){var c=getComputedStyle(header);if(c.display!=="flex"||c.flexDirection!=="row")return null;row=header;} + return {header:header,row:row}; + } + function fits(target){ + var area=target.header.getBoundingClientRect(),r=host.getBoundingClientRect(); + if(r.width<84||r.height<36||r.leftarea.right+1||r.toparea.bottom+1)return false; + return !Array.from(target.header.querySelectorAll("button,[role=button],h1,h2,[role=heading]")).some(function(e){ + if(host.contains(e))return false;var b=e.getBoundingClientRect();return b.width>0&&b.height>0&&r.leftb.left+1&&r.topb.top+1; + }); + } function mount(){ var target=anchor();if(!host)create(); - if(target && host.parentNode!==target)target.appendChild(host); - if(!target && host.isConnected)host.remove(); + var resizeNode=document.getElementById("side")||document.documentElement; + if(resizeObserver&&resizeTarget!==resizeNode){resizeObserver.disconnect();resizeObserver.observe(resizeNode);resizeTarget=resizeNode;} + if(target){ + host.dataset.compact=String(target.header.getBoundingClientRect().width<560); + if(host.parentNode!==target.row){if(menu.open)menu.close();target.row.appendChild(host);} + if(!fits(target)&&host.dataset.compact!=="true")host.dataset.compact="true"; + if(!fits(target)){host.remove();target=null;} + }else if(host.isConnected)host.remove(); report(!!target,messageRows().length>0); scanMessages(); } @@ -85,9 +153,14 @@ if(!document.body)return; observer=new MutationObserver(function(records){if(records.some(function(r){return !host || !host.contains(r.target);}))schedule();}); observer.observe(document.body,{subtree:true,childList:true,attributes:true,attributeFilter:["data-id","data-muted"]}); + if(window.ResizeObserver)resizeObserver=new ResizeObserver(schedule); + themeObserver=new MutationObserver(schedule); + themeObserver.observe(document.documentElement,{attributes:true,attributeFilter:["class","style","data-theme"]}); + themeObserver.observe(document.body,{attributes:true,attributeFilter:["class","style","data-theme"]}); + window.addEventListener("resize",mount); mount();call("integration_state").then(window.__whatrustState); setInterval(function(){call("integration_state").then(window.__whatrustState);mount();},10000); - window.addEventListener("pagehide",function(){observer.disconnect();}); + window.addEventListener("pagehide",function(){observer.disconnect();if(resizeObserver)resizeObserver.disconnect();themeObserver.disconnect();window.removeEventListener("resize",mount);}); } if(document.body)start();else document.addEventListener("DOMContentLoaded",start,{once:true}); })(); diff --git a/src-tauri/src/integration.rs b/src-tauri/src/integration.rs index 33e7ec9..90ba9c0 100644 --- a/src-tauri/src/integration.rs +++ b/src-tauri/src/integration.rs @@ -11,6 +11,7 @@ pub struct Integration(pub Mutex>); pub struct Adapter { pub mounted: bool, pub detector: bool, + pub theme: Option, #[serde(skip)] pub last_signal: Option, #[serde(skip)] @@ -114,6 +115,7 @@ pub fn report_integration( app: AppHandle, mounted: bool, detector: bool, + theme: Option, ) -> Result<(), String> { remote(&window)?; app.state::() @@ -125,6 +127,7 @@ pub fn report_integration( .clone_from(&Adapter { mounted, detector, + theme: theme.filter(|t| matches!(t.as_str(), "dark" | "light")), last_signal: None, signals: 0, }); diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 2b745db..fc4cf15 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -400,6 +400,18 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { }) .map(Ok) .unwrap_or_else(|| initialize(app, false))?; + let theme = selected(app, owner.label()) + .and_then(|label| { + app.state::() + .0 + .lock() + .unwrap() + .get(&label) + .and_then(|a| a.theme.clone()) + }) + .unwrap_or_else(|| "system".into()); + let encoded_theme = serde_json::to_string(&theme).expect("theme string"); + let theme_script = format!("document.documentElement.dataset.theme={encoded_theme}"); let panel = if let Some(v) = app.get_webview("settings") { if v.window().label() != owner.label() { v.reparent(&owner)?; @@ -408,12 +420,14 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { } else { owner.add_child( WebviewBuilder::new("settings", WebviewUrl::App("index.html".into())) + .initialization_script(format!(r#"document.addEventListener("DOMContentLoaded",function(){{{theme_script}}},{{once:true}})"#)) .data_directory(app.path().app_data_dir()?.join("management")) .on_navigation(crate::integration::local_url), tauri::LogicalPosition::new(0., 0.), tauri::LogicalSize::new(800., 600.), )? }; + let _ = panel.eval(theme_script); for (l, v) in app.webviews() { if l.starts_with("wa-") && v.window().label() == owner.label() { let _ = v.hide(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a7f1f3e..271e373 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.2", + "version": "0.6.9-local.3", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From e9eeaeb3819245247cd46d7c0760f2e0358f744f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 15:42:41 -0300 Subject: [PATCH 07/22] Avoid repeated mounting when header space is insufficient --- scripts/adapter-check.template.cjs | 4 ++++ src-tauri/resources/integration.js | 3 +++ 2 files changed, 7 insertions(+) diff --git a/scripts/adapter-check.template.cjs b/scripts/adapter-check.template.cjs index 72a3796..4025b0e 100644 --- a/scripts/adapter-check.template.cjs +++ b/scripts/adapter-check.template.cjs @@ -64,6 +64,10 @@ async (page) => { check('unsafe_narrow_header_uses_recovery',await page.locator('#__whatrust_controls').count()===0&&await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.mounted===false)); await page.evaluate(()=>document.getElementById('list').style.width='');await page.waitForTimeout(40);await page.clock.runFor(300); check('safe_width_restores_controls_once',await page.locator('#actions>#__whatrust_controls').count()===1); + await page.evaluate(()=>{window.__mountMutations=0;window.__probe=new MutationObserver(rs=>{for(const r of rs)for(const n of [...r.addedNodes,...r.removedNodes])if(n.id==='__whatrust_controls')window.__mountMutations++;});window.__probe.observe(document.getElementById('app'),{subtree:true,childList:true});document.getElementById('list').style.width='280px';}); + await page.waitForTimeout(40);await page.clock.runFor(1600); + check('unsafe_header_does_not_retry_own_mutations',await page.evaluate(()=>window.__mountMutations<=4)); + await page.evaluate(()=>{window.__probe.disconnect();document.getElementById('list').style.width='';});await page.waitForTimeout(40);await page.clock.runFor(300); check('reports_whatsapp_dark_theme',await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.theme==='dark')); await page.evaluate(()=>document.body.style.color='rgb(10,10,10)');await page.clock.runFor(300); check('reports_whatsapp_light_theme_change',await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.theme==='light')); diff --git a/src-tauri/resources/integration.js b/src-tauri/resources/integration.js index b9d973c..39805f8 100644 --- a/src-tauri/resources/integration.js +++ b/src-tauri/resources/integration.js @@ -111,6 +111,9 @@ }else if(host.isConnected)host.remove(); report(!!target,messageRows().length>0); scanMessages(); + // Consume our own insertion/removal records after processing this layout. + // Otherwise an unsuitable header causes append/remove retries every 200ms. + if(observer)observer.takeRecords(); } function schedule(){if(scheduled)return;scheduled=setTimeout(function(){scheduled=0;mount();},200);} // Adapter contract: only mounted message IDs/direction and explicit mute state. From afad60ae51f0a6d40bd5b675ef4f182133e55fee Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 15:50:16 -0300 Subject: [PATCH 08/22] Style recovery toolbar and remove its compact scrollbar --- docs/windows-usabilidade.md | 6 +++++- settings-ui/shell.html | 22 +++++++++++++++++++++- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/resources/integration.js | 2 +- src-tauri/src/sessions.rs | 14 ++++++++++++++ src-tauri/tauri.conf.json | 2 +- 7 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index 1da3db0..a450808 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -42,7 +42,7 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.3`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.4`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. @@ -67,3 +67,7 @@ A inspeção do WhatsApp real encontrou uma barra de navegação vertical implem O menu de contas abre próximo ao botão, respeita os limites da janela e fecha por Esc ou clique externo. O tema claro/escuro resolvido pelo adaptador é transmitido apenas como um valor validado (`light`/`dark`) e aplicado à visualização local quando ela abre. Isso alinha painel, menus, diálogos e controles mesmo quando WhatsApp e Windows usam temas diferentes. A identidade do whatRust permanece indicada no painel. As regressões incluem navegação vertical antes do cabeçalho correto, cabeçalhos aninhados, ações originais ocupando a largura, telas estreitas, recuperação e mudança de tema. O recebimento real de mensagens continua dependendo de validação pelo usuário. + +## Barra de recuperação (local.4) + +O shell tem uma faixa de 48 px com seletor de contas e configurações. Raiz e corpo não geram rolagem; o texto da tela completa fica oculto na faixa compacta. Seus controles e cores seguem os mesmos tamanhos e temas do WhatsApp. A rolagem das conversas e do painel de configurações não é modificada. diff --git a/settings-ui/shell.html b/settings-ui/shell.html index 58a6b7b..539bdb0 100644 --- a/settings-ui/shell.html +++ b/settings-ui/shell.html @@ -1 +1,21 @@ -whatRust
whatRust

Conecte uma conta usando o QR Code ou o número de telefone na tela do WhatsApp. Contas e configurações permanecem acessíveis antes do login. Se todas as janelas estiverem destacadas, use o seletor acima para retornar.

\ No newline at end of file + +whatRust +
+

Escolha uma conta para continuar

Use o seletor acima para abrir um WhatsApp ou as configurações para gerenciar suas contas.

+ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4ed11ad..75c0023 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.3" +version = "0.6.9-local.4" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3ecd91c..bcf6e23 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.3" +version = "0.6.9-local.4" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/integration.js b/src-tauri/resources/integration.js index 39805f8..229c422 100644 --- a/src-tauri/resources/integration.js +++ b/src-tauri/resources/integration.js @@ -59,7 +59,7 @@ bar.append(accountButton,gear);root.append(style,bar,menu);draw(); } function resolvedTheme(){ - var header=host&&host.closest("header"),c=header&&getComputedStyle(header).color; + var candidate=(!host||!host.isConnected)&&anchor();var header=(host&&host.closest("header"))||(candidate&&candidate.header),c=header&&getComputedStyle(header).color; var rgb=c&&c.match(/[\d.]+/g); if(rgb&&rgb.length>=3)return .2126*+rgb[0]+.7152*+rgb[1]+.0722*+rgb[2]>140?"dark":"light"; return matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"; diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index fc4cf15..9700ddd 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -217,6 +217,20 @@ pub fn layout(app: &AppHandle, parent: &str) { size: tauri::PhysicalSize::new(size.width.max(1), height).into(), }); if shell { + let theme = active + .as_ref() + .and_then(|label| { + app.state::() + .0 + .lock() + .unwrap() + .get(label) + .and_then(|a| a.theme.clone()) + }) + .unwrap_or_else(|| "system".into()); + if let Ok(json) = serde_json::to_string(&theme) { + let _=v.eval(format!("if(document.documentElement&&document.documentElement.dataset.theme!=={json})document.documentElement.dataset.theme={json}")); + } let panel_open = state(app).panel_parent.lock().unwrap().as_deref() == Some(parent); if !panel_open && (active.is_none() || offset > 0) { let _ = v.show(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 271e373..e18ac94 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.3", + "version": "0.6.9-local.4", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From 31b0aa0f5d689698551bad2c694539248613eb57 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 16:24:15 -0300 Subject: [PATCH 09/22] Fix account activation, anchored menus and lock lifecycle; refine Portuguese UI --- docs/windows-usabilidade.md | 20 ++- scripts/shell-check.template.cjs | 40 +++++ scripts/test-adapter.ps1 | 2 +- scripts/test-shell.ps1 | 15 ++ scripts/test-ui.ps1 | 15 ++ scripts/ui-audit.template.cjs | 64 ++++++++ settings-ui/dialog.css | 4 +- settings-ui/dialog.js | 5 +- settings-ui/index.html | 4 +- settings-ui/lock.css | 59 ++----- settings-ui/lock.html | 17 +- settings-ui/lock.js | 20 +-- settings-ui/main.js | 40 ++++- settings-ui/shell.html | 7 +- settings-ui/shell.js | 19 ++- settings-ui/style.css | 10 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/resources/bridge.js | 14 +- src-tauri/resources/bridge.test.mjs | 16 +- src-tauri/resources/integration.js | 9 +- src-tauri/src/accounts.rs | 14 +- src-tauri/src/applock.rs | 145 +++++++++++++--- src-tauri/src/biometric/macos.rs | 4 +- src-tauri/src/biometric/mod.rs | 2 +- src-tauri/src/commands.rs | 60 ++++--- src-tauri/src/integration.rs | 9 +- src-tauri/src/lib.rs | 8 +- src-tauri/src/lock.rs | 17 +- src-tauri/src/opener.rs | 8 +- src-tauri/src/sessions.rs | 30 +++- src-tauri/src/settings.rs | 12 +- src-tauri/src/smoke.rs | 246 ++++++++++++++++++++++++---- src-tauri/src/tray.rs | 12 +- src-tauri/src/window.rs | 58 +++---- src-tauri/tauri.conf.json | 2 +- 36 files changed, 767 insertions(+), 244 deletions(-) create mode 100644 scripts/shell-check.template.cjs create mode 100644 scripts/test-shell.ps1 create mode 100644 scripts/test-ui.ps1 create mode 100644 scripts/ui-audit.template.cjs diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index a450808..6b74880 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -34,6 +34,8 @@ node --test src-tauri/resources/notifications.test.mjs node settings-ui/comboToAccelerator.test.mjs node settings-ui/dialog.test.mjs .\scripts\test-adapter.ps1 +.\scripts\test-ui.ps1 +.\scripts\test-shell.ps1 ``` Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout. O teste do adaptador usa Playwright CLI com página e dados sintéticos e não acessa conversas reais. @@ -42,7 +44,7 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.4`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.5`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. @@ -71,3 +73,19 @@ As regressões incluem navegação vertical antes do cabeçalho correto, cabeça ## Barra de recuperação (local.4) O shell tem uma faixa de 48 px com seletor de contas e configurações. Raiz e corpo não geram rolagem; o texto da tela completa fica oculto na faixa compacta. Seus controles e cores seguem os mesmos tamanhos e temas do WhatsApp. A rolagem das conversas e do painel de configurações não é modificada. + +## Largura do seletor (local.5) + +O seletor compacto acompanha a largura do nome selecionado até 320 px (ou 65% da janela). O conteúdo excedente usa reticências e o nome completo fica na dica e na opção acessível. Um elemento de medição com a mesma fonte evita que opções maiores afastem a seta de nomes curtos. A atualização local recebe o ID da conta ativa, mantendo nome e janela sincronizados. + +## Menus, troca de contas e bloqueio (local.5) + +Menus locais removem o `inset` padrão do popover, medem sua altura real e escolhem acima/abaixo do botão conforme o espaço. Acompanham rolagem e redimensionamento e fecham quando o botão sai do painel visível. Restauração de foco não rola o painel nem toma foco de outro menu/diálogo. Diálogos altos e avisos de download têm limites próprios; nomes extensos não ampliam o painel. + +A ativação de conta é uma operação única na fila da interface: seleciona a identidade, oculta as demais visualizações, mostra a conta, aplica os limites físicos e publica o mesmo ID ao seletor. O tamanho final é aplicado depois de mostrar a visualização. Atualizações pendentes do seletor usam o ID mais recente. + +A tela de senha usa SVG de cadeado, paleta neutra, botão verde e textos em português. A revisão também inclui diálogos de senha, menus nativos e avisos de download/anexo. Bloqueio e ativação compartilham a fila da interface. O desbloqueio sempre retorna do callback IPC antes de destruir o próprio WebView2; destruí-lo dentro do callback podia travar a reintegração. O painel verifica novamente o estado de bloqueio antes de revelar sua janela. + +A redefinição exige confirmação e grava um marcador fora da pasta dos perfis. A limpeza só ocorre na próxima inicialização, antes de criar WebViews. Falhas mantêm o marcador e abortam a abertura dos perfis; a redefinição não declara sucesso ignorando arquivos em uso. Os testes destrutivos usam somente diretórios temporários sintéticos. + +A regressão nativa usa senha sintética para validar rejeição de senha incorreta, troca de senha, bloqueio de janelas principal/destacada, recusa de desbloqueio pela página remota, biometria desativada, restauração da conta pendente, bloqueio ao fechar e desativação com senha atual. O teste visual cobre temas claro/escuro, teclado e textos em português. Autenticação real pelo Windows Hello requer interação com o dispositivo e permanece pendente; não é simulada como sucesso. diff --git a/scripts/shell-check.template.cjs b/scripts/shell-check.template.cjs new file mode 100644 index 0000000..012fe33 --- /dev/null +++ b/scripts/shell-check.template.cjs @@ -0,0 +1,40 @@ +async(page)=>{ + const checks=[];const check=(n,p)=>{if(!p)throw Error(n);checks.push(n)}; + await page.addInitScript(()=>{window.calls=[];window.__TAURI__={core:{invoke:async(cmd,args)=>{window.calls.push({cmd,args});return cmd==='list_accounts'?[{id:'default',name:'Matheus',unread:2},{id:'acct-1',name:'VS Prestadora de Serviços',unread:0}]:null;}}};}); + await page.emulateMedia({colorScheme:'dark'}); + await page.goto(__SHELL_URL__); + for(const width of [480,560,1920]){ + await page.setViewportSize({width,height:48}); + check('compact_shell_no_scroll_'+width,await page.evaluate(()=>document.documentElement.scrollHeight<=innerHeight&&document.documentElement.scrollWidth<=innerWidth&&getComputedStyle(document.querySelector('main')).display==='none')); + check('toolbar_controls_fit_'+width,await page.evaluate(()=>Array.from(document.querySelectorAll('button,select')).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;}))); + } + await page.selectOption('#accounts','acct-1'); + check('account_switch_preserved',await page.evaluate(()=>window.calls.some(c=>c.cmd==='open_account'&&c.args.id==='acct-1'))); + await page.getByRole('button',{name:'Configurações do whatRust',exact:true}).click(); + check('settings_access_preserved',await page.evaluate(()=>window.calls.some(c=>c.cmd==='open_app_panel'))); + await page.setViewportSize({width:1000,height:800}); + check('full_window_empty_state',await page.getByRole('heading',{name:'Escolha uma conta para continuar'}).isVisible()); + check('neutral_dark_background',await page.evaluate(()=>getComputedStyle(document.body).backgroundColor==='rgb(22, 23, 23)')); + await page.setViewportSize({width:1280,height:48}); + + await page.evaluate(()=>window.refresh('default')); + check('arrow_close_to_short_name',await page.evaluate(()=>{const m=document.getElementById('account-size'),r=document.createRange();r.selectNodeContents(m);const arrow=document.querySelector('.chevron').getBoundingClientRect();return Math.abs(arrow.left-r.getBoundingClientRect().right-8)<2;})); + await page.screenshot({path:'output/playwright/shell-short-local5.png'}); + const longName='Conta empresarial com um nome muito longo para conferir as reticências no seletor'; + await page.evaluate(name=>{const s=document.getElementById('accounts');s.options[1].textContent=name;s.value='acct-1';s.dispatchEvent(new Event('change'));},longName); + const longWidth=await page.locator('#accounts').evaluate(e=>e.getBoundingClientRect().width); + check('long_name_capped_with_ellipsis',await page.locator('#accounts').evaluate(e=>getComputedStyle(e).textOverflow==='ellipsis'&&e.getBoundingClientRect().width<=320&&document.getElementById('account-size').scrollWidth>e.clientWidth)); + check('full_name_available_in_tooltip',await page.locator('#accounts').getAttribute('title')===longName); + await page.screenshot({path:'output/playwright/shell-long-local5.png'}); + await page.evaluate(()=>window.refresh('acct-1')); + check('selected_account_tracks_active_window',await page.locator('#accounts').inputValue()==='acct-1'); + await page.evaluate(()=>window.refresh('default')); + check('short_name_shrinks_after_long_name',await page.locator('#accounts').evaluate(e=>e.getBoundingClientRect().width){window.__TAURI__.core.invoke=(cmd)=>cmd==='list_accounts'?new Promise(resolve=>window.__releaseList=resolve):Promise.resolve();}); + await page.reload(); + await page.evaluate(async()=>{window.refresh('default');window.refresh('acct-1');window.__releaseList([{id:'default',name:'Matheus'},{id:'acct-1',name:'Empresa'}]);}); + await page.waitForFunction(()=>document.getElementById('accounts').options.length===2); + check('delayed_refresh_keeps_latest_active_account',await page.locator('#accounts').inputValue()==='acct-1'); + return {passed:true,checks}; +} diff --git a/scripts/test-adapter.ps1 b/scripts/test-adapter.ps1 index f645791..a7fd394 100644 --- a/scripts/test-adapter.ps1 +++ b/scripts/test-adapter.ps1 @@ -4,7 +4,7 @@ $taskOutput=Join-Path $taskRoot 'output\playwright' New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null $taskSource=Get-Content -LiteralPath (Join-Path $taskRoot 'src-tauri\resources\integration.js') -Raw -Encoding UTF8 $taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'adapter-check.template.cjs') -Raw -Encoding UTF8 -$taskCode=$taskTemplate.Replace('__ADAPTER_SOURCE__',($taskSource | ConvertTo-Json -Compress)) +$taskCode=$taskTemplate.Replace('__ADAPTER_SOURCE__',(ConvertTo-Json -InputObject ([string]$taskSource) -Compress)) $taskFile=Join-Path $taskOutput 'adapter-check.cjs' [IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter open about:blank | Out-Null diff --git a/scripts/test-shell.ps1 b/scripts/test-shell.ps1 new file mode 100644 index 0000000..6d476af --- /dev/null +++ b/scripts/test-shell.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop' +$taskRoot=Split-Path -Parent $PSScriptRoot +$taskOutput=Join-Path $taskRoot 'output\playwright' +New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null +$taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'shell-check.template.cjs') -Raw -Encoding UTF8 +$taskUrl=([uri](Join-Path $taskRoot 'settings-ui\shell.html')).AbsoluteUri +$taskCode=$taskTemplate.Replace('__SHELL_URL__',($taskUrl | ConvertTo-Json -Compress)) +$taskFile=Join-Path $taskOutput 'shell-check.cjs' +[IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-shell open about:blank | Out-Null +$taskResult=npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-shell run-code --filename $taskFile +$taskResult | Set-Content -LiteralPath (Join-Path $taskOutput 'shell-result.log') -Encoding UTF8 +$taskResult | Select-Object -First 3 +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-shell close | Out-Null +if (($taskResult -join "`n") -match '### Error') { exit 1 } diff --git a/scripts/test-ui.ps1 b/scripts/test-ui.ps1 new file mode 100644 index 0000000..6954d54 --- /dev/null +++ b/scripts/test-ui.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop' +$taskRoot=Split-Path -Parent $PSScriptRoot +$taskOutput=Join-Path $taskRoot 'output\playwright' +New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null +$taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'ui-audit.template.cjs') -Raw -Encoding UTF8 +$taskUrl=([uri](Join-Path $taskRoot 'settings-ui\index.html')).AbsoluteUri +$taskCode=$taskTemplate.Replace('__SETTINGS_URL__',($taskUrl | ConvertTo-Json -Compress)) +$taskFile=Join-Path $taskOutput 'ui-audit.cjs' +[IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-ui-audit open about:blank | Out-Null +$taskResult=npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-ui-audit run-code --filename $taskFile +$taskResult | Set-Content -LiteralPath (Join-Path $taskOutput 'ui-audit-result.log') -Encoding UTF8 +$taskResult | Select-Object -First 3 +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-ui-audit close | Out-Null +if (($taskResult -join "`n") -match '### Error') { exit 1 } diff --git a/scripts/ui-audit.template.cjs b/scripts/ui-audit.template.cjs new file mode 100644 index 0000000..6a4386f --- /dev/null +++ b/scripts/ui-audit.template.cjs @@ -0,0 +1,64 @@ +async(page)=>{ + const checks=[];const check=(name,ok)=>{if(!ok)throw Error(name);checks.push(name);}; + await page.addInitScript(()=>{ + window.__settings={close_to_tray:true,start_minimized:false,autostart:false,hotkey_enabled:true,hotkey:'CmdOrCtrl+Shift+W',notifications:true,notify_when_focused:true,notify_active_chat:true,respect_muted:true,zoom:1,menu_bar_icon:true}; + window.__accounts=[{id:'default',name:'Matheus',order:0,unread:2,is_default:true,detached:false},{id:'acct-1',name:'Empresa '+('NomeMuitoLongo'.repeat(18)),order:1,unread:0,is_default:false,detached:false}]; + window.__events=[];window.__TAURI__={core:{invoke:async(cmd,args)=>{window.__events.push({cmd,args});if(cmd==='list_accounts')return window.__accounts;if(cmd==='get_settings')return window.__settings;if(cmd==='set_settings'){window.__settings=args.settings;return null;}if(cmd==='get_lock_status')return {enabled:false,biometric_available:false};if(cmd==='notification_diagnostics')return {version:'test',accounts:window.__accounts,adapters:{},native:{},recent:[]};if(cmd==='unlock_password')return false;if(cmd==='unlock_biometric')return false;if(cmd==='test_notification')return 'Teste solicitado';return null;}}}; + }); + const url=__SETTINGS_URL__; + await page.goto(url); + const names=['Contas','Notificações','Aparência','Segurança','Inicialização','Atalhos','Diagnóstico']; + for(const scheme of ['light','dark'])for(const [width,height] of [[560,480],[900,700],[1920,1000],[2560,1300]]){ + await page.emulateMedia({colorScheme:scheme});await page.setViewportSize({width,height}); + for(const name of names){await page.getByRole('link',{name,exact:true}).click();check('panel_'+scheme+'_'+width+'_'+name,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth&&Array.from(document.querySelectorAll('.prefs input,.prefs select,.prefs button')).filter(e=>e.getClientRects().length).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1;})));} + await page.getByRole('link',{name:'Contas',exact:true}).click(); + const trigger=page.getByRole('button',{name:'Opções de Matheus',exact:true});await trigger.click(); + check('menu_anchored_'+scheme+'_'+width,await page.evaluate(()=>{const b=document.querySelector('[aria-label="Opções de Matheus"]').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();const gap=Math.min(Math.abs(m.top-b.bottom-6),Math.abs(b.top-m.bottom-6));return m.left>=12&&m.right<=innerWidth-12+1&&m.top>=12&&m.bottom<=innerHeight-12+1&&Math.abs(m.right-b.right)<=1&&gap<=1;})); + await page.keyboard.press('End');check('menu_keyboard_'+scheme+'_'+width,await page.evaluate(()=>document.activeElement===document.querySelector('#account-actions button:last-child'))); + await page.keyboard.press('Escape');check('menu_focus_restore_'+scheme+'_'+width,await trigger.evaluate(e=>document.activeElement===e)); + } + await page.setViewportSize({width:900,height:600});await page.getByRole('link',{name:'Contas',exact:true}).click();await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + await page.getByRole('menuitem',{name:'Renomear',exact:true}).click(); + check('rename_dialog_focus_not_stolen',await page.getByRole('dialog').locator('input').evaluate(e=>document.activeElement===e)); + await page.keyboard.press('Escape'); + for(const [width,height] of [[560,480],[900,480]]){ + await page.setViewportSize({width,height}); + await page.evaluate(()=>{window.__auditDialog=Dlg.confirm('Conteúdo '+('teste'.repeat(200)),{title:'Confirmação '+('nome'.repeat(40)),okLabel:'Confirmar operação'});}); + check('long_dialog_within_'+width,await page.getByRole('dialog').evaluate(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight&&e.scrollWidth<=e.clientWidth;})); + await page.keyboard.press('Escape'); + } + await page.setViewportSize({width:900,height:600});await page.getByRole('link',{name:'Contas',exact:true}).click();await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + await page.keyboard.press('Escape');await page.locator('.account-more').last().click(); + check('changing_menu_keeps_focus_in_new_menu',await page.evaluate(()=>document.getElementById('account-actions').contains(document.activeElement))); + await page.setViewportSize({width:560,height:480});await page.waitForTimeout(50); + check('menu_follows_resize',await page.evaluate(()=>{const b=document.querySelector('.account-more:last-child').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();return m.left>=12&&m.right<=innerWidth-11&&m.top>=12&&m.bottom<=innerHeight-11;})); + await page.keyboard.press('Escape'); + await page.evaluate(()=>{const f=document.createElement('div');f.id='audit-filler';f.style.height='1200px';document.getElementById('sec-contas').appendChild(f);}); + await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + await page.evaluate(()=>document.querySelector('.prefs').scrollTo({top:900,behavior:'instant'}));await page.waitForFunction(()=>document.getElementById('account-actions').dataset.open==='false'); + check('menu_closes_when_anchor_scrolls_away',await page.evaluate(()=>document.getElementById('account-actions').dataset.open==='false'&&document.querySelector('.prefs').scrollTop>500)); + await page.evaluate(()=>{document.getElementById('audit-filler').remove();document.querySelector('.prefs').scrollTop=0;window.__nativeShow=HTMLElement.prototype.showPopover;HTMLElement.prototype.showPopover=undefined;}); + await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + check('menu_fallback_anchored',await page.evaluate(()=>{const b=document.querySelector('[aria-label="Opções de Matheus"]').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();return Math.abs(m.right-b.right)<=1&&Math.min(Math.abs(m.top-b.bottom-6),Math.abs(b.top-m.bottom-6))<=1;})); + await page.keyboard.press('Escape');await page.evaluate(()=>HTMLElement.prototype.showPopover=window.__nativeShow); + await page.evaluate(()=>{window.__oneButtonDialog=Dlg.alert('Teste de foco');});await page.keyboard.press('Tab'); + check('single_button_dialog_traps_focus',await page.getByRole('dialog').evaluate(e=>e.contains(document.activeElement)));await page.keyboard.press('Escape'); + await page.goto(new URL('lock.html',url).href); + for(const [width,height] of [[560,480],[900,700]]){await page.setViewportSize({width,height});check('lock_controls_'+width,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth&&Array.from(document.querySelectorAll('button,input')).filter(e=>e.getClientRects().length).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;})));} + for(const scheme of ['light','dark'])for(const [width,height] of [[320,480],[420,540]]){ + await page.emulateMedia({colorScheme:scheme});await page.setViewportSize({width,height}); + check('lock_layout_'+scheme+'_'+width,await page.locator('.lock-card').evaluate(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;})); + check('lock_vector_icon_'+scheme+'_'+width,await page.locator('.lock-glyph svg').count()===1&&!await page.locator('.lock-glyph').textContent()); + await page.screenshot({path:'output/playwright/lock-'+scheme+'-'+width+'-local5.png'}); + } + await page.getByRole('button',{name:'Desbloquear',exact:true}).click(); + check('empty_password_portuguese',await page.locator('#error').textContent()==='Digite sua senha.'); + await page.getByLabel('Senha',{exact:true}).fill('incorreta');await page.keyboard.press('Enter'); + await page.waitForFunction(()=>document.getElementById('error').textContent.includes('Senha incorreta')); + check('wrong_password_feedback_and_focus',await page.locator('#password').evaluate(e=>!e.value&&document.activeElement===e&&!document.getElementById('unlock').disabled)); + await page.getByRole('link',{name:'Esqueceu a senha? Redefinir…'}).click(); + check('reset_confirmation_portuguese',await page.getByRole('dialog').textContent().then(t=>t.includes('Redefinir o whatRust?')&&t.includes('todas as contas')&&t.includes('Cancelar'))); + await page.getByRole('button',{name:'Cancelar',exact:true}).click(); + check('cancel_reset_has_no_side_effect',await page.evaluate(()=>!window.__events.some(e=>e.cmd==='reset_app_lock'))); + return {passed:true,count:checks.length,checks}; +} diff --git a/settings-ui/dialog.css b/settings-ui/dialog.css index e43bd95..46653a6 100644 --- a/settings-ui/dialog.css +++ b/settings-ui/dialog.css @@ -8,7 +8,7 @@ } .dlg-backdrop[hidden] { display: none; } .dlg { - width: 100%; max-width: 340px; padding: 20px; + box-sizing:border-box;width:100%;max-width:340px;max-height:calc(100vh - 48px);overflow-y:auto;overflow-wrap:anywhere;padding:20px; border-radius: 12px; box-shadow: 0 14px 44px #0007; background: var(--card,#fff); color: var(--fg,#0a0a0a); font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; @@ -27,7 +27,7 @@ .dlg-input:focus { outline: 2px solid #25d36688; outline-offset: 1px; } .dlg-error { color: #f15c6d; font-size: 13px; margin: 8px 0 0; } .dlg-error[hidden] { display: none; } -.dlg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } +.dlg-actions { display:flex;flex-wrap:wrap; justify-content: flex-end; gap: 8px; margin-top: 18px; } .dlg-actions button { padding: 8px 16px; font-size: 14px; font-weight: 600; border: 0; border-radius: 20px; cursor: pointer; diff --git a/settings-ui/dialog.js b/settings-ui/dialog.js index 60bdc0a..9162ba5 100644 --- a/settings-ui/dialog.js +++ b/settings-ui/dialog.js @@ -44,6 +44,7 @@ // Keep focus inside the dialog: it is modal in intent, and a Tab that // escapes it lands on controls the dialog is covering. var f = open.focusable; + if (f.length === 1) { e.preventDefault(); f[0].focus(); return; } if (f.length < 2) return; var first = f[0]; var last = f[f.length - 1]; @@ -90,7 +91,7 @@ var actions = el("div", "dlg-actions"); var cancel = null; if (kind !== "alert") { - cancel = el("button", "dlg-secondary", opts.cancelLabel || "Cancel"); + cancel = el("button", "dlg-secondary", opts.cancelLabel || "Cancelar"); cancel.type = "button"; actions.appendChild(cancel); } @@ -108,7 +109,7 @@ // Never trim a password: leading or trailing whitespace is part of it. var v = opts.password ? input.value : input.value.trim(); if (opts.required !== false && !v) { - error.textContent = opts.requiredMessage || "This cannot be empty."; + error.textContent = opts.requiredMessage || "Este campo não pode ficar vazio."; error.hidden = false; input.focus(); return; diff --git a/settings-ui/index.html b/settings-ui/index.html index 9ac375f..591647e 100644 --- a/settings-ui/index.html +++ b/settings-ui/index.html @@ -50,8 +50,8 @@

Comportamento

diff --git a/settings-ui/lock.css b/settings-ui/lock.css index 73fa2d5..ae9460f 100644 --- a/settings-ui/lock.css +++ b/settings-ui/lock.css @@ -1,42 +1,17 @@ -:root { color-scheme: light dark; } -* { box-sizing: border-box; } -body { - margin: 0; - height: 100vh; - display: grid; - place-items: center; - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - background: #0b141a; - color: #e9edef; -} -.lock-card { - width: 320px; - display: flex; - flex-direction: column; - gap: 12px; - padding: 28px; - text-align: center; -} -.lock-glyph { font-size: 44px; } -h1 { font-size: 18px; font-weight: 600; margin: 0 0 6px; } -input[type="password"] { - padding: 10px 12px; - border-radius: 8px; - border: 1px solid #2a3942; - background: #202c33; - color: #e9edef; - font-size: 15px; -} -button { - padding: 10px 12px; - border: none; - border-radius: 8px; - background: #00a884; - color: #fff; - font-size: 15px; - cursor: pointer; -} -button.secondary { background: #2a3942; } -.error { color: #f15c6d; min-height: 1.2em; margin: 0; font-size: 13px; } -.reset { color: #8696a0; font-size: 13px; text-decoration: none; margin-top: 8px; } -.reset:hover { text-decoration: underline; } +:root{color-scheme:light dark;--bg:#fff;--card:#f1f2f2;--fg:#0a0a0a;--dim:#626262;--green:#1daa61;--line:#e9ebeb;--danger:#c13543} +@media(prefers-color-scheme:dark){:root{--bg:#161717;--card:#232626;--fg:#fafafa;--dim:#a1a5a5;--green:#21c063;--line:#3b4040;--danger:#f15c6d}} +*{box-sizing:border-box} +[hidden]{display:none!important} +body{margin:0;min-height:100vh;display:grid;place-items:center;font:14px "Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--fg)} +.lock-card{width:min(380px,100%);display:flex;flex-direction:column;gap:16px;padding:28px;text-align:center} +.lock-glyph{display:grid;place-items:center;width:64px;height:64px;margin:0 auto 4px;border-radius:50%;background:var(--card);color:var(--dim)} +.lock-glyph svg{width:32px;height:32px} +h1{font-size:22px;line-height:1.3;font-weight:600;margin:0} +.lock-subtitle{font-size:14px;line-height:1.5;color:var(--dim);margin:0 0 4px} +input[type=password]{width:100%;min-width:0;padding:13px 16px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--fg);font:inherit} +button{min-height:44px;padding:11px 16px;border:0;border-radius:24px;background:var(--green);color:#0a0a0a;font:600 14px "Segoe UI",Helvetica,Arial,sans-serif;cursor:pointer} +button:hover{filter:brightness(.94)}button:disabled{opacity:.55;cursor:wait} +button.secondary{background:var(--card);color:var(--fg)} +.error{color:var(--danger);min-height:1.4em;line-height:1.4;margin:0;font-size:13px;overflow-wrap:anywhere} +.reset{color:var(--green);font-size:13px;text-decoration:none;line-height:1.5}.reset:hover{text-decoration:underline} +button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:3px} diff --git a/settings-ui/lock.html b/settings-ui/lock.html index 4d870f7..1f868e9 100644 --- a/settings-ui/lock.html +++ b/settings-ui/lock.html @@ -1,21 +1,22 @@ - + - whatRust — Locked + whatRust — Bloqueado
- -

whatRust is locked

- - - + +

Aplicativo bloqueado

+

Digite sua senha para acessar suas contas do WhatsApp.

+ + + - Forgot password? Reset… + Esqueceu a senha? Redefinir…
diff --git a/settings-ui/lock.js b/settings-ui/lock.js index e82b4bf..a7abde2 100644 --- a/settings-ui/lock.js +++ b/settings-ui/lock.js @@ -7,7 +7,7 @@ let busy = false; async function tryUnlock() { if (busy) return; const password = pwd.value; - if (!password) { errEl.textContent = "Enter your password."; return; } + if (!password) { errEl.textContent = "Digite sua senha."; return; } busy = true; document.getElementById("unlock").disabled = true; document.getElementById("biometric").disabled = true; @@ -15,12 +15,12 @@ async function tryUnlock() { try { const ok = await invoke("unlock_password", { password }); if (!ok) { - errEl.textContent = "Wrong password."; + errEl.textContent = "Senha incorreta. Tente novamente."; pwd.value = ""; pwd.focus(); } } catch (e) { - errEl.textContent = String(e); + errEl.textContent = "Não foi possível desbloquear. Tente novamente."; } finally { busy = false; document.getElementById("unlock").disabled = false; @@ -36,9 +36,9 @@ async function tryBiometric() { errEl.textContent = ""; try { const ok = await invoke("unlock_biometric"); - if (!ok) errEl.textContent = "Biometric authentication failed — enter your password."; + if (!ok) errEl.textContent = "Não foi possível confirmar a biometria. Digite sua senha."; } catch (e) { - errEl.textContent = String(e); + errEl.textContent = "Não foi possível desbloquear. Tente novamente."; } finally { busy = false; document.getElementById("unlock").disabled = false; @@ -56,22 +56,22 @@ async function init() { document.getElementById("reset").addEventListener("click", async (e) => { e.preventDefault(); const ok = await Dlg.confirm( - "This logs out ALL accounts and removes the app lock. You will need to " + - "re-scan the WhatsApp QR code. Your chats stay on your phone.", - { title: "Reset whatRust?", okLabel: "Reset", danger: true } + "Isso desconecta todas as contas deste aplicativo e remove o bloqueio. " + + "Você precisará vincular o WhatsApp novamente pelo QR Code. Suas conversas continuam no celular.", + { title: "Redefinir o whatRust?", okLabel: "Redefinir", danger: true } ); if (!ok) return; try { await invoke("reset_app_lock"); } catch (err) { - errEl.textContent = String(err); + errEl.textContent = "Não foi possível redefinir o aplicativo. Tente novamente."; } }); try { const s = await invoke("get_lock_status"); if (s.biometric_enabled) { - bio.textContent = "Use " + (s.biometric_label || "biometrics"); + bio.textContent = "Usar " + (s.biometric_label || "biometria"); bio.hidden = false; tryBiometric(); // auto-prompt on load } diff --git a/settings-ui/main.js b/settings-ui/main.js index 4d53dd3..eb1301b 100644 --- a/settings-ui/main.js +++ b/settings-ui/main.js @@ -74,11 +74,11 @@ async function accountAction(action) { } function accountMenu(a, accounts, anchor) { - document.getElementById("account-actions")?.remove(); + const previous=document.getElementById("account-actions");if(previous){if(previous.dataset.open==="true")previous.hidePopover();previous.remove();} const menu = document.createElement("div"); menu.id="account-actions";menu.className="account-actions"; const nativePopover=typeof menu.showPopover==="function"; if(nativePopover)menu.setAttribute("popover", "auto"); - else {menu.style.position="fixed";menu.style.zIndex="9999";menu.showPopover=()=>{menu.style.display="block";menu.dataset.open="true";};menu.hidePopover=()=>{menu.style.display="none";menu.dataset.open="false";anchor.focus();};}menu.setAttribute("role", "menu");menu.setAttribute("aria-label", "Ações de " + a.name); + else {menu.style.position="fixed";menu.style.zIndex="9999";menu.showPopover=()=>{menu.style.display="block";menu.dataset.open="true";};menu.hidePopover=()=>{menu.style.display="none";menu.dataset.open="false";anchor.focus({preventScroll:true});};}menu.setAttribute("role", "menu");menu.setAttribute("aria-label", "Ações de " + a.name); const actions = [ ["Abrir conta", () => accountAction(() => invoke("open_account", {id:a.id}))], ...( /Win/.test(navigator.platform || navigator.userAgent) ? [[a.detached ? "Reintegrar à janela" : "Abrir em janela separada", () => accountAction(() => invoke(a.detached?"attach_account":"detach_account",{id:a.id}))]] : []), @@ -92,9 +92,33 @@ function accountMenu(a, accounts, anchor) { } if(accounts.length>1)actions.push(["Remover conta",async()=>{if(await Dlg.confirm("A sessão local desta conta será apagada. Para usá-la novamente, você precisará ler o QR code.",{title:`Remover "${a.name}"?`,okLabel:"Remover",danger:true}))await accountAction(()=>invoke("remove_account",{id:a.id}));}]); for(const [label,action] of actions){const button=document.createElement("button");button.type="button";button.textContent=label;button.setAttribute("role","menuitem");if(label==="Remover conta")button.className="danger";button.onclick=()=>{menu.hidePopover();menu.dataset.open="false";action();};menu.appendChild(button);} - document.body.appendChild(menu);const rect=anchor.getBoundingClientRect();menu.style.top=Math.max(12,Math.min(rect.bottom+4,innerHeight-menu.childElementCount*42-16))+"px";menu.style.right=Math.max(12,innerWidth-rect.right)+"px";menu.showPopover();menu.dataset.open="true";menu.querySelector("button").focus(); - menu.addEventListener("keydown",e=>{if(e.key==="Escape"&&!nativePopover){e.preventDefault();menu.hidePopover();return;}const buttons=[...menu.querySelectorAll("button")],at=buttons.indexOf(document.activeElement);if(e.key==="ArrowDown"||e.key==="ArrowUp"){e.preventDefault();buttons[(at+(e.key==="ArrowDown"?1:-1)+buttons.length)%buttons.length].focus();}}); - menu.addEventListener("toggle",e=>{menu.dataset.open=String(e.newState==="open");anchor.setAttribute("aria-expanded",String(e.newState==="open"));if(e.newState==="closed")anchor.focus();}); + const lifecycle=new AbortController(); + const originalHide=menu.hidePopover.bind(menu); + function afterClose(){menu.dataset.open="false";anchor.setAttribute("aria-expanded","false");lifecycle.abort();if(anchor.isConnected&&!document.querySelector('.dlg-backdrop,.account-actions[data-open="true"]'))anchor.focus({preventScroll:true});} + menu.hidePopover=()=>{originalHide();afterClose();}; + function place(){ + const r=anchor.getBoundingClientRect(),m=menu.getBoundingClientRect(),padding=12,gap=6; + const pane=document.querySelector('.prefs').getBoundingClientRect(); + if(!anchor.isConnected||r.bottompane.bottom){menu.hidePopover();return;} + const below=Math.max(0,innerHeight-padding-r.bottom-gap),above=Math.max(0,r.top-gap-padding),natural=menu.scrollHeight+2; + const down=below>=natural||below>=above; + menu.style.maxHeight=Math.max(40,Math.min(innerHeight-24,down?below:above))+"px"; + const height=menu.getBoundingClientRect().height; + const top=down?r.bottom+gap:r.top-height-gap; + menu.style.left=Math.max(padding,Math.min(r.right-m.width,innerWidth-m.width-padding))+"px"; + menu.style.top=Math.max(padding,Math.min(top,innerHeight-height-padding))+"px"; + } + menu.addEventListener("toggle",e=>{menu.dataset.open=String(e.newState==="open");anchor.setAttribute("aria-expanded",String(e.newState==="open"));if(e.newState==="closed")afterClose();}); + menu.addEventListener("keydown",e=>{ + if(e.key==="Escape"){e.preventDefault();menu.hidePopover();return;} + const buttons=[...menu.querySelectorAll("button")],at=buttons.indexOf(document.activeElement); + if(["ArrowDown","ArrowUp","Home","End"].includes(e.key)){e.preventDefault();const next=e.key==="Home"?0:e.key==="End"?buttons.length-1:(at+(e.key==="ArrowDown"?1:-1)+buttons.length)%buttons.length;buttons[next].focus();} + }); + window.addEventListener("resize",place,{signal:lifecycle.signal}); + document.addEventListener("scroll",place,{capture:true,signal:lifecycle.signal}); + if(!nativePopover)document.addEventListener("pointerdown",e=>{if(!menu.contains(e.target)&&!anchor.contains(e.target))menu.hidePopover();},{signal:lifecycle.signal}); + document.body.appendChild(menu);menu.showPopover();menu.dataset.open="true";anchor.setAttribute("aria-expanded","true");place();menu.querySelector("button").focus(); + } function renderAccounts(accounts) { const list=document.getElementById("accounts-list"),startup=document.getElementById("startup_account"); @@ -213,8 +237,8 @@ function wireLock() { const next = await Dlg.prompt("Pelo menos 4 caracteres.", { title: "Nova senha", password: true, - okLabel: "Change", - requiredMessage: "Enter a new password.", + okLabel: "Alterar", + requiredMessage: "Informe uma nova senha.", }); if (next === null) return; try { @@ -227,7 +251,7 @@ function wireLock() { const current = await Dlg.prompt("Informe sua senha atual para desativar o bloqueio.", { title: "Desativar bloqueio", password: true, - okLabel: "Disable", + okLabel: "Desativar", danger: true, requiredMessage: "Informe sua senha atual.", }); diff --git a/settings-ui/shell.html b/settings-ui/shell.html index 539bdb0..0157ef6 100644 --- a/settings-ui/shell.html +++ b/settings-ui/shell.html @@ -7,15 +7,16 @@ *{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}body{display:flex;flex-direction:column;background:var(--bg);color:var(--fg)} header{height:48px;min-height:48px;flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 12px;border-bottom:1px solid var(--line)} svg{width:24px;height:24px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:none} -.account-picker{position:relative;min-width:0;max-width:min(360px,65vw);height:40px;display:flex;align-items:center;border-radius:20px}.account-picker:hover,button:hover{background:var(--hover)} +.account-picker{position:relative;min-width:0;max-width:min(320px,65vw);height:40px;display:flex;align-items:center;border-radius:20px}.account-picker:hover,button:hover{background:var(--hover)} .account-picker>svg{position:absolute;left:8px;pointer-events:none}.account-picker>.chevron{left:auto;right:8px;width:16px;height:16px} -select,button{font:inherit;border:0;background:transparent;color:inherit;cursor:pointer}select{appearance:none;min-width:140px;max-width:100%;height:40px;padding:8px 32px 8px 40px;text-overflow:ellipsis;border-radius:20px}option{background:var(--bg);color:var(--fg)} +select,button{font:inherit;border:0;background:transparent;color:inherit;cursor:pointer}select{position:absolute;inset:0;width:100%;appearance:none;min-width:0;max-width:100%;height:40px;padding:8px 32px 8px 40px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;border-radius:20px}option{background:var(--bg);color:var(--fg)} +.selected-size{font:inherit;visibility:hidden;white-space:pre;max-width:100%;overflow:hidden;height:40px;padding:8px 32px 8px 40px;pointer-events:none} button{display:inline-flex;align-items:center;justify-content:center;gap:10px;flex:none;height:40px;min-width:40px;padding:8px 12px;border-radius:20px} button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:1px} main{flex:1;min-height:0;display:grid;place-content:center;text-align:center;padding:24px}main>svg{width:64px;height:64px;margin:0 auto 24px;color:var(--dim);stroke-width:1} h1{font-size:24px;font-weight:500;margin:0 0 12px}p{max-width:420px;color:var(--dim);line-height:1.6;margin:0 auto} @media(max-height:100px){main{display:none}}@media(max-width:480px){#settings span{display:none}#settings{width:40px;padding:8px}} -
+

Escolha uma conta para continuar

Use o seletor acima para abrir um WhatsApp ou as configurações para gerenciar suas contas.

diff --git a/settings-ui/shell.js b/settings-ui/shell.js index 08cbca5..96e295f 100644 --- a/settings-ui/shell.js +++ b/settings-ui/shell.js @@ -1,7 +1,20 @@ "use strict"; const invoke=window.__TAURI__.core.invoke; -let busy=false; -window.refresh=async()=>{if(busy)return;busy=true;try{const list=await invoke("list_accounts"),sel=document.getElementById("accounts"),previous=sel.value;sel.replaceChildren();for(const a of list){const o=document.createElement("option");o.value=a.id;o.textContent=a.name+(a.unread?" · "+a.unread:"");sel.appendChild(o);}if(list.some(a=>a.id===previous))sel.value=previous;}catch{}finally{busy=false;}}; +const select=document.getElementById("accounts"),measure=document.getElementById("account-size"); +let busy=false,activeId=null; +function fitSelectedName(){const text=select.selectedOptions[0]?.textContent||"Contas";measure.textContent=text;select.title=text;} +window.refresh=async(currentId)=>{ + if(currentId!==undefined)activeId=currentId; + if(busy)return;busy=true; + try{ + const list=await invoke("list_accounts"),previous=select.value; + select.replaceChildren(); + for(const a of list){const option=document.createElement("option");option.value=a.id;option.textContent=a.name+(a.unread?" · "+a.unread:"");select.appendChild(option);} + const chosen=activeId||previous||list.find(a=>a.is_default)?.id; + if(list.some(a=>a.id===chosen))select.value=chosen; + fitSelectedName(); + }catch{}finally{busy=false;} +}; document.getElementById("settings").onclick=()=>invoke("open_app_panel"); -document.getElementById("accounts").onchange=e=>invoke("open_account",{id:e.target.value}); +select.onchange=()=>{fitSelectedName();invoke("open_account",{id:select.value}).catch(()=>refresh());}; refresh(); diff --git a/settings-ui/style.css b/settings-ui/style.css index 26087cd..5194b59 100644 --- a/settings-ui/style.css +++ b/settings-ui/style.css @@ -79,7 +79,7 @@ select option { background: var(--bg); color: var(--fg); } .acct-subtitle { font-size: 13px; color: var(--dim); font-weight: 400; } .account-more { font-size: 26px; line-height: 1; width: 36px; height: 36px; border-radius: 50%; padding: 0; color: var(--dim); background: transparent; } .acct-unread { background: var(--green); color: var(--on-green); font-size: 12px; padding: 2px 7px; border-radius: 10px; } -.account-actions { position: fixed; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--fg); box-shadow: 0 2px 12px #0004; padding: 6px 0; width: 245px; margin: 0; max-height: calc(100vh - 24px); overflow-y: auto; } +.account-actions { position: fixed; inset: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--fg); box-shadow: 0 2px 12px #0004; padding: 6px 0; width: 245px; max-width:calc(100vw - 24px); margin: 0; max-height: calc(100vh - 24px);overflow-y:auto;overscroll-behavior:contain; } .account-actions button { display: block; width: 100%; border-radius: 0; background: transparent; color: inherit; text-align: left; font-size: 14px; font-weight: 400; padding: 12px 22px; } .account-actions button:hover, .account-actions button:focus-visible { background: var(--hover); } .account-actions .danger { color: var(--danger); } @@ -109,3 +109,11 @@ select option { background: var(--bg); color: var(--fg); } @media(max-width:640px){.settings-nav{padding:12px 4px;}.settings-nav a{padding:16px;}.back-button span{display:none;}.back-button{width:40px;padding:8px!important;}} .back-button span { font-size:14px;color:inherit; } + +.row select { max-width:min(280px,100%);text-overflow:ellipsis;white-space:nowrap;overflow:hidden; } +#startup_account { width:clamp(150px,35vw,280px); } + +.row-subtitle,.group-note,.hotkey-hint { overflow-wrap:anywhere; } + +.row-title {overflow-wrap:anywhere;} +.note {min-width:0;overflow-wrap:anywhere;flex:1;} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 75c0023..b4f167b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.4" +version = "0.6.9-local.5" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bcf6e23..5b49278 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.4" +version = "0.6.9-local.5" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index 755cc02..38de298 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -445,7 +445,7 @@ var style = document.createElement("style"); style.textContent = [ ":host{all:initial}", - ".stack{position:fixed;left:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;", + ".stack{position:fixed;left:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;max-height:calc(100vh - 32px);overflow-y:auto;", "font:14px/1.35 system-ui,-apple-system,'Segoe UI',Roboto,Ubuntu,sans-serif;max-width:min(380px,calc(100vw - 32px))}", ".toast{display:flex;align-items:center;gap:12px;padding:10px 10px 10px 14px;border-radius:10px;", "background:#233138;color:#e9edef;box-shadow:0 4px 18px rgba(0,0,0,.35)}", @@ -483,10 +483,10 @@ text.className = "text"; var title = document.createElement("div"); title.className = "title"; - title.textContent = d.ok ? "Downloaded" : "Download failed"; + title.textContent = d.ok ? "Download concluído" : "Falha no download"; var name = document.createElement("div"); name.className = "name"; - name.textContent = d.ok ? String(d.name || "") : "The file could not be downloaded."; + name.textContent = d.ok ? String(d.name || "") : "Não foi possível baixar o arquivo."; if (d.ok) name.title = String(d.name || ""); text.appendChild(title); text.appendChild(name); @@ -502,18 +502,18 @@ }; if (d.ok && typeof d.id === "number") { if (d.canOpen) { - toast.appendChild(dl.button("Open", "", function () { + toast.appendChild(dl.button("Abrir", "", function () { invoke("open_download", { id: d.id }); close(); })); } - toast.appendChild(dl.button("Show in folder", "", function () { + toast.appendChild(dl.button("Mostrar na pasta", "", function () { invoke("reveal_download", { id: d.id }); close(); })); } var x = dl.button("\u00d7", "close", close); - x.setAttribute("aria-label", "Dismiss"); + x.setAttribute("aria-label", "Fechar aviso"); toast.appendChild(x); // Hovering keeps it up; leaving restarts the countdown. toast.addEventListener("mouseenter", function () { if (timer) clearTimeout(timer); }); @@ -570,7 +570,7 @@ try { invoke("dlog", { msg: String(m).slice(0, 280) }); } catch (e) {} }; drop.attachFailed = function () { - nativeNotify("whatRust", "Could not attach dropped files. Use the attach (+) button and try again."); + nativeNotify("whatRust", "Não foi possível anexar os arquivos. Use o botão de anexo (+) e tente novamente."); }; // Decode one base64 stanza to bytes. Modern WebKit (18.4+) has // Uint8Array.fromBase64 — ~10x faster than atob + a char loop on multi-MB diff --git a/src-tauri/resources/bridge.test.mjs b/src-tauri/resources/bridge.test.mjs index 8fc959b..45851e3 100644 --- a/src-tauri/resources/bridge.test.mjs +++ b/src-tauri/resources/bridge.test.mjs @@ -398,7 +398,7 @@ async function testDecodeFailureNotifies() { await sleep(30); assert(mediaInput.batches.length === 0, "failed decode is never injected"); assert( - invocations.some((x) => x.cmd === "notify" && /Could not attach/.test(x.args.body)), + invocations.some((x) => x.cmd === "notify" && /Não foi possível anexar/.test(x.args.body)), "native failure notification is sent" ); } @@ -537,10 +537,10 @@ async function testDownloadToastOffersOpenAndShowInFolder() { const [toast] = toastsIn(document); assert(!!toast, "one toast in the page"); const texts = toast.all().map((e) => e.textContent); - assert(texts.includes("Downloaded") && texts.includes(name), "title and file name, as plain text"); + assert(texts.includes("Download concluído") && texts.includes(name), "title and file name, as plain text"); const labels = buttonsOf(toast).map((b) => b.textContent); - assert(labels.includes("Open") && labels.includes("Show in folder"), `buttons: ${labels}`); - buttonsOf(toast).find((b) => b.textContent === "Open").click(); + assert(labels.includes("Abrir") && labels.includes("Mostrar na pasta"), `buttons: ${labels}`); + buttonsOf(toast).find((b) => b.textContent === "Abrir").click(); const call = invocations.find((x) => x.cmd === "open_download"); assert(call && call.args.id === 4 && Object.keys(call.args).length === 1, "Open sends just the id"); assert(toastsIn(document).length === 0, "the toast closes after a choice"); @@ -552,14 +552,14 @@ async function testRiskyDownloadsAndFailures() { w.__whatrustDownloadDone({ ok: true, id: 9, name: "setup.exe", canOpen: false }); const [toast] = toastsIn(document); const labels = buttonsOf(toast).map((b) => b.textContent); - assert(!labels.includes("Open"), "no Open button for a program"); - buttonsOf(toast).find((b) => b.textContent === "Show in folder").click(); + assert(!labels.includes("Abrir"), "no Open button for a program"); + buttonsOf(toast).find((b) => b.textContent === "Mostrar na pasta").click(); assert(invocations.some((x) => x.cmd === "reveal_download" && x.args.id === 9), "Show in folder sends the id"); w.__whatrustDownloadDone({ ok: false }); const [failed] = toastsIn(document); assert(failed && /failed/.test(failed.className), "failure toast"); - assert(failed.all().some((e) => e.textContent === "Download failed"), "failure says so"); - assert(buttonsOf(failed).every((b) => b.textContent !== "Open"), "nothing to open after a failure"); + assert(failed.all().some((e) => e.textContent === "Falha no download"), "failure says so"); + assert(buttonsOf(failed).every((b) => b.textContent !== "Abrir"), "nothing to open after a failure"); for (let i = 0; i < 5; i++) w.__whatrustDownloadDone({ ok: true, id: 20 + i, name: i + ".jpg", canOpen: true }); assert(toastsIn(document).length === 3, "at most three toasts stack up"); } diff --git a/src-tauri/resources/integration.js b/src-tauri/resources/integration.js index 229c422..b40cd25 100644 --- a/src-tauri/resources/integration.js +++ b/src-tauri/resources/integration.js @@ -6,7 +6,8 @@ function call(cmd, args) { var t=window.__TAURI__;if(!t||!t.core)return Promise.resolve(null);return t.core.invoke(cmd, args).catch(function () { return null; }); } function button(text, fn) { var b=document.createElement("button");b.type="button";b.textContent=text;b.addEventListener("click",function(e){e.stopPropagation();fn();});return b; } function openPanel() { if(menu.open)menu.close();call("open_app_panel"); } - function positionMenu(){var r=accountButton.getBoundingClientRect();menu.style.top=Math.min(r.bottom+8,innerHeight-menu.offsetHeight-12)+"px";menu.style.left=Math.max(12,Math.min(r.left,innerWidth-menu.offsetWidth-12))+"px";} + function positionMenu(){var r=accountButton.getBoundingClientRect(),below=Math.max(0,innerHeight-12-r.bottom-8),above=Math.max(0,r.top-20),natural=menu.scrollHeight+2,down=below>=natural||below>=above;menu.style.maxHeight=Math.max(40,Math.min(innerHeight-24,down?below:above))+"px";var height=menu.offsetHeight;menu.style.top=Math.max(12,Math.min(down?r.bottom+8:r.top-height-8,innerHeight-height-12))+"px";menu.style.left=Math.max(12,Math.min(r.left,innerWidth-menu.offsetWidth-12))+"px";} + function draw() { if(!accountButton)return; var focusKey=root.activeElement && root.activeElement.dataset.item; @@ -23,6 +24,7 @@ var manage=button("Gerenciar contas e configurações",openPanel);manage.dataset.item="manage";menu.appendChild(manage); var close=button("Fechar",function(){menu.close();});close.dataset.item="close";menu.appendChild(close); status=document.createElement("p");status.setAttribute("role","status");status.textContent=own?"Conta atual: "+own.name:"Escolha uma conta";menu.appendChild(status); + if(menu.open)positionMenu(); if(keepFocus){var restore=Array.from(menu.querySelectorAll("button")).find(function(b){return b.dataset.item===focusKey;})||menu.querySelector("button");if(restore)restore.focus();} } window.__whatrustState=function(next){if(!next)return;state=next;window.__whatrustPreferNative=!!next.native_notifications;draw();}; @@ -43,8 +45,8 @@ :host([data-compact="true"]) .badge{position:absolute;inset-inline-end:0;top:0} [hidden]{display:none!important}svg{width:24px;height:24px;flex:none;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} dialog{color-scheme:light dark;color:light-dark(#0a0a0a,#fafafa);background:light-dark(#fff,#232626);border:1px solid #8882;border-radius:8px;box-shadow:0 2px 12px #0005;width:320px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);overflow:auto;padding:12px 8px;inset:auto;margin:0} - dialog::backdrop{background:transparent}dialog button{display:block;width:100%;text-align:left;margin:5px 0;padding:10px 12px;border-radius:8px} - h2{font-size:16px;font-weight:600;margin:4px 12px 10px}p{font-size:12px;opacity:.65;margin:12px 12px 4px}dialog [aria-current=true]{background:#8696a01f;font-weight:600} + dialog::backdrop{background:transparent}dialog button{overflow-wrap:anywhere;display:block;width:100%;text-align:left;margin:5px 0;padding:10px 12px;border-radius:8px} + h2{font-size:16px;font-weight:600;margin:4px 12px 10px}p{overflow-wrap:anywhere;font-size:12px;opacity:.65;margin:12px 12px 4px}dialog [aria-current=true]{background:#8696a01f;font-weight:600} `; var bar=document.createElement("span");bar.className="bar"; accountButton=button("Contas",function(){draw();if(!menu.open)menu.showModal();positionMenu();accountButton.setAttribute("aria-expanded","true");var b=menu.querySelector("button");if(b)b.focus();});accountButton.className="account";accountButton.textContent=""; @@ -111,6 +113,7 @@ }else if(host.isConnected)host.remove(); report(!!target,messageRows().length>0); scanMessages(); + if(menu.open)positionMenu(); // Consume our own insertion/removal records after processing this layout. // Otherwise an unsuitable header causes append/remove retries every 200ms. if(observer)observer.takeRecords(); diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index a043d51..a967dd1 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -87,10 +87,10 @@ pub fn add(f: &mut AccountsFile, name: &str) -> Account { /// errors if the id is unknown. Returns the removed account on success. pub fn remove(f: &mut AccountsFile, id: &str) -> Result { if f.accounts.len() <= 1 { - return Err("cannot remove the last account".into()); + return Err("Não é possível remover a última conta.".into()); } let Some(pos) = f.accounts.iter().position(|a| a.id == id) else { - return Err(format!("unknown account: {id}")); + return Err(format!("Conta não encontrada: {id}")); }; let removed = f.accounts.remove(pos); if f.startup_account.as_deref() == Some(id) { @@ -109,7 +109,7 @@ pub fn startup_account(f: &AccountsFile) -> Option<&Account> { pub fn set_startup_account(f: &mut AccountsFile, id: &str) -> Result<(), String> { if !f.accounts.iter().any(|a| a.id == id) { - return Err(format!("unknown account: {id}")); + return Err(format!("Conta não encontrada: {id}")); } f.startup_account = Some(id.to_owned()); Ok(()) @@ -118,7 +118,7 @@ pub fn set_startup_account(f: &mut AccountsFile, id: &str) -> Result<(), String> /// Rename an account by id. Errors if the id is unknown. pub fn rename(f: &mut AccountsFile, id: &str, name: &str) -> Result<(), String> { let Some(acct) = f.accounts.iter_mut().find(|a| a.id == id) else { - return Err(format!("unknown account: {id}")); + return Err(format!("Conta não encontrada: {id}")); }; acct.name = name.to_string(); Ok(()) @@ -408,7 +408,7 @@ mod tests { fn cannot_remove_last_account() { let mut f = AccountsFile::default(); let err = remove(&mut f, "default").unwrap_err(); - assert!(err.contains("last account")); + assert!(err.contains("última conta")); assert_eq!(f.accounts.len(), 1); } @@ -417,7 +417,7 @@ mod tests { let mut f = AccountsFile::default(); add(&mut f, "Work"); let err = remove(&mut f, "nope").unwrap_err(); - assert!(err.contains("unknown account")); + assert!(err.contains("Conta não encontrada")); assert_eq!(f.accounts.len(), 2); } @@ -432,7 +432,7 @@ mod tests { fn rename_unknown_id_is_err() { let mut f = AccountsFile::default(); let err = rename(&mut f, "nope", "x").unwrap_err(); - assert!(err.contains("unknown account")); + assert!(err.contains("Conta não encontrada")); } #[test] diff --git a/src-tauri/src/applock.rs b/src-tauri/src/applock.rs index 714328f..54a477c 100644 --- a/src-tauri/src/applock.rs +++ b/src-tauri/src/applock.rs @@ -37,6 +37,15 @@ impl AppLockConfig { pub fn is_active(&self) -> bool { self.enabled && self.password_phc.is_some() } + pub fn should_lock_on_launch(&self) -> bool { + self.is_active() && self.lock_on_launch + } + pub fn should_lock_on_hide(&self) -> bool { + self.is_active() && self.lock_on_hide + } + pub fn should_lock_when_idle(&self, seconds: u64) -> bool { + self.is_active() && self.idle_secs > 0 && seconds >= u64::from(self.idle_secs) + } } fn config_path(app: &AppHandle) -> tauri::Result { @@ -99,28 +108,48 @@ fn reset_targets(config_dir: &Path, data_dir: &Path) -> Vec { ] } -/// Factory reset for the forgot-password flow: log out ALL accounts and clear the lock. -pub fn reset_all(app: &AppHandle) { - // Belt-and-suspenders: explicitly drop each per-account profile first. - let f = crate::accounts::load(app); - for a in &f.accounts { - crate::accounts::delete_profile(app, &a.id); - } - // Then remove the config files + the whole data root (default store + profiles). - let config_dir = app.path().app_config_dir().ok(); - let data_dir = app.path().app_data_dir().ok(); - if let (Some(c), Some(d)) = (config_dir, data_dir) { - for p in reset_targets(&c, &d) { - if p.is_dir() { - let _ = std::fs::remove_dir_all(&p); - } else { - let _ = std::fs::remove_file(&p); - } +/// Request cleanup on the next launch, before any profile is opened. +/// The sibling marker survives even when config_dir == data_dir. +fn reset_marker(config_dir: &Path) -> PathBuf { + let mut marker = config_dir.as_os_str().to_os_string(); + marker.push(".reset-pending"); + PathBuf::from(marker) +} + +pub fn request_reset(app: &AppHandle) -> Result<(), String> { + let config = app.path().app_config_dir().map_err(|e| e.to_string())?; + let marker = reset_marker(&config); + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(marker, b"reset-sessions-v1").map_err(|e| e.to_string()) +} + +fn complete_reset_paths(config: &Path, data: &Path) -> std::io::Result { + let marker = reset_marker(config); + if !marker.exists() { + return Ok(false); + } + // Remove profiles first. Keep the marker and refuse startup on any failure. + // Never treat a partially removed (or locked) WebView store as logged out. + if data.exists() { + std::fs::remove_dir_all(data)?; + } + for p in reset_targets(config, data).into_iter().take(2) { + match std::fs::remove_file(p) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), } } - // NOTE (macOS, hardware-pending): the default account uses the system WKWebsiteDataStore - // and additional accounts use data_store_identifier — both system-managed, NOT plain files - // under app_data_dir, so file deletion alone may not fully log out on macOS. Verify on a Mac. + std::fs::remove_file(marker)?; + Ok(true) +} + +pub fn complete_pending_reset(app: &AppHandle) -> Result<(), String> { + let config = app.path().app_config_dir().map_err(|e| e.to_string())?; + let data = app.path().app_data_dir().map_err(|e| e.to_string())?; + complete_reset_paths(&config,&data).map(|_|()).map_err(|_|"Não foi possível concluir a redefinição. Feche outras instâncias do whatRust e tente novamente.".into()) } #[cfg(test)] @@ -204,4 +233,80 @@ mod tests { // The data root must be a target — this is what logs the DEFAULT account out. assert!(t.contains(&data.to_path_buf())); } + #[test] + fn deferred_reset_clears_profiles_before_removing_marker() { + let dir = std::env::temp_dir().join(format!("whatrust-reset-{}", accounts_test_id())); + let config = dir.join("config"); + let data = dir.join("data"); + std::fs::create_dir_all(&config).unwrap(); + std::fs::create_dir_all(data.join("profiles")).unwrap(); + std::fs::write(config.join("app-lock.json"), b"hash fixture").unwrap(); + std::fs::write(config.join("accounts.json"), b"accounts fixture").unwrap(); + std::fs::write(reset_marker(&config), b"reset-sessions-v1").unwrap(); + assert!(complete_reset_paths(&config, &data).unwrap()); + assert!(!data.exists()); + assert!(!config.join("app-lock.json").exists()); + assert!(!reset_marker(&config).exists()); + assert!(!complete_reset_paths(&config, &data).unwrap()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn failed_reset_keeps_marker_and_password() { + let dir = std::env::temp_dir().join(format!("whatrust-reset-{}", accounts_test_id())); + let config = dir.join("config"); + let data = dir.join("data"); + std::fs::create_dir_all(&config).unwrap(); + std::fs::write(&data, b"a file cannot be removed as a directory").unwrap(); + std::fs::write(config.join("app-lock.json"), b"hash fixture").unwrap(); + std::fs::write(reset_marker(&config), b"reset-sessions-v1").unwrap(); + assert!(complete_reset_paths(&config, &data).is_err()); + assert!(reset_marker(&config).exists()); + assert!(config.join("app-lock.json").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + fn accounts_test_id() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + } + #[test] + fn automatic_lock_triggers_require_an_enabled_password() { + let mut c = AppLockConfig { + lock_on_hide: true, + idle_secs: 60, + ..Default::default() + }; + assert!(!c.should_lock_on_launch()); + assert!(!c.should_lock_on_hide()); + assert!(!c.should_lock_when_idle(500)); + c.enabled = true; + c.password_phc = Some("hash fixture".into()); + assert!(c.should_lock_on_launch()); + assert!(c.should_lock_on_hide()); + assert!(!c.should_lock_when_idle(59)); + assert!(c.should_lock_when_idle(60)); + c.lock_on_launch = false; + c.lock_on_hide = false; + c.idle_secs = 0; + assert!(!c.should_lock_on_launch()); + assert!(!c.should_lock_on_hide()); + assert!(!c.should_lock_when_idle(500)); + } + #[test] + fn reset_marker_survives_shared_config_and_data_directory() { + let config = std::env::temp_dir().join(format!("whatrust-reset-{}", accounts_test_id())); + std::fs::create_dir_all(config.join("profiles")).unwrap(); + std::fs::write(config.join("app-lock.json"), b"hash fixture").unwrap(); + let marker = reset_marker(&config); + assert!(!marker.starts_with(&config)); + assert_eq!( + reset_marker(Path::new("com.karem.whatrust")), + Path::new("com.karem.whatrust.reset-pending") + ); + std::fs::write(&marker, b"reset-sessions-v1").unwrap(); + assert!(complete_reset_paths(&config, &config).unwrap()); + assert!(!config.exists()); + assert!(!marker.exists()); + } } diff --git a/src-tauri/src/biometric/macos.rs b/src-tauri/src/biometric/macos.rs index 6a7dba7..0ef6ed9 100644 --- a/src-tauri/src/biometric/macos.rs +++ b/src-tauri/src/biometric/macos.rs @@ -18,7 +18,7 @@ pub fn authenticate(_app: &tauri::AppHandle, reason: &str) -> Result Result &'static str { } #[cfg(target_os = "linux")] { - "system authentication" + "autenticação do sistema" } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a75ba93..ef98dc6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,6 @@ -use crate::accounts::{self, ActiveAccount, UnreadMap}; +#[cfg(not(windows))] +use crate::accounts::ActiveAccount; +use crate::accounts::{self, UnreadMap}; use crate::applock::{self, AppLockConfig}; use crate::lock; use crate::settings::Settings; @@ -65,7 +67,7 @@ pub fn open_download(app: tauri::AppHandle, id: u64) -> Result<(), String> { let path = app .state::() .path(id) - .ok_or("unknown download")?; + .ok_or("Download não encontrado")?; if crate::downloads::is_risky(&path) { crate::dlog::log("download: open refused for a program, revealing instead"); crate::opener::reveal_file(&app, path); @@ -82,7 +84,7 @@ pub fn reveal_download(app: tauri::AppHandle, id: u64) -> Result<(), String> { let path = app .state::() .path(id) - .ok_or("unknown download")?; + .ok_or("Download não encontrado")?; crate::opener::reveal_file(&app, path); Ok(()) } @@ -243,12 +245,13 @@ pub async fn switch_account( let _guard = accounts::lock_mutations(); let f = accounts::load(&app); let Some(acct) = f.accounts.iter().find(|a| a.id == id) else { - return Err("unknown account".into()); + return Err("Conta não encontrada".into()); }; if app.get_webview(&accounts::window_label(&acct.id)).is_none() { crate::window::open_account_window(&app, acct, false).map_err(|e| e.to_string())?; } crate::window::show_account(&app, &accounts::window_label(&acct.id)); + #[cfg(not(windows))] if let Some(active) = app.try_state::() { *active.lock().unwrap() = accounts::window_label(&acct.id); } @@ -298,7 +301,7 @@ pub async fn add_account( let name = name.trim(); if name.is_empty() { - return Err("account name cannot be empty".into()); + return Err("Informe um nome para a conta.".into()); } let _guard = accounts::lock_mutations(); @@ -311,7 +314,7 @@ pub async fn add_account( // number stays consumed, so a retry gets a fresh profile id. f.accounts.retain(|a| a.id != acct.id); accounts::save(&app, &f) - .map_err(|rollback| format!("{e}; and could not roll back the account: {rollback}"))?; + .map_err(|rollback| format!("{e}; e não foi possível restaurar a conta: {rollback}"))?; return Err(e.to_string()); } crate::tray::rebuild_menu(&app); @@ -386,7 +389,7 @@ pub async fn rename_account( lock::require_unlocked(&app)?; let name = name.trim(); if name.is_empty() { - return Err("account name cannot be empty".into()); + return Err("Informe um nome para a conta.".into()); } let _guard = accounts::lock_mutations(); @@ -422,7 +425,7 @@ pub async fn open_account( let _guard = accounts::lock_mutations(); let f = accounts::load(&app); let Some(acct) = f.accounts.iter().find(|a| a.id == id) else { - return Err(format!("unknown account: {id}")); + return Err(format!("Conta não encontrada: {id}")); }; // Open the window if it was closed, then show + focus it. if app.get_webview(&accounts::window_label(&acct.id)).is_none() { @@ -430,6 +433,7 @@ pub async fn open_account( } crate::window::show_account(&app, &accounts::window_label(&acct.id)); // Track as the active account. + #[cfg(not(windows))] if let Some(active) = app.try_state::() { *active.lock().unwrap() = accounts::window_label(&acct.id); } @@ -492,13 +496,13 @@ pub fn set_app_lock_password( lock::require_unlocked(&app)?; let mut c = applock::load(&app); if c.is_active() { - return Err("app lock is already enabled".into()); + return Err("O bloqueio já está ativado.".into()); } if new != confirm { - return Err("passwords do not match".into()); + return Err("As senhas não coincidem.".into()); } if new.chars().count() < 4 { - return Err("password must be at least 4 characters".into()); + return Err("A senha deve ter pelo menos 4 caracteres.".into()); } c.password_phc = Some(applock::hash_password(&new)?); c.enabled = true; @@ -521,15 +525,18 @@ pub fn change_app_lock_password( } lock::require_unlocked(&app)?; let mut c = applock::load(&app); - let phc = c.password_phc.clone().ok_or("app lock is not enabled")?; + let phc = c + .password_phc + .clone() + .ok_or("O bloqueio não está ativado.")?; if !applock::verify_password(¤t, &phc) { - return Err("current password is incorrect".into()); + return Err("A senha atual está incorreta.".into()); } if new != confirm { - return Err("passwords do not match".into()); + return Err("As senhas não coincidem.".into()); } if new.chars().count() < 4 { - return Err("password must be at least 4 characters".into()); + return Err("A senha deve ter pelo menos 4 caracteres.".into()); } c.password_phc = Some(applock::hash_password(&new)?); applock::save(&app, &c).map_err(|e| e.to_string()) @@ -546,9 +553,12 @@ pub fn disable_app_lock( } lock::require_unlocked(&app)?; let mut c = applock::load(&app); - let phc = c.password_phc.clone().ok_or("app lock is not enabled")?; + let phc = c + .password_phc + .clone() + .ok_or("O bloqueio não está ativado.")?; if !applock::verify_password(¤t, &phc) { - return Err("current password is incorrect".into()); + return Err("A senha atual está incorreta.".into()); } c = AppLockConfig::default(); // fully reset: disabled, no hash, no biometric, default triggers applock::save(&app, &c).map_err(|e| e.to_string())?; @@ -591,16 +601,16 @@ pub fn set_biometric_enabled( let mut c = applock::load(&app); if enabled { if !c.is_active() { - return Err("set an app-lock password first".into()); + return Err("Defina uma senha de bloqueio primeiro.".into()); } if !matches!( crate::biometric::availability(), crate::biometric::Availability::Available ) { - return Err("biometric authentication is not available on this device".into()); + return Err("A autenticação biométrica não está disponível neste dispositivo.".into()); } - if !crate::biometric::authenticate(&app, "Enable biometric unlock for whatRust")? { - return Err("biometric test did not succeed".into()); + if !crate::biometric::authenticate(&app, "Ativar desbloqueio por biometria no whatRust")? { + return Err("Não foi possível confirmar a biometria.".into()); } } c.biometric_enabled = enabled; @@ -614,7 +624,7 @@ pub async fn lock_app(window: tauri::Webview, app: tauri::AppHandle) -> Result<( return Err("forbidden".into()); } if !applock::load(&app).is_active() { - return Err("app lock is not enabled".into()); + return Err("O bloqueio não está ativado.".into()); } lock::lock_now(&app); Ok(()) @@ -651,9 +661,9 @@ pub fn unlock_biometric(window: tauri::Webview, app: tauri::AppHandle) -> Result return Err("forbidden".into()); } if !applock::load(&app).biometric_enabled { - return Err("biometric unlock is not enabled".into()); + return Err("O desbloqueio por biometria não está ativado.".into()); } - if crate::biometric::authenticate(&app, "Unlock whatRust")? { + if crate::biometric::authenticate(&app, "Desbloquear o whatRust")? { lock::unlock(&app); Ok(true) } else { @@ -668,7 +678,7 @@ pub fn reset_app_lock(window: tauri::Webview, app: tauri::AppHandle) -> Result<( if !lock::is_lock_window(&window) { return Err("forbidden".into()); } - crate::applock::reset_all(&app); + crate::applock::request_reset(&app)?; app.restart(); } diff --git a/src-tauri/src/integration.rs b/src-tauri/src/integration.rs index 90ba9c0..2f73bbe 100644 --- a/src-tauri/src/integration.rs +++ b/src-tauri/src/integration.rs @@ -105,7 +105,14 @@ pub fn broadcast(app: &AppHandle) { } else if label == "settings" { let _ = v.eval("typeof loadAccounts === 'function' && !accountsBusy && loadAccounts()"); } else if label.starts_with("shell-") { - let _ = v.eval("window.refresh && window.refresh()"); + #[cfg(windows)] + let current = crate::sessions::selected(app, v.window().label()) + .and_then(|label| accounts::id_from_label(&label).map(str::to_owned)); + #[cfg(not(windows))] + let current: Option = None; + if let Ok(json) = serde_json::to_string(¤t) { + let _ = v.eval(format!("window.refresh && window.refresh({json})")); + } } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1858461..922a5fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -187,6 +187,9 @@ pub fn run() { // before any account window can fire a notification. See aumid.rs. aumid::register(handle); + // A reset only runs while no WebViews hold the session stores open. + // Failure aborts startup before reading accounts or creating a view. + applock::complete_pending_reset(handle).map_err(std::io::Error::other)?; let s = settings::load(handle); let args: Vec = std::env::args().collect(); let start_hidden = s.start_minimized || args.iter().any(|a| a == "--minimized"); @@ -199,6 +202,7 @@ pub fn run() { accounts::add(&mut f, "Conta de teste B"); } accounts::save(handle, &f)?; + applock::save(handle, &applock::AppLockConfig::default())?; } // Backfill a persisted store_uuid for any non-default account missing one @@ -216,7 +220,7 @@ pub fn run() { // App lock: decide the initial state and whether to start hidden. let lock_cfg = applock::load(handle); - let lock_on_launch = lock_cfg.is_active() && lock_cfg.lock_on_launch; + let lock_on_launch = lock_cfg.should_lock_on_launch(); handle.manage(lock::LockState::new(!lock_on_launch)); let open_hidden = start_hidden || lock_on_launch; @@ -278,7 +282,7 @@ pub fn run() { continue; } let idle_ok = user_idle::UserIdle::get_time() - .map(|t| t.as_seconds() >= c.idle_secs as u64) + .map(|t| c.should_lock_when_idle(t.as_seconds())) .unwrap_or(false); if idle_ok { let h = idle_handle.clone(); diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index 2b4c457..fb4605f 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -56,6 +56,11 @@ pub fn require_unlocked(app: &AppHandle) -> Result<(), String> { /// Lock the app: mark locked, hide every user-facing window (recording which were /// visible), and show the lock screen. pub fn lock_now(app: &AppHandle) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || lock_on_main(&handle)); +} + +fn lock_on_main(app: &AppHandle) { let state = match app.try_state::() { Some(s) => s, None => return, // not yet set up — bail @@ -81,6 +86,16 @@ pub fn lock_now(app: &AppHandle) { /// Unlock the app: mark unlocked, destroy the lock window, and restore the windows /// that were visible when we locked (or fall back to the active account). pub fn unlock(app: &AppHandle) { + // Password IPC may execute inside this very WebView's callback. Always + // return from that callback before destroying its WebView2 controller. + let handle = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let ui = handle.clone(); + let _ = handle.run_on_main_thread(move || unlock_on_main(&ui)); + }); +} + +fn unlock_on_main(app: &AppHandle) { let state = match app.try_state::() { Some(s) => s, None => return, // not yet set up — bail @@ -124,7 +139,7 @@ fn show_lock_window_inner(app: &AppHandle) { return; } let built = WebviewWindowBuilder::new(app, "lock", WebviewUrl::App("lock.html".into())) - .title("whatRust — Locked") + .title("whatRust — Bloqueado") .inner_size(420.0, 540.0) .resizable(false) .decorations(false) diff --git a/src-tauri/src/opener.rs b/src-tauri/src/opener.rs index f9ac29c..fda38af 100644 --- a/src-tauri/src/opener.rs +++ b/src-tauri/src/opener.rs @@ -42,8 +42,8 @@ pub fn open_url(app: &tauri::AppHandle, url: tauri::Url) { crate::dlog::log(&format!("opener: {e}")); crate::notify::show( &app, - "Couldn't open the link", - "No browser could be started. Copy the link and open it yourself.", + "Não foi possível abrir o link", + "Não foi possível iniciar o navegador. Copie o link e abra-o no navegador.", ); } }); @@ -75,8 +75,8 @@ fn report(app: &tauri::AppHandle, what: &str, result: Result<(), String>) { crate::dlog::log(&format!("opener: {what} file FAILED: {e}")); crate::notify::show( app, - "Couldn't open the file", - "Open it from your Downloads folder instead.", + "Não foi possível abrir o arquivo", + "Abra o arquivo pela pasta em que ele foi salvo.", ); } } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 9700ddd..a1dd8f2 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -56,7 +56,8 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { tauri::WindowEvent::CloseRequested { api, .. } => { if crate::settings::load(&a).close_to_tray { api.prevent_close(); - if crate::applock::load(&a).lock_on_hide { + let c = crate::applock::load(&a); + if c.should_lock_on_hide() { crate::lock::lock_now(&a); } if let Some(w) = a.get_window("main") { @@ -137,6 +138,19 @@ pub fn open_account(app: &AppHandle, account: &Account, hidden: bool) -> tauri:: } pub fn activate(app: &AppHandle, label: &str) { + // Commit identity, visibility, title and published state in one UI task. + // Calls from notifications, tray and IPC must never interleave these steps. + let handle = app.clone(); + let label = label.to_string(); + if app + .run_on_main_thread(move || activate_on_main(&handle, &label)) + .is_err() + { + crate::dlog::log("account activation: UI dispatch failed"); + } +} + +fn activate_on_main(app: &AppHandle, label: &str) { if !crate::lock::is_unlocked(app) { *state(app).pending.lock().unwrap() = Some(label.to_string()); crate::lock::show_lock_window(app); @@ -162,7 +176,6 @@ pub fn activate(app: &AppHandle, label: &str) { } *app.state::().lock().unwrap() = label.to_string(); sync_menu(app, parent.label()); - layout(app, parent.label()); let name = accounts::id_from_label(label) .and_then(|id| { accounts::load(app) @@ -173,9 +186,11 @@ pub fn activate(app: &AppHandle, label: &str) { .map(|a| a.name) .unwrap_or_else(|| "WhatsApp".into()); let _ = parent.set_title(&format!("whatRust — {name}")); - let _ = view.show(); let _ = parent.show(); let _ = parent.unminimize(); + let _ = view.show(); + // WebView2 must be visible before its final bounds/viewport are applied. + layout(app, parent.label()); let _ = parent.set_focus(); let _ = view.set_focus(); crate::integration::broadcast(app); @@ -449,6 +464,12 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { } *state(app).panel_parent.lock().unwrap() = Some(owner.label().into()); layout(app, owner.label()); + // A worker can finish constructing a panel after a lock request. + if !crate::lock::is_unlocked(app) { + let _ = owner.hide(); + crate::lock::show_lock_window(app); + return Ok(()); + } let _ = panel.show(); let _ = owner.show(); let _ = owner.unminimize(); @@ -477,7 +498,8 @@ pub fn toggle(app: &AppHandle) { if let Some(v) = app.get_webview(&label) { let w = v.window(); if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) { - if crate::applock::load(app).lock_on_hide { + let c = crate::applock::load(app); + if c.should_lock_on_hide() { crate::lock::lock_now(app); } else { let _ = w.hide(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 139b5f5..b667039 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -158,7 +158,9 @@ pub fn apply(app: &AppHandle, s: &Settings) -> Option { let flatpak_autostart_handled = flatpak_id() .map(|id| { if let Err(e) = apply_flatpak_autostart(&id, s.autostart) { - warnings.push(format!("autostart could not be updated: {e}")); + warnings.push(format!( + "Não foi possível atualizar a inicialização automática: {e}" + )); } true }) @@ -179,7 +181,9 @@ pub fn apply(app: &AppHandle, s: &Settings) -> Option { .and_then(|on| if on { autostart.disable() } else { Ok(()) }) }; if let Err(e) = result { - warnings.push(format!("autostart could not be updated: {e}")); + warnings.push(format!( + "Não foi possível atualizar a inicialização automática: {e}" + )); } } @@ -188,7 +192,9 @@ pub fn apply(app: &AppHandle, s: &Settings) -> Option { let _ = gs.unregister_all(); if s.hotkey_enabled && !s.hotkey.trim().is_empty() { if let Err(e) = gs.register(s.hotkey.as_str()) { - warnings.push(format!("shortcut not registered (it may be in use): {e}")); + warnings.push(format!( + "Não foi possível registrar o atalho (ele pode estar em uso): {e}" + )); } } if warnings.is_empty() { diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index 0046f07..f01a4f9 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -12,6 +12,50 @@ fn eval(app: &AppHandle, label: &str, script: &str) -> Result { rx.recv_timeout(Duration::from_secs(8)) .map_err(|e| e.to_string()) } +fn visible(app: &AppHandle, label: &str) -> Result { + let v = app.get_webview(label).ok_or("missing view")?; + let (tx, rx) = std::sync::mpsc::channel(); + v.with_webview(move |platform| { + let mut value = windows::core::BOOL::default(); + let result = unsafe { platform.controller().IsVisible(&mut value) } + .map(|_| value.as_bool()) + .map_err(|e| e.to_string()); + let _ = tx.send(result); + }) + .map_err(|e| e.to_string())?; + rx.recv_timeout(Duration::from_secs(4)) + .map_err(|e| e.to_string())? +} +fn wait_until(mut condition: impl FnMut() -> bool) -> bool { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if condition() { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(40)); + } +} +fn fills_client(app: &AppHandle, label: &str) -> bool { + let handle = app.clone(); + let label = label.to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + let _ = app.run_on_main_thread(move || { + let result = (|| { + let w = handle.get_window("main")?; + let scale = w.scale_factor().ok()?; + let client = w.inner_size().ok()?; + let bounds = handle.get_webview(&label)?.bounds().ok()?; + let origin = bounds.position.to_physical::(scale); + let size = bounds.size.to_physical::(scale); + Some(size.width == client.width && origin.y as u32 + size.height == client.height) + })(); + let _ = tx.send(result == Some(true)); + }); + rx.recv_timeout(Duration::from_secs(3)).unwrap_or(false) +} pub fn start(app: &AppHandle) { let app = app.clone(); std::thread::spawn(move || { @@ -22,6 +66,7 @@ pub fn start(app: &AppHandle) { let a = &f.accounts[0]; let b = &f.accounts[1]; let label = crate::accounts::window_label(&a.id); + crate::dlog::log("smoke: two_live_profiles"); result.push(json!({"test":"two_live_profiles","pass":app.get_webview(&label).is_some()&&app.get_webview(&crate::accounts::window_label(&b.id)).is_some()})); // Reproduce the installed-app regression: the page reports no // anchor while loading/QR login, then mounts/unmounts its header. @@ -35,6 +80,7 @@ pub fn start(app: &AppHandle) { } std::thread::sleep(Duration::from_millis(300)); } + crate::dlog::log("smoke: real_whatsapp_login_screen_without_linked_account"); result.push(json!({"test":"real_whatsapp_login_screen_without_linked_account","pass":login_ready})); for (step, mounted) in [false, true, false, true, false].into_iter().enumerate() { eval(&app, &label, &format!("window.__menuStep=-1;window.__TAURI__.core.invoke('report_integration',{{mounted:{mounted},detector:false}}).then(()=>window.__menuStep={step})"))?; @@ -61,19 +107,26 @@ pub fn start(app: &AppHandle) { return Err(format!("webview bounds mismatch step={step} mounted={mounted} scale={scale} client={client:?} origin={origin:?} size={size:?}")); } } + crate::dlog::log("smoke: login_header_transitions_do_not_deadlock"); result.push(json!({"test":"login_header_transitions_do_not_deadlock","pass":true})); + crate::dlog::log("smoke: physical_bounds_have_no_bottom_gap"); result.push(json!({"test":"physical_bounds_have_no_bottom_gap","pass":true})); // These profiles are isolated from the user's sessions. A logged-out // WhatsApp view must still allow opening the local account panel. + crate::dlog::log("smoke: logged_out_profile_has_management_access"); result.push(json!({"test":"logged_out_profile_has_management_access","pass":app.get_webview("shell-main").is_some()&&crate::accounts::startup_account(&f).is_some()})); eval(&app, &label, "window.__whatrustSmokeToken='same-live-view'")?; crate::sessions::detach(&app, &a.id)?; + crate::dlog::log("smoke: detach_without_recreate"); result.push(json!({"test":"detach_without_recreate","pass":app.get_webview(&label).unwrap().window().label().starts_with("detached-")&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); crate::sessions::attach(&app, &a.id)?; + crate::dlog::log("smoke: reattach_preserves_view"); result.push(json!({"test":"reattach_preserves_view","pass":app.get_webview(&label).unwrap().window().label()=="main"&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); crate::sessions::open_panel(&app, Some("main")).map_err(|e| e.to_string())?; std::thread::sleep(Duration::from_secs(2)); + crate::dlog::log("smoke: local_panel_same_window"); result.push(json!({"test":"local_panel_same_window","pass":app.get_webview("settings").is_some_and(|v|v.window().label()=="main"&&crate::integration::trusted_local(&v))})); + crate::dlog::log("smoke: local_panel_loaded"); result.push(json!({"test":"local_panel_loaded","pass":eval(&app,"settings","document.querySelectorAll('.group').length>=7")?=="true"})); crate::sessions::close_panel(&app); crate::sessions::activate(&app, &label); @@ -84,51 +137,81 @@ pub fn start(app: &AppHandle) { } std::thread::sleep(Duration::from_millis(250)); } + crate::dlog::log("smoke: remote_cannot_read_settings"); result.push(json!({"test":"remote_cannot_read_settings","pass":eval(&app,&label,"window.__smokeDenied===true")?=="true"})); crate::sessions::close_panel(&app); app.get_window("main") .unwrap() .set_size(tauri::LogicalSize::new(900., 650.)) .map_err(|e| e.to_string())?; - std::thread::sleep(Duration::from_millis(500)); - let bounds = app - .get_webview(&label) - .unwrap() - .bounds() - .map_err(|e| e.to_string())?; - result.push(json!({"test":"native_resize","pass":bounds.size.to_logical::(app.get_window("main").unwrap().scale_factor().unwrap_or(1.)).width>500.})); + result.push( + json!({"test":"native_resize","pass":wait_until(||fills_client(&app,&label))}), + ); let w = app.get_window("main").unwrap(); for (width, height) in [(560., 480.), (1100., 800.), (900., 650.)] { w.set_size(tauri::LogicalSize::new(width, height)) .map_err(|e| e.to_string())?; - std::thread::sleep(Duration::from_millis(200)); - let scale = w.scale_factor().unwrap_or(1.); - let client = w.inner_size().map_err(|e| e.to_string())?; - let b = app - .get_webview(&label) - .unwrap() - .bounds() - .map_err(|e| e.to_string())?; - let origin = b.position.to_physical::(scale); - let size = b.size.to_physical::(scale); - if size.width != client.width || origin.y as u32 + size.height != client.height { - return Err("resize leaves client gap".into()); + if !wait_until(|| fills_client(&app, &label)) { + return Err("resize did not fill client area".into()); } } + crate::dlog::log("smoke: repeated_resize_fills_client_area"); result.push(json!({"test":"repeated_resize_fills_client_area","pass":true})); w.maximize().map_err(|e| e.to_string())?; - std::thread::sleep(Duration::from_millis(350)); - let scale = w.scale_factor().unwrap_or(1.); - let client = w.inner_size().map_err(|e| e.to_string())?; - let b = app - .get_webview(&label) - .unwrap() - .bounds() - .map_err(|e| e.to_string())?; - let origin = b.position.to_physical::(scale); - let size = b.size.to_physical::(scale); - result.push(json!({"test":"maximized_view_has_no_client_gap","pass":size.width==client.width&&origin.y as u32+size.height==client.height})); + crate::dlog::log("smoke: maximized_view_has_no_client_gap"); + result.push(json!({"test":"maximized_view_has_no_client_gap","pass":wait_until(||fills_client(&app,&label))})); w.unmaximize().map_err(|e| e.to_string())?; + // Concurrent callers must finish on exactly one authoritative view. + for _ in 0..6 { + let h1 = app.clone(); + let l1 = label.clone(); + let h2 = app.clone(); + let l2 = crate::accounts::window_label(&f.accounts[1].id); + let first = std::thread::spawn(move || crate::sessions::activate(&h1, &l1)); + let second = std::thread::spawn(move || crate::sessions::activate(&h2, &l2)); + first.join().map_err(|_| "activation worker failed")?; + second.join().map_err(|_| "activation worker failed")?; + } + crate::sessions::activate(&app, &label); + eval(&app, &label, "true")?; // UI queue barrier after the final request. + let global_active = app + .state::() + .lock() + .unwrap() + .clone(); + crate::dlog::log("smoke: rapid_account_switch_identity_consistent"); + result.push(json!({"test":"rapid_account_switch_identity_consistent","pass":crate::sessions::selected(&app,"main").as_deref()==Some(label.as_str())&&global_active==label&&app.get_window("main").unwrap().title().map_err(|e|e.to_string())?.ends_with(&a.name)})); + crate::dlog::log("smoke: rapid_account_switch_exactly_one_visible"); + result.push(json!({"test":"rapid_account_switch_exactly_one_visible","pass":visible(&app,&label)?&&!visible(&app,&crate::accounts::window_label(&f.accounts[1].id))?})); + for (test, target, hide) in [ + ( + "first_switch_background_viewport", + crate::accounts::window_label(&f.accounts[1].id), + false, + ), + ("single_activation_restores_viewport", label.clone(), true), + ] { + let w = app.get_window("main").unwrap(); + if hide { + w.hide().map_err(|e| e.to_string())?; + } + crate::sessions::activate(&app, &target); + let expected = w + .inner_size() + .map_err(|e| e.to_string())? + .to_logical::(w.scale_factor().unwrap_or(1.)) + .width; + let script = format!("innerWidth>={}", expected - 2.); + let mut full = false; + for _ in 0..20 { + if eval(&app, &target, &script)? == "true" { + full = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + result.push(json!({"test":test,"pass":full})); + } crate::notify::from_account( &app, &label, @@ -148,15 +231,112 @@ pub fn start(app: &AppHandle) { } std::thread::sleep(Duration::from_millis(200)); } + crate::dlog::log("smoke: windows_notification_submission"); result.push(json!({"test":"windows_notification_submission","pass":app.state::().records().iter().any(|n|n.source==crate::notify::Source::Test && n.status=="submitted")})); + let panel = app.get_webview("settings").ok_or("missing local panel")?; + crate::dlog::log("smoke: password_validation"); + result.push(json!({"test":"password_validation","pass":crate::commands::set_app_lock_password(panel.clone(),app.clone(),"abc".into(),"abc".into()).is_err()&&crate::commands::set_app_lock_password(panel.clone(),app.clone(),"test-pass".into(),"mismatch".into()).is_err()})); + crate::commands::set_app_lock_password( + panel.clone(), + app.clone(), + "smoke-old".into(), + "smoke-old".into(), + )?; + crate::dlog::log("smoke: password_stored_as_argon2id"); + result.push(json!({"test":"password_stored_as_argon2id","pass":crate::applock::load(&app).password_phc.is_some_and(|p|p.starts_with("$argon2id$")&&!p.contains("smoke-old"))})); + crate::dlog::log("smoke: password_change_requires_current"); + result.push(json!({"test":"password_change_requires_current","pass":crate::commands::change_app_lock_password(panel.clone(),app.clone(),"wrong".into(),"smoke-new".into(),"smoke-new".into()).is_err()})); + crate::commands::change_app_lock_password( + panel.clone(), + app.clone(), + "smoke-old".into(), + "smoke-new".into(), + "smoke-new".into(), + )?; + crate::commands::set_app_lock_options(panel.clone(), app.clone(), true, true, 120)?; + let config = crate::applock::load(&app); + crate::dlog::log("smoke: lock_preferences_persist"); + result.push(json!({"test":"lock_preferences_persist","pass":config.lock_on_launch&&config.lock_on_hide&&config.idle_secs==120})); + crate::sessions::detach(&app, &b.id)?; crate::lock::lock_now(&app); - std::thread::sleep(Duration::from_millis(300)); - result.push(json!({"test":"lock_hides_host","pass":!app.get_window("main").unwrap().is_visible().unwrap_or(true)&&!crate::lock::is_unlocked(&app)})); + if !wait_until(|| !crate::lock::is_unlocked(&app) && app.get_webview("lock").is_some()) + { + return Err("lock screen did not open".into()); + } + crate::dlog::log("smoke: lock_hides_all_hosts"); + result.push(json!({"test":"lock_hides_all_hosts","pass":!app.get_window("main").unwrap().is_visible().unwrap_or(true)&&!app.get_window(&format!("detached-{}",b.id)).unwrap().is_visible().unwrap_or(true)})); + crate::lock::lock_now(&app); // Repeated lock must preserve the hidden-host list. + let target = crate::accounts::window_label(&b.id); + crate::sessions::activate(&app, &target); + crate::sessions::open_panel(&app, Some("main")).map_err(|e| e.to_string())?; + eval(&app, &label, "true")?; + crate::dlog::log("smoke: locked_activation_and_panel_do_not_reveal_accounts"); + result.push(json!({"test":"locked_activation_and_panel_do_not_reveal_accounts","pass":!app.get_window("main").unwrap().is_visible().unwrap_or(true)&&!app.get_window(&format!("detached-{}",b.id)).unwrap().is_visible().unwrap_or(true)})); + if !wait_until(|| { + eval(&app, "lock", "!!document.getElementById('unlock')") + .ok() + .as_deref() + == Some("true") + }) { + return Err("lock UI not ready".into()); + } + let lock_view = app.get_webview("lock").ok_or("missing lock view")?; + result.push(json!({"test":"disabled_biometric_cannot_unlock","pass":crate::commands::unlock_biometric(lock_view,app.clone()).is_err()&&!crate::lock::is_unlocked(&app)})); + result.push(json!({"test":"remote_cannot_unlock","pass":crate::commands::unlock_password(app.get_webview(&label).unwrap(),app.clone(),"smoke-new".into()).is_err()&&!crate::lock::is_unlocked(&app)})); + eval(&app,"lock","document.getElementById('password').value='wrong';document.getElementById('unlock').click();true")?; + let wrong = wait_until(|| { + eval( + &app, + "lock", + "document.getElementById('error').textContent.includes('Senha incorreta')", + ) + .ok() + .as_deref() + == Some("true") + }); + crate::dlog::log("smoke: wrong_password_keeps_locked_portuguese_feedback"); + result.push(json!({"test":"wrong_password_keeps_locked_portuguese_feedback","pass":wrong&&!crate::lock::is_unlocked(&app)})); + eval(&app,"lock","document.getElementById('password').value='smoke-new';document.getElementById('unlock').click();true")?; + let unlocked = wait_until(|| { + crate::lock::is_unlocked(&app) + && app + .get_window(&format!("detached-{}", b.id)) + .unwrap() + .is_visible() + .unwrap_or(false) + }); + let pending = wait_until(|| { + *app.state::() + .lock() + .unwrap() + == target + }); + crate::dlog::log("smoke: correct_password_restores_pending_account"); + result.push(json!({"test":"correct_password_restores_pending_account","pass":unlocked&&pending})); + crate::dlog::log("smoke: reattach after authentication begin"); + crate::sessions::attach(&app, &b.id)?; + crate::dlog::log("smoke: reattach after authentication end"); + crate::dlog::log("smoke: close to tray begin"); + app.get_window("main") + .unwrap() + .close() + .map_err(|e| e.to_string())?; + crate::dlog::log("smoke: close_to_tray_locks_when_enabled"); + result.push(json!({"test":"close_to_tray_locks_when_enabled","pass":wait_until(||!crate::lock::is_unlocked(&app)&&!app.get_window("main").unwrap().is_visible().unwrap_or(true))})); crate::lock::unlock(&app); - result.push(json!({"test":"unlock_restores_host","pass":crate::lock::is_unlocked(&app)&&app.get_window("main").unwrap().is_visible().unwrap_or(false)})); + if !wait_until(|| crate::lock::is_unlocked(&app)) { + return Err("unlock did not complete".into()); + } + crate::dlog::log("smoke: disable_lock_requires_current_password"); + result.push(json!({"test":"disable_lock_requires_current_password","pass":crate::commands::disable_app_lock(panel.clone(),app.clone(),"wrong".into()).is_err()})); + crate::commands::disable_app_lock(panel, app.clone(), "smoke-new".into())?; + crate::dlog::log("smoke: disable_clears_hash_and_biometrics"); + result.push(json!({"test":"disable_clears_hash_and_biometrics","pass":crate::applock::load(&app)==crate::applock::AppLockConfig::default()})); + Ok(()) })(); if let Err(e) = run { + crate::dlog::log("smoke: native_smoke"); result.push(json!({"test":"native_smoke","pass":false,"error":e})); } let passed = result.iter().all(|r| r["pass"] == true); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 381fe82..de1ca02 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -127,17 +127,17 @@ pub fn rebuild_menu(app: &AppHandle) { builder = builder.item(&item); } - let Ok(accounts_item) = MenuItemBuilder::with_id("accounts", "Accounts\u{2026}").build(app) + let Ok(accounts_item) = MenuItemBuilder::with_id("accounts", "Contas\u{2026}").build(app) else { return; }; - let Ok(settings) = MenuItemBuilder::with_id("settings", "Settings").build(app) else { + let Ok(settings) = MenuItemBuilder::with_id("settings", "Configurações").build(app) else { return; }; - let Ok(reload) = MenuItemBuilder::with_id("reload", "Reload").build(app) else { + let Ok(reload) = MenuItemBuilder::with_id("reload", "Recarregar").build(app) else { return; }; - let Ok(quit) = MenuItemBuilder::with_id("quit", "Quit").build(app) else { + let Ok(quit) = MenuItemBuilder::with_id("quit", "Sair").build(app) else { return; }; @@ -147,7 +147,7 @@ pub fn rebuild_menu(app: &AppHandle) { .item(&settings) .item(&reload); if lock_active { - let Ok(lock_item) = MenuItemBuilder::with_id("lock", "Lock now").build(app) else { + let Ok(lock_item) = MenuItemBuilder::with_id("lock", "Bloquear agora").build(app) else { return; }; tail = tail.item(&lock_item); @@ -188,7 +188,7 @@ pub fn update_badge(app: &AppHandle, count: u32) { } BadgeState::Count(c) => { let _ = tray.set_title(Some(c.to_string())); - let _ = tray.set_tooltip(Some(format!("{c} unread — WhatsApp"))); + let _ = tray.set_tooltip(Some(format!("{c} conversas não lidas — WhatsApp"))); if let Ok(icon) = Image::from_bytes(ICON_UNREAD) { let _ = tray.set_icon_with_as_template(Some(icon), ICON_IS_TEMPLATE); } diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 7a8aee1..e207071 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -173,23 +173,23 @@ macro_rules! account_webview { if success { let body = match done.as_ref().and_then(|(_, p)| p.file_name()) { Some(n) => format!( - "{} — saved to your chosen location.", + "{} — salvo no local escolhido.", n.to_string_lossy() ), - None => "Saved to your chosen location.".to_string(), + None => "Arquivo salvo no local escolhido.".to_string(), }; crate::notify::show_for_account( app, webview.label(), - "Download complete", + "Download concluído", &body, ); } else { crate::notify::show_for_account( app, webview.label(), - "Download failed", - "The file could not be downloaded. Please try again.", + "Falha no download", + "Não foi possível baixar o arquivo. Tente novamente.", ); } } @@ -250,7 +250,7 @@ pub(crate) fn build_account_window( if let tauri::WindowEvent::CloseRequested { api, .. } = event { if crate::settings::load(&app_handle).close_to_tray { let lc = crate::applock::load(&app_handle); - if lc.is_active() && lc.lock_on_hide { + if lc.should_lock_on_hide() { crate::lock::lock_now(&app_handle); } else if let Some(w) = app_handle.get_webview_window(&label_for_close) { let _ = w.hide(); @@ -474,8 +474,8 @@ fn register_drop_handler(win: &WebviewWindow) { // with zero paths. Tell the user instead of silently doing nothing. crate::notify::show( win.app_handle(), - "Nothing was attached", - "That item can't be dropped directly. Drag the file from your file manager, or use the attach (+) button.", + "Nenhum arquivo foi anexado", + "Não é possível arrastar esse item diretamente. Arraste o arquivo pelo Explorador de Arquivos ou use o botão de anexo (+).", ); return; } @@ -485,8 +485,8 @@ fn register_drop_handler(win: &WebviewWindow) { )); crate::notify::show( win.app_handle(), - "Files not attached", - "The drop worker was unavailable. Please try again.", + "Arquivos não anexados", + "Não foi possível processar os arquivos. Tente novamente.", ); } }); @@ -584,50 +584,46 @@ fn plan_drop(paths: &[std::path::PathBuf]) -> (Vec, DropSkips) { /// Human summary of skipped files for the toast. Counts and reasons only — no names. fn summarize_skips(s: &DropSkips) -> String { let mut parts: Vec = Vec::new(); - let plural = |n: usize| if n == 1 { "file" } else { "files" }; + let plural = |n: usize| if n == 1 { "arquivo" } else { "arquivos" }; if s.too_large > 0 { parts.push(format!( - "{} {} over the 500 MB size limit", + "{} {} acima do limite de 500 MB", s.too_large, plural(s.too_large) )); } if s.over_budget > 0 { parts.push(format!( - "{} {} over the 500 MB per-drop total", + "{} {} acima do total de 500 MB por envio", s.over_budget, plural(s.over_budget) )); } if s.over_count > 0 { parts.push(format!( - "{} {} over the 200-file limit", + "{} {} acima do limite de 200 arquivos", s.over_count, plural(s.over_count) )); } if s.not_file > 0 { - parts.push(format!( - "{} folder(s) or special {}", - s.not_file, - plural(s.not_file) - )); + parts.push(format!("pastas ou arquivos especiais: {}", s.not_file)); } if s.unreadable > 0 { parts.push(format!( - "{} unreadable {}", + "{} {} sem permissão de leitura", s.unreadable, plural(s.unreadable) )); } if s.changed > 0 { parts.push(format!( - "{} {} that changed while reading", + "{} {} alterados durante a leitura", s.changed, plural(s.changed) )); } - format!("Not attached: {}.", parts.join(", ")) + format!("Não anexados: {}.", parts.join(", ")) } /// Why streaming one file to the page stopped. @@ -834,8 +830,8 @@ fn stream_drop(w: &WebviewWindow, drop_id: u64, paths: &[std::path::PathBuf]) { )); crate::notify::show( w.app_handle(), - "Files not attached", - "The dropped files couldn't be handed to WhatsApp. Please try again.", + "Arquivos não anexados", + "Não foi possível enviar os arquivos ao WhatsApp. Tente novamente.", ); return; } @@ -856,8 +852,8 @@ fn stream_drop(w: &WebviewWindow, drop_id: u64, paths: &[std::path::PathBuf]) { if drop_ack_failed(&ack) { crate::notify::show( &app, - "Files not attached", - "WhatsApp wasn't ready to receive the dropped files. Please try again.", + "Arquivos não anexados", + "O WhatsApp ainda não estava pronto para receber os arquivos. Tente novamente.", ); } }); @@ -873,7 +869,7 @@ fn stream_drop(w: &WebviewWindow, drop_id: u64, paths: &[std::path::PathBuf]) { if skips.total() > 0 { crate::notify::show( w.app_handle(), - "Some files were not attached", + "Alguns arquivos não foram anexados", &summarize_skips(&skips), ); } @@ -1489,7 +1485,7 @@ fn open_settings_legacy(app: &AppHandle) { return; } match WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("index.html".into())) - .title("whatRust — Settings") + .title("whatRust — Configurações") .inner_size(440.0, 680.0) .min_inner_size(440.0, 520.0) .resizable(true) @@ -1699,8 +1695,8 @@ mod tests { assert_eq!(skips.total(), 2); // The toast summary names both reasons, never file names. let s = summarize_skips(&skips); - assert!(s.contains("folder"), "{s}"); - assert!(s.contains("unreadable"), "{s}"); + assert!(s.contains("pastas"), "{s}"); + assert!(s.contains("sem permissão de leitura"), "{s}"); assert!(!s.contains("ok.mp4"), "{s}"); } @@ -1807,7 +1803,7 @@ mod tests { too_large: 1, ..Default::default() }); - assert_eq!(s, "Not attached: 1 file over the 500 MB size limit."); + assert_eq!(s, "Não anexados: 1 arquivo acima do limite de 500 MB."); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e18ac94..bf36b0e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.4", + "version": "0.6.9-local.5", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From 03d308f49e69b55004faf14f2c86bc2926204d21 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 16:41:18 -0300 Subject: [PATCH 10/22] Fix Windows notification constructor routing and minimized-window delivery --- docs/windows-usabilidade.md | 10 ++++- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/resources/bridge.js | 24 +++++++++--- src-tauri/resources/bridge.test.mjs | 28 +++++++++++++- src-tauri/src/lib.rs | 8 +++- src-tauri/src/sessions.rs | 1 + src-tauri/src/smoke.rs | 57 +++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- 9 files changed, 122 insertions(+), 12 deletions(-) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index 6b74880..4af0d11 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -44,7 +44,7 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.5`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.6`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. @@ -89,3 +89,11 @@ A tela de senha usa SVG de cadeado, paleta neutra, botão verde e textos em port A redefinição exige confirmação e grava um marcador fora da pasta dos perfis. A limpeza só ocorre na próxima inicialização, antes de criar WebViews. Falhas mantêm o marcador e abortam a abertura dos perfis; a redefinição não declara sucesso ignorando arquivos em uso. Os testes destrutivos usam somente diretórios temporários sintéticos. A regressão nativa usa senha sintética para validar rejeição de senha incorreta, troca de senha, bloqueio de janelas principal/destacada, recusa de desbloqueio pela página remota, biometria desativada, restauração da conta pendente, bloqueio ao fechar e desativação com senha atual. O teste visual cobre temas claro/escuro, teclado e textos em português. Autenticação real pelo Windows Hello requer interação com o dispositivo e permanece pendente; não é simulada como sucesso. + +## Notificação com conversa aberta e janela minimizada (local.6) + +O plugin genérico de notificações do Tauri substituía `window.Notification` por uma função que chamava `plugin:notification|notify`. A ponte capturava essa função como se fosse o construtor nativo e delegava para ela; a recusa assíncrona da ACL não acionava a alternativa. No Windows, o aplicativo não registra mais esse plugin, pois o serviço de `notify.rs` já controla permissões, bloqueio, conta e cliques. As permissões da página remota permanecem restritas. macOS/Linux conservam o plugin existente. + +A ponte pede também a permissão real do navegador. Sem ela, usa diretamente o comando próprio; erros assíncronos do construtor nativo acionam a alternativa. O estado de primeiro plano exclui explicitamente janelas minimizadas. O teste nativo agora cria uma notificação pela API JavaScript real dentro do WhatsApp/WebView2, minimizado e oculto na bandeja, e exige recibo da rota WebView2. Uma chamada direta de teste ao Rust não é evidência suficiente para essa integração. + +A API persistente `ServiceWorkerRegistration.showNotification()` chamada pela página usa diretamente o comando próprio: o contrato de `ICoreWebView2_24.NotificationReceived` cobre notificações não persistentes, portanto uma Promise persistente resolvida não prova que esse evento ocorreu. Referência: https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2_24 . A permissão real continua sendo solicitada para o navegador, inclusive para os contextos de worker. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b4f167b..e53a22d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.5" +version = "0.6.9-local.6" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5b49278..de86ea2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.5" +version = "0.6.9-local.6" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index 38de298..c385e78 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -287,8 +287,22 @@ // 2) Notification override — forward to a native OS notification via Rust. try { var OriginalNotification = window.Notification; + // Request the actual browser permission as well as advertising our own + // bridge permission. A shim-only grant cannot enable worker notifications. + if (window.__WHATRUST_WINDOWS__ && OriginalNotification && OriginalNotification.requestPermission) { + Promise.resolve().then(function(){return OriginalNotification.permission === "granted" ? "granted" : OriginalNotification.requestPermission();}).then(function(permission){ + notePath("browser permission " + (permission === "granted" ? "granted" : "not granted; page fallback")); + }).catch(function(){notePath("browser permission unavailable; page fallback");}); + } function ShimNotification(title, options) { - if (window.__whatrustPreferNative && typeof OriginalNotification === "function") { try { return new OriginalNotification(title, options); } catch (e) { notePath("native constructor refused; page fallback"); } } + if (window.__whatrustPreferNative && typeof OriginalNotification === "function" && OriginalNotification.permission === "granted") { + try { + notePath("native Notification()"); + var notification = new OriginalNotification(title, options); + if(notification.addEventListener)notification.addEventListener("error",function(){notePath("native notification error; page fallback");options=options||{};nativeNotify(title,options.body,options.icon);},{once:true}); + return notification; + } catch (e) { notePath("native constructor refused; page fallback"); } + } options = options || {}; this.title = title; this.body = options.body || ""; @@ -352,10 +366,11 @@ try { var SWR = window.ServiceWorkerRegistration; if (SWR && SWR.prototype && typeof SWR.prototype.showNotification === "function") { - var originalShowNotification = SWR.prototype.showNotification; - var originalGetNotifications = SWR.prototype.getNotifications; + // WebView2's NotificationReceived contract covers non-persistent + // Notification() events. A persistent showNotification() promise can + // resolve without that event, so the page-side registration uses our + // own service directly, even when the constructor uses native delivery. SWR.prototype.showNotification = function (title, options) { - if (window.__whatrustPreferNative) return originalShowNotification.call(this, title, options).catch(function(){options=options||{};nativeNotify(title,options.body,options.icon);}); options = options || {}; notePath("page registration.showNotification()"); // Route through nativeNotify so the service-worker path shares the same de-dup @@ -366,7 +381,6 @@ }; if (typeof SWR.prototype.getNotifications === "function") { SWR.prototype.getNotifications = function () { - if (window.__whatrustPreferNative) return originalGetNotifications.apply(this, arguments); return Promise.resolve([]); }; } diff --git a/src-tauri/resources/bridge.test.mjs b/src-tauri/resources/bridge.test.mjs index 45851e3..93c2465 100644 --- a/src-tauri/resources/bridge.test.mjs +++ b/src-tauri/resources/bridge.test.mjs @@ -153,6 +153,7 @@ function makeHarness(options = {}) { const window = { location: { origin: "https://web.whatsapp.com" }, Notification: options.nativeNotification, + ServiceWorkerRegistration: options.serviceWorkerRegistration, __WHATRUST_WINDOWS__: !!options.windows, __whatrustPreferNative: !!options.preferNative, addEventListener() {}, @@ -590,17 +591,40 @@ async function testUnreadReportsSayWhenThePageHasSettled() { async function testWindowsNativePreferenceAndFallback() { let nativeCalls=0; - const healthy=makeHarness({windows:true,preferNative:true,nativeNotification:class {constructor(){nativeCalls++;}}}); + const healthy=makeHarness({windows:true,preferNative:true,nativeNotification:class {static permission="granted";constructor(){nativeCalls++;}}}); new healthy.window.Notification("one",{body:"same"});new healthy.window.Notification("two",{body:"same"}); assert(nativeCalls===2,"healthy native route owns delivery"); assert(!healthy.invocations.some(x=>x.cmd==="notify"),"page route does not duplicate native delivery"); - const failed=makeHarness({windows:true,preferNative:true,nativeNotification:class {constructor(){throw Error("permission");}}}); + const failed=makeHarness({windows:true,preferNative:true,nativeNotification:class {static permission="granted";constructor(){throw Error("permission");}}}); new failed.window.Notification("same",{body:"same"});new failed.window.Notification("same",{body:"same"}); assert(failed.invocations.filter(x=>x.cmd==="notify").length===2,"native rejection falls back without dropping distinct repeated text"); } +async function testNativePermissionAndAsyncErrorFallback() { + const denied=makeHarness({windows:true,preferNative:true,nativeNotification:class {static permission="denied";constructor(){throw Error("must not be used");}}}); + new denied.window.Notification("permission denied",{body:"test"}); + assert(denied.invocations.filter(x=>x.cmd==="notify").length===1,"missing real browser permission uses safe page route"); + let fail; + const asynchronous=makeHarness({windows:true,preferNative:true,nativeNotification:class {static permission="granted";addEventListener(type,callback){if(type==="error")fail=callback;}}}); + new asynchronous.window.Notification("async browser failure",{body:"test"});fail(); + assert(asynchronous.invocations.filter(x=>x.cmd==="notify").length===1,"asynchronous native error reaches page fallback"); + let requested=0; + makeHarness({windows:true,nativeNotification:class {static permission="default";static requestPermission(){requested++;return Promise.resolve("granted");}}}); + await sleep(0);assert(requested===1,"requests actual browser permission for service-worker notifications"); +} + +async function testPersistentWindowsNotificationUsesOwnService() { + let originals=0; + const h=makeHarness({windows:true,preferNative:true,nativeNotification:class {static permission="granted";},serviceWorkerRegistration:class {showNotification(){originals++;return Promise.resolve();}getNotifications(){return Promise.resolve([]);}}}); + await new h.window.ServiceWorkerRegistration().showNotification("persistent test",{body:"test"}); + assert(originals===0,"persistent registration never waits for a non-persistent WebView2 event"); + assert(h.invocations.filter(x=>x.cmd==="notify").length===1,"Windows registration notification reaches account-aware service exactly once"); +} + const tests = [ testWindowsNativePreferenceAndFallback, + testNativePermissionAndAsyncErrorFallback, + testPersistentWindowsNotificationUsesOwnService, testUnreadReportsSayWhenThePageHasSettled, testNotificationPathIsLoggedOnceWithoutContent, testDownloadToastOffersOpenAndShowInFolder, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 922a5fd..493a427 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -94,7 +94,13 @@ pub fn run() { } } - builder = builder.plugin(tauri_plugin_notification::init()); + // The Windows toast service is owned by notify.rs. This plugin injects a + // replacement window.Notification that bypasses that account/lock-aware + // service and invokes its own forbidden remote IPC command instead. + #[cfg(not(windows))] + { + builder = builder.plugin(tauri_plugin_notification::init()); + } #[cfg(desktop)] { diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index a1dd8f2..3e804a8 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -522,6 +522,7 @@ pub fn account_focused(app: &AppHandle, label: &str) -> bool { app.get_webview(label).is_some_and(|v| { let parent = v.window(); parent.is_visible().unwrap_or(false) + && !parent.is_minimized().unwrap_or(false) && parent.is_focused().unwrap_or(false) && selected(app, parent.label()).as_deref() == Some(label) && state(app).panel_parent.lock().unwrap().as_deref() != Some(parent.label()) diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index f01a4f9..c803e04 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -233,6 +233,63 @@ pub fn start(app: &AppHandle) { } crate::dlog::log("smoke: windows_notification_submission"); result.push(json!({"test":"windows_notification_submission","pass":app.state::().records().iter().any(|n|n.source==crate::notify::Source::Test && n.status=="submitted")})); + // Exercise the real page constructor, not just the Rust test command: + // plugin JS replacement used to silently invoke forbidden IPC here. + let host = app.get_webview(&label).unwrap().window(); + host.minimize().map_err(|e| e.to_string())?; + result.push(json!({"test":"minimized_account_is_not_foreground","pass":wait_until(||host.is_minimized().unwrap_or(false))&&!crate::sessions::account_focused(&app,&label)})); + let context=eval(&app,&label,"JSON.stringify({hidden:document.hidden,focused:document.hasFocus(),preferNative:window.__whatrustPreferNative,permission:Notification.permission})")?; + crate::dlog::log(&format!("smoke: minimized browser context {context}")); + for (test, hide) in [ + ("page_notification_while_minimized", false), + ("page_notification_while_hidden_to_tray", true), + ] { + if hide { + host.hide().map_err(|e| e.to_string())?; + } + let previous = app + .state::() + .records() + .iter() + .filter(|n| n.source == crate::notify::Source::WebView2) + .count(); + eval(&app,&label,"new Notification('Teste isolado do whatRust',{body:'Verificação da rota do navegador com janela oculta.'});true")?; + let delivered = wait_until(|| { + app.state::() + .records() + .iter() + .filter(|n| { + n.source == crate::notify::Source::WebView2 + && matches!( + n.status.as_str(), + "submitted" | "dismissed" | "activated" + ) + }) + .count() + > previous + }); + result.push(json!({"test":test,"pass":delivered})); + } + let page_before = app + .state::() + .records() + .iter() + .filter(|n| n.source == crate::notify::Source::Page) + .count(); + eval(&app,&label,"ServiceWorkerRegistration.prototype.showNotification.call({},'Teste da rota de registro',{body:'Verificação isolada do encaminhamento ao aplicativo.'});true")?; + result.push(json!({"test":"registration_notification_while_hidden_reaches_own_service","pass":wait_until(||app.state::().records().iter().filter(|n|n.source==crate::notify::Source::Page&&matches!(n.status.as_str(),"submitted"|"dismissed"|"activated")).count()>page_before)})); + let s = crate::settings::load(&app); + let mut off = s.clone(); + off.notifications = false; + crate::settings::save(&app, &off).map_err(|e| e.to_string())?; + eval( + &app, + &label, + "new Notification('Teste sem aviso',{body:'Desativado nas preferências.'});true", + )?; + result.push(json!({"test":"page_constructor_respects_notifications_disabled","pass":wait_until(||app.state::().records().iter().any(|n|n.source==crate::notify::Source::WebView2&&n.reason.as_deref()==Some("notifications_disabled")))})); + crate::settings::save(&app, &s).map_err(|e| e.to_string())?; + crate::sessions::activate(&app, &label); let panel = app.get_webview("settings").ok_or("missing local panel")?; crate::dlog::log("smoke: password_validation"); result.push(json!({"test":"password_validation","pass":crate::commands::set_app_lock_password(panel.clone(),app.clone(),"abc".into(),"abc".into()).is_err()&&crate::commands::set_app_lock_password(panel.clone(),app.clone(),"test-pass".into(),"mismatch".into()).is_err()})); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bf36b0e..b1bcb85 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.5", + "version": "0.6.9-local.6", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From a628ff82bbea44dda99cee2a180f7081ef2f2417 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 16:44:20 -0300 Subject: [PATCH 11/22] Synchronize child WebView visibility with minimized and hidden account windows --- docs/windows-usabilidade.md | 2 ++ src-tauri/src/lock.rs | 4 ++++ src-tauri/src/sessions.rs | 41 ++++++++++++++++++++++++++++++++++--- src-tauri/src/smoke.rs | 3 +++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index 4af0d11..3991496 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -97,3 +97,5 @@ O plugin genérico de notificações do Tauri substituía `window.Notification` A ponte pede também a permissão real do navegador. Sem ela, usa diretamente o comando próprio; erros assíncronos do construtor nativo acionam a alternativa. O estado de primeiro plano exclui explicitamente janelas minimizadas. O teste nativo agora cria uma notificação pela API JavaScript real dentro do WhatsApp/WebView2, minimizado e oculto na bandeja, e exige recibo da rota WebView2. Uma chamada direta de teste ao Rust não é evidência suficiente para essa integração. A API persistente `ServiceWorkerRegistration.showNotification()` chamada pela página usa diretamente o comando próprio: o contrato de `ICoreWebView2_24.NotificationReceived` cobre notificações não persistentes, portanto uma Promise persistente resolvida não prova que esse evento ocorreu. Referência: https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2_24 . A permissão real continua sendo solicitada para o navegador, inclusive para os contextos de worker. + +As WebViews filhas também precisam de visibilidade própria: minimizar a janela nativa deixava `document.hidden === false` mesmo sem foco. O layout agora oculta controladores quando a janela está minimizada, na bandeja ou bloqueada e restaura apenas a conta/painel selecionado ao reaparecer. A minimização preserva o último tamanho útil. Há regressões para `document.hidden` e retorno à visibilidade, além do envio da notificação. diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index fb4605f..fb4ef38 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -76,6 +76,8 @@ fn lock_on_main(app: &AppHandle) { for (label, w) in app.windows() { if should_hide(&label) && w.is_visible().unwrap_or(false) { let _ = w.hide(); + #[cfg(windows)] + crate::sessions::layout(app, &label); hidden.push(label); } } @@ -111,6 +113,8 @@ fn unlock_on_main(app: &AppHandle) { for label in hidden { if let Some(w) = app.get_window(&label) { let _ = w.show(); + #[cfg(windows)] + crate::sessions::layout(app, &label); } } } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 3e804a8..4253606 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -52,7 +52,9 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { if let Some(label) = state(&a).main_active.lock().unwrap().clone() { *a.state::().lock().unwrap() = label; } + layout(&a, "main"); } + tauri::WindowEvent::Focused(false) => layout(&a, "main"), tauri::WindowEvent::CloseRequested { api, .. } => { if crate::settings::load(&a).close_to_tray { api.prevent_close(); @@ -62,6 +64,7 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { } if let Some(w) = a.get_window("main") { let _ = w.hide(); + layout(&a, "main"); } } else { a.exit(0); @@ -200,9 +203,39 @@ pub fn layout(app: &AppHandle, parent: &str) { let Some(w) = app.get_window(parent) else { return; }; + // Child WebViews do not automatically inherit the native parent's hidden + // or minimized state. Without this, document.hidden stays false and the + // open chat may suppress its own desktop notification. + let minimized = w.is_minimized().unwrap_or(false); + let visible = w.is_visible().unwrap_or(false) && !minimized && crate::lock::is_unlocked(app); + let active = selected(app, parent); + let panel_open = state(app).panel_parent.lock().unwrap().as_deref() == Some(parent); + for (label, view) in app.webviews() { + if view.window().label() != parent { + continue; + } + if !visible { + let _ = view.hide(); + } else if label.starts_with("wa-") { + if !panel_open && active.as_deref() == Some(label.as_str()) { + let _ = view.show(); + } else { + let _ = view.hide(); + } + } else if label == "settings" { + if panel_open { + let _ = view.show(); + } else { + let _ = view.hide(); + } + } + } + // Keep the last usable viewport while minimized; don't turn it into 1x1. + if minimized { + return; + } let Ok(size) = w.inner_size() else { return }; let scale = w.scale_factor().unwrap_or(1.0); - let active = selected(app, parent); let top = if parent == "main" && active .as_ref() @@ -246,8 +279,7 @@ pub fn layout(app: &AppHandle, parent: &str) { if let Ok(json) = serde_json::to_string(&theme) { let _=v.eval(format!("if(document.documentElement&&document.documentElement.dataset.theme!=={json})document.documentElement.dataset.theme={json}")); } - let panel_open = state(app).panel_parent.lock().unwrap().as_deref() == Some(parent); - if !panel_open && (active.is_none() || offset > 0) { + if visible && !panel_open && (active.is_none() || offset > 0) { let _ = v.show(); } else { let _ = v.hide(); @@ -330,7 +362,9 @@ pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { } tauri::WindowEvent::Focused(true) => { *a.state::().lock().unwrap() = l.clone(); + layout(&a, &p); } + tauri::WindowEvent::Focused(false) => layout(&a, &p), tauri::WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let a = a.clone(); @@ -503,6 +537,7 @@ pub fn toggle(app: &AppHandle) { crate::lock::lock_now(app); } else { let _ = w.hide(); + layout(app, w.label()); } } else { activate(app, &label); diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index c803e04..c384076 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -238,6 +238,7 @@ pub fn start(app: &AppHandle) { let host = app.get_webview(&label).unwrap().window(); host.minimize().map_err(|e| e.to_string())?; result.push(json!({"test":"minimized_account_is_not_foreground","pass":wait_until(||host.is_minimized().unwrap_or(false))&&!crate::sessions::account_focused(&app,&label)})); + result.push(json!({"test":"minimized_document_is_hidden","pass":wait_until(||eval(&app,&label,"document.hidden").ok().as_deref()==Some("true"))})); let context=eval(&app,&label,"JSON.stringify({hidden:document.hidden,focused:document.hasFocus(),preferNative:window.__whatrustPreferNative,permission:Notification.permission})")?; crate::dlog::log(&format!("smoke: minimized browser context {context}")); for (test, hide) in [ @@ -246,6 +247,7 @@ pub fn start(app: &AppHandle) { ] { if hide { host.hide().map_err(|e| e.to_string())?; + crate::sessions::layout(&app, host.label()); } let previous = app .state::() @@ -290,6 +292,7 @@ pub fn start(app: &AppHandle) { result.push(json!({"test":"page_constructor_respects_notifications_disabled","pass":wait_until(||app.state::().records().iter().any(|n|n.source==crate::notify::Source::WebView2&&n.reason.as_deref()==Some("notifications_disabled")))})); crate::settings::save(&app, &s).map_err(|e| e.to_string())?; crate::sessions::activate(&app, &label); + result.push(json!({"test":"restored_document_is_visible","pass":wait_until(||eval(&app,&label,"!document.hidden").ok().as_deref()==Some("true"))})); let panel = app.get_webview("settings").ok_or("missing local panel")?; crate::dlog::log("smoke: password_validation"); result.push(json!({"test":"password_validation","pass":crate::commands::set_app_lock_password(panel.clone(),app.clone(),"abc".into(),"abc".into()).is_err()&&crate::commands::set_app_lock_password(panel.clone(),app.clone(),"test-pass".into(),"mismatch".into()).is_err()})); From b6358161d2b36d629290e8ff761153a624d4dd49 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 17:15:23 -0300 Subject: [PATCH 12/22] Make hide-lock silent, cancel locked notifications and harden UI lifecycle and installer launch --- docs/windows-usabilidade.md | 16 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/resources/bridge.js | 7 +- src-tauri/resources/bridge.test.mjs | 11 + src-tauri/resources/integration.js | 2 +- src-tauri/src/commands.rs | 20 +- src-tauri/src/dlog.rs | 9 + src-tauri/src/integration.rs | 6 +- src-tauri/src/lib.rs | 16 +- src-tauri/src/lock.rs | 54 +- src-tauri/src/notify.rs | 203 +++++- src-tauri/src/sessions.rs | 63 +- src-tauri/src/settings.rs | 16 + src-tauri/src/smoke.rs | 75 ++ src-tauri/src/ui_health.rs | 54 ++ src-tauri/src/window.rs | 59 +- src-tauri/tauri.conf.json | 5 +- src-tauri/windows/LICENSE-APACHE-2.0 | 177 +++++ src-tauri/windows/LICENSE-MIT | 21 + src-tauri/windows/README.md | 8 + src-tauri/windows/installer.nsi | 994 +++++++++++++++++++++++++++ 22 files changed, 1713 insertions(+), 107 deletions(-) create mode 100644 src-tauri/src/ui_health.rs create mode 100644 src-tauri/windows/LICENSE-APACHE-2.0 create mode 100644 src-tauri/windows/LICENSE-MIT create mode 100644 src-tauri/windows/README.md create mode 100644 src-tauri/windows/installer.nsi diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index 3991496..d6f2640 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -44,7 +44,7 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.6`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.7`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. @@ -99,3 +99,17 @@ A ponte pede também a permissão real do navegador. Sem ela, usa diretamente o A API persistente `ServiceWorkerRegistration.showNotification()` chamada pela página usa diretamente o comando próprio: o contrato de `ICoreWebView2_24.NotificationReceived` cobre notificações não persistentes, portanto uma Promise persistente resolvida não prova que esse evento ocorreu. Referência: https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2_24 . A permissão real continua sendo solicitada para o navegador, inclusive para os contextos de worker. As WebViews filhas também precisam de visibilidade própria: minimizar a janela nativa deixava `document.hidden === false` mesmo sem foco. O layout agora oculta controladores quando a janela está minimizada, na bandeja ou bloqueada e restaura apenas a conta/painel selecionado ao reaparecer. A minimização preserva o último tamanho útil. Há regressões para `document.hidden` e retorno à visibilidade, além do envio da notificação. + +## Bloqueio silencioso, abertura pelo instalador e travamentos (local.7) + +“Bloquear ao ocultar” agora protege as contas e permanece na bandeja, sem criar nem focar a tela de senha. Abrir pelo ícone, atalho, menu ou outra execução apresenta o desbloqueio. Bloqueio manual mantém a apresentação imediata. Solicitações antigas de apresentação são invalidadas quando a intenção mais recente é ocultar. + +Ao bloquear, o serviço avança uma geração de eventos, cancela os avisos pendentes e remove os avisos anteriores do Windows somente deste aplicativo. A operação do sistema ocorre em workers; o loop gráfico não espera pela limpeza. O envio revalida bloqueio e geração imediatamente antes/depois de chamar o Windows. Temporizadores e conversões de ícone preservam a geração de detecção para não reaparecer após um desbloqueio rápido. A preferência global de notificações e o respeito ao silenciamento continuam valendo. + +O evento CloseRequested de uma notificação do WebView2 não executa mais remoção de histórico WinRT de forma síncrona. ReportShown/Clicked/Closed são adiados para fora do callback original; nenhum método COM é chamado com a coleção de notificações emprestada. A visibilidade dos filhos só é alterada quando muda, reduzindo eventos e reentrância. Consultas de Windows Hello e os IPCs de notificações/layout são executados fora do callback gráfico. Essas alterações tratam caminhos capazes de bloquear a UI; o AppHang registrado pelo Windows confirma a ocorrência, mas não fornece a pilha necessária para atribuir todos os congelamentos a uma única causa. + +O arquivo de diagnóstico anterior é preservado em `whatrust.previous.log`. Um pulso verifica a resposta da thread gráfica; atraso superior a 5 s registra o estágio técnico atual e o tempo de recuperação, sem conteúdo de conversas. Não encerra nem reinicia o processo automaticamente. O teste nativo provoca um atraso finito em um perfil isolado para validar esse diagnóstico. + +A página final NSIS e `/R` passam `--show`. Esse argumento sobrepõe somente a preferência de iniciar oculto; não dispensa a senha quando há bloqueio ao iniciar. Autostart com `--minimized` continua oculto. O template está fixado no Tauri CLI 2.12.3, com origem, hash e licenças em `src-tauri/windows`; apenas as duas chamadas de execução são modificadas. + +A regressão nativa inclui 12 ciclos de criar/encerrar avisos enquanto troca conta, abre/fecha painel e altera não lidas; chegada bloqueada; remoção de histórico Windows; reabertura exigindo senha; não repetição de eventos atrasados; e início explícito visível com preferência persistida de segundo plano. Todos os testes usam contas e arquivos sintéticos. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e53a22d..963ef67 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.6" +version = "0.6.9-local.7" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index de86ea2..333d933 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.6" +version = "0.6.9-local.7" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index c385e78..9203f97 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -143,6 +143,9 @@ return Promise.race([load, timeout]); } function nativeNotify(title, body, icon) { + if(window.__whatrustLocked)return false; + var epoch=window.__whatrustNotificationEpoch||0; + function deliver(args){if(window.__WHATRUST_WINDOWS__)args.epoch=epoch;invoke("notify",args);} title = String(title || "WhatsApp"); body = String(body || ""); var now = Date.now(); @@ -154,12 +157,12 @@ recentNotif[key] = now; var scheme = typeof icon === "string" && (/^(blob|data|https):/.exec(icon) || [])[1]; if (!scheme) { - invoke("notify", { title: title, body: body }); + deliver({ title: title, body: body }); return true; } iconPng(icon).then(function (png) { notePath("icon " + scheme + (png ? " ok" : " unavailable")); - invoke("notify", png ? { title: title, body: body, icon: png } : { title: title, body: body }); + deliver(png ? { title: title, body: body, icon: png } : { title: title, body: body }); }); return true; } diff --git a/src-tauri/resources/bridge.test.mjs b/src-tauri/resources/bridge.test.mjs index 93c2465..331ffd4 100644 --- a/src-tauri/resources/bridge.test.mjs +++ b/src-tauri/resources/bridge.test.mjs @@ -621,10 +621,21 @@ async function testPersistentWindowsNotificationUsesOwnService() { assert(h.invocations.filter(x=>x.cmd==="notify").length===1,"Windows registration notification reaches account-aware service exactly once"); } +async function testDelayedIconRetainsDetectionEpoch(){ + const h=makeHarness({windows:true});h.window.__whatrustNotificationEpoch=7; + new h.window.Notification("late icon",{body:"test",icon:"https://example.com/icon.png"}); + h.window.__whatrustNotificationEpoch=8;await sleep(0); + assert(h.invocations.find(c=>c.cmd==="notify").args.epoch===7,"icon conversion retains the event epoch from before locking"); + const before=h.invocations.filter(c=>c.cmd==="notify").length;h.window.__whatrustLocked=true; + new h.window.Notification("while locked",{body:"test"}); + assert(h.invocations.filter(c=>c.cmd==="notify").length===before,"locked page bridge does not forward message notifications"); +} + const tests = [ testWindowsNativePreferenceAndFallback, testNativePermissionAndAsyncErrorFallback, testPersistentWindowsNotificationUsesOwnService, + testDelayedIconRetainsDetectionEpoch, testUnreadReportsSayWhenThePageHasSettled, testNotificationPathIsLoggedOnceWithoutContent, testDownloadToastOffersOpenAndShowInFolder, diff --git a/src-tauri/resources/integration.js b/src-tauri/resources/integration.js index b40cd25..530897b 100644 --- a/src-tauri/resources/integration.js +++ b/src-tauri/resources/integration.js @@ -27,7 +27,7 @@ if(menu.open)positionMenu(); if(keepFocus){var restore=Array.from(menu.querySelectorAll("button")).find(function(b){return b.dataset.item===focusKey;})||menu.querySelector("button");if(restore)restore.focus();} } - window.__whatrustState=function(next){if(!next)return;state=next;window.__whatrustPreferNative=!!next.native_notifications;draw();}; + window.__whatrustState=function(next){if(!next)return;state=next;window.__whatrustPreferNative=!!next.native_notifications;window.__whatrustNotificationEpoch=next.notification_epoch||0;window.__whatrustLocked=!!next.notifications_locked;draw();}; function create(){ host=document.createElement("span");host.id="__whatrust_controls";host.setAttribute("data-whatrust","controls"); host.style.cssText="display:inline-flex;align-items:center;flex:0 0 auto;min-width:84px;max-width:200px;margin-inline-start:8px;align-self:center"; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ef98dc6..eb92fef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -24,13 +24,14 @@ fn is_remote_label(label: &str) -> bool { /// `icon` is the sender's picture as a small base64 PNG (bridge.js), when /// WhatsApp gave one and it could be loaded. -#[tauri::command] +#[tauri::command(async)] pub fn notify( window: tauri::Webview, app: tauri::AppHandle, title: String, body: String, icon: Option, + epoch: Option, ) { if crate::integration::remote(&window).is_err() { return; @@ -38,14 +39,7 @@ pub fn notify( // issue #3 diagnostics: confirm the command is actually reached from the // injected bridge. No message content is logged (PII). crate::dlog::log("commands::notify invoked"); - crate::notify::from_account( - &app, - window.label(), - &title, - &body, - icon.as_deref(), - crate::notify::Source::Page, - ); + crate::notify::from_page(&app, window.label(), &title, &body, icon.as_deref(), epoch); } /// Diagnostic breadcrumb from the injected page script (bridge.js) into the same @@ -89,7 +83,7 @@ pub fn reveal_download(app: tauri::AppHandle, id: u64) -> Result<(), String> { Ok(()) } -#[tauri::command] +#[tauri::command(async)] pub fn set_unread( window: tauri::Webview, app: tauri::AppHandle, @@ -457,7 +451,7 @@ pub struct LockStatus { /// Read-only status for BOTH the settings window and the lock screen. Never returns /// the password hash. Allowed from any non-remote window, even while locked. -#[tauri::command] +#[tauri::command(async)] pub fn get_lock_status( window: tauri::Webview, app: tauri::AppHandle, @@ -588,7 +582,7 @@ pub fn set_app_lock_options( /// Enable/disable biometric. Enabling requires the lock to be set, the platform to /// report Available, and one successful test authentication. -#[tauri::command] +#[tauri::command(async)] pub fn set_biometric_enabled( window: tauri::Webview, app: tauri::AppHandle, @@ -655,7 +649,7 @@ pub fn unlock_password( } /// Unlock with biometric. Lock-window only; works while locked. -#[tauri::command] +#[tauri::command(async)] pub fn unlock_biometric(window: tauri::Webview, app: tauri::AppHandle) -> Result { if !lock::is_lock_window(&window) { return Err("forbidden".into()); diff --git a/src-tauri/src/dlog.rs b/src-tauri/src/dlog.rs index 9dcf95e..f864b49 100644 --- a/src-tauri/src/dlog.rs +++ b/src-tauri/src/dlog.rs @@ -39,6 +39,15 @@ fn log_path() -> Option { pub fn init() { if let Some(p) = log_path() { let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Keep the preceding run: a forced termination must not erase the + // last control-flow evidence as soon as the user opens the app again. + if p.exists() { + let previous = p.with_file_name(format!( + "{}.previous.log", + p.file_stem().unwrap_or_default().to_string_lossy() + )); + let _ = std::fs::copy(&p, previous); + } let _ = std::fs::File::create(&p); } log("=== session start ==="); diff --git a/src-tauri/src/integration.rs b/src-tauri/src/integration.rs index 2f73bbe..ce3fe9e 100644 --- a/src-tauri/src/integration.rs +++ b/src-tauri/src/integration.rs @@ -75,6 +75,8 @@ pub struct BridgeState { pub accounts: Vec, pub current_id: String, pub native_notifications: bool, + pub notification_epoch: u64, + pub notifications_locked: bool, pub windows: bool, } fn bridge_state(app: &AppHandle, label: &str) -> BridgeState { @@ -86,6 +88,8 @@ fn bridge_state(app: &AppHandle, label: &str) -> BridgeState { }, current_id: accounts::id_from_label(label).unwrap_or("").into(), native_notifications: app.state::().native_ready(label), + notification_epoch: app.state::().epoch(), + notifications_locked: !crate::lock::is_unlocked(app), windows: cfg!(windows), } } @@ -116,7 +120,7 @@ pub fn broadcast(app: &AppHandle) { } } } -#[tauri::command] +#[tauri::command(async)] pub fn report_integration( window: Webview, app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 493a427..b8f38c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,8 @@ mod settings; #[cfg(all(windows, debug_assertions))] mod smoke; mod tray; +#[cfg(windows)] +mod ui_health; mod unread; mod window; @@ -72,7 +74,9 @@ pub fn run() { // A whatsapp:// (or wa.me) link handed to a second launch by // the OS scheme handler (issue #23): open it in the running copy. links::open_from_outside(app, &link); - } else if !args.iter().any(|a| a == "--minimized") { + } else if args.iter().any(|a| a == "--show") + || !args.iter().any(|a| a == "--minimized") + { window::show_main(app); } }, @@ -196,9 +200,13 @@ pub fn run() { // A reset only runs while no WebViews hold the session stores open. // Failure aborts startup before reading accounts or creating a view. applock::complete_pending_reset(handle).map_err(std::io::Error::other)?; - let s = settings::load(handle); + let mut s = settings::load(handle); + if smoke_mode { + s.start_minimized = true; + settings::save(handle, &s)?; + } let args: Vec = std::env::args().collect(); - let start_hidden = s.start_minimized || args.iter().any(|a| a == "--minimized"); + let start_hidden = settings::start_hidden(s.start_minimized, &args); // Load accounts (seeds a single `default` on first run / corrupt file). let mut f = accounts::load(handle); @@ -248,6 +256,8 @@ pub fn run() { window::open_account_window(handle, account, open_hidden || !is_startup)?; } dlog::log("startup: account windows built"); + #[cfg(windows)] + ui_health::start(handle); tray::setup(handle)?; // Let setup return before accessing tray/window APIs again. This // avoids waiting on the Windows UI loop while it is being created. diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index fb4ef38..dcb1490 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -1,4 +1,7 @@ -use std::sync::Mutex; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; /// Runtime lock state. `unlocked == false` means the app is locked. @@ -6,6 +9,7 @@ pub struct LockState { pub unlocked: Mutex, /// Labels of windows hidden by `lock_now`, to re-show on `unlock`. pub hidden: Mutex>, + prompt_requested: AtomicBool, } impl LockState { @@ -13,6 +17,7 @@ impl LockState { Self { unlocked: Mutex::new(unlocked), hidden: Mutex::new(Vec::new()), + prompt_requested: AtomicBool::new(false), } } } @@ -56,22 +61,37 @@ pub fn require_unlocked(app: &AppHandle) -> Result<(), String> { /// Lock the app: mark locked, hide every user-facing window (recording which were /// visible), and show the lock screen. pub fn lock_now(app: &AppHandle) { + lock_with_prompt(app, true); +} +pub fn lock_on_hide(app: &AppHandle) { + lock_with_prompt(app, false); +} +fn lock_with_prompt(app: &AppHandle, prompt: bool) { let handle = app.clone(); - let _ = app.run_on_main_thread(move || lock_on_main(&handle)); + let _ = app.run_on_main_thread(move || lock_on_main(&handle, prompt)); } - -fn lock_on_main(app: &AppHandle) { +fn lock_on_main(app: &AppHandle, prompt: bool) { let state = match app.try_state::() { Some(s) => s, None => return, // not yet set up — bail }; + if !prompt { + state.prompt_requested.store(false, Ordering::SeqCst); + if let Some(w) = app.get_window("lock") { + let _ = w.hide(); + } + } // Idempotent: if already locked, just ensure the lock screen is up — do NOT // re-hide windows or clobber the recorded `hidden` list (which unlock restores). if !*state.unlocked.lock().unwrap() { - show_lock_window(app); + if prompt { + show_lock_window(app); + } return; } *state.unlocked.lock().unwrap() = false; + crate::notify::on_lock(app); + crate::integration::broadcast(app); let mut hidden = Vec::new(); for (label, w) in app.windows() { if should_hide(&label) && w.is_visible().unwrap_or(false) { @@ -82,7 +102,9 @@ fn lock_on_main(app: &AppHandle) { } } *state.hidden.lock().unwrap() = hidden; - show_lock_window(app); + if prompt { + show_lock_window(app); + } } /// Unlock the app: mark unlocked, destroy the lock window, and restore the windows @@ -102,6 +124,8 @@ fn unlock_on_main(app: &AppHandle) { Some(s) => s, None => return, // not yet set up — bail }; + state.prompt_requested.store(false, Ordering::SeqCst); + crate::notify::on_unlock(app); *state.unlocked.lock().unwrap() = true; if let Some(w) = app.get_webview_window("lock") { let _ = w.destroy(); @@ -120,17 +144,26 @@ fn unlock_on_main(app: &AppHandle) { } #[cfg(windows)] crate::sessions::restore_pending(app); + crate::integration::broadcast(app); } /// Create-or-show the dedicated lock window. `content_protected` blocks screen /// capture of the lock screen; `prevent_close` while locked stops the X from /// relaunching into an unlocked state. pub fn show_lock_window(app: &AppHandle) { + if let Some(state) = app.try_state::() { + state.prompt_requested.store(true, Ordering::SeqCst); + } let app = app.clone(); tauri::async_runtime::spawn_blocking(move || { static CREATE: Mutex<()> = Mutex::new(()); let _guard = CREATE.lock().unwrap_or_else(|e| e.into_inner()); - if !is_unlocked(&app) { + if !is_unlocked(&app) + && app + .state::() + .prompt_requested + .load(Ordering::SeqCst) + { show_lock_window_inner(&app); } }); @@ -156,7 +189,12 @@ fn show_lock_window_inner(app: &AppHandle) { if let Ok(win) = built { // Unlock can race a queued window creation; never leave a stale lock // screen on top after authentication has already succeeded. - if is_unlocked(app) { + if is_unlocked(app) + || !app + .state::() + .prompt_requested + .load(Ordering::SeqCst) + { let _ = win.destroy(); return; } diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index b85fd3a..970d8cd 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -29,7 +29,14 @@ pub struct Notice { pub reason: Option, } #[derive(Default)] -pub struct Recent(Mutex); +pub struct Recent { + inner: Mutex, + generation: AtomicU64, + #[cfg(windows)] + delivery_gate: Mutex<()>, + #[cfg(windows)] + cleared_generation: AtomicU64, +} #[derive(Default)] struct Inner { delivered: HashMap, @@ -47,8 +54,20 @@ pub fn event_id() -> String { const DEDUP: Duration = Duration::from_secs(60); pub const FALLBACK_WAIT: Duration = Duration::from_secs(3); impl Recent { + pub fn epoch(&self) -> u64 { + self.generation.load(Ordering::SeqCst) + } + #[cfg(any(windows, test))] + fn cancel_pending(&self, label: &str, id: &str, identity: &str) { + self.finish(label, id, identity, false); + let mut inner = self.inner.lock().unwrap(); + if let Some(n) = inner.records.iter_mut().find(|n| n.id == id) { + n.status = "suppressed".into(); + n.reason = Some("locked".into()); + } + } fn reserve(&self, label: &str, identity: &str, now: Instant) -> bool { - let mut i = self.0.lock().unwrap_or_else(|e| e.into_inner()); + let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner()); i.seen.retain(|_, t| now.duration_since(*t) <= DEDUP); // Bound both memory and burst bookkeeping. IDs are not message content. if i.seen.len() >= 2048 { @@ -61,7 +80,7 @@ impl Recent { true } pub fn notified_since(&self, label: &str, since: Instant) -> bool { - self.0 + self.inner .lock() .unwrap() .delivered @@ -69,7 +88,7 @@ impl Recent { .is_some_and(|(_, t)| *t >= since) } pub fn consume_coverage(&self, label: &str, since: Instant) -> bool { - let mut inner = self.0.lock().unwrap(); + let mut inner = self.inner.lock().unwrap(); let receipts = inner.coverage.entry(label.into()).or_default(); while receipts.front().is_some_and(|(_, time)| *time < since) { receipts.pop_front(); @@ -77,10 +96,10 @@ impl Recent { receipts.pop_front().is_some() } pub fn native_ready(&self, label: &str) -> bool { - self.0.lock().unwrap().native.contains(label) + self.inner.lock().unwrap().native.contains(label) } pub fn set_native(&self, label: &str, ready: bool) { - let mut i = self.0.lock().unwrap(); + let mut i = self.inner.lock().unwrap(); if ready { i.native.insert(label.into()); } else { @@ -88,7 +107,7 @@ impl Recent { } } pub fn records(&self) -> Vec { - self.0 + self.inner .lock() .unwrap() .records @@ -98,7 +117,7 @@ impl Recent { .collect() } fn record(&self, label: &str, id: &str, source: Source, status: &str, reason: Option<&str>) { - let mut i = self.0.lock().unwrap(); + let mut i = self.inner.lock().unwrap(); i.records.push_back(Notice { id: id.into(), account_id: crate::accounts::id_from_label(label).unwrap_or("").into(), @@ -115,7 +134,7 @@ impl Recent { } } fn finish(&self, label: &str, id: &str, identity: &str, success: bool) { - let mut i = self.0.lock().unwrap(); + let mut i = self.inner.lock().unwrap(); let success = success && !i.records.iter().any(|r| r.id == id && r.status == "failed"); let real_source = i .records @@ -150,7 +169,7 @@ impl Recent { } fn lifecycle(&self, id: &str, status: &str) { if let Some(r) = self - .0 + .inner .lock() .unwrap() .records @@ -206,6 +225,36 @@ pub fn suppression( None } +pub fn from_page( + app: &AppHandle, + label: &str, + title: &str, + body: &str, + icon: Option<&str>, + epoch: Option, +) { + let recent = app.state::(); + if epoch.is_some_and(|e| e != recent.epoch()) { + recent.record( + label, + &event_id(), + Source::Page, + "suppressed", + Some("locked"), + ); + crate::dlog::log("notification: stale page event suppressed after lock transition"); + return; + } + from_account(app, label, title, body, icon, Source::Page); +} +/// Advance again on unlock so work detected during initial locked startup +/// cannot become deliverable merely because authentication just completed. +pub fn on_unlock(app: &AppHandle) { + app.state::() + .generation + .fetch_add(1, Ordering::SeqCst); +} + pub fn from_account( app: &AppHandle, label: &str, @@ -314,8 +363,12 @@ pub fn unread_increased(app: &AppHandle, label: &str, n: u32) { let app = app.clone(); let label = label.to_string(); let started = Instant::now(); + let epoch = app.state::().epoch(); std::thread::spawn(move || { std::thread::sleep(FALLBACK_WAIT); + if app.state::().epoch() != epoch || !crate::lock::is_unlocked(&app) { + return; + } if app .state::() .notified_since(&label, started - Duration::from_secs(5)) @@ -348,9 +401,13 @@ pub fn visual_signal( let identity: String = identity.chars().take(180).collect(); // The queue is bounded by the adapter's batch/rate limit; native delivery // gets first refusal. No content is taken from the DOM or kept in history. + let epoch = app.state::().epoch(); tauri::async_runtime::spawn(async move { let start = Instant::now(); tokio::time::sleep(Duration::from_millis(1800)).await; + if app.state::().epoch() != epoch || !crate::lock::is_unlocked(&app) { + return; + } if app .state::() .consume_coverage(&label, start - Duration::from_millis(300)) @@ -384,6 +441,9 @@ pub fn show_for_account(app: &AppHandle, label: &str, title: &str, body: &str) { show(app, title, body); } pub fn show_with_icon(app: &AppHandle, title: &str, body: &str, icon: Option<&Path>) { + if !crate::lock::is_unlocked(app) { + return; + } #[cfg(windows)] windows_toast(app, title, body, icon.map(Path::to_path_buf), None, None); #[cfg(not(windows))] @@ -414,17 +474,64 @@ fn escape_xml(s: &str) -> String { } #[cfg(windows)] -pub fn cancel_native(app: &AppHandle, identity: &str) { +fn clear_history_before_delivery(app: &AppHandle, generation: u64) { use windows::{core::HSTRING, UI::Notifications::ToastNotificationManager}; - if let Ok(history) = ToastNotificationManager::History() { - let _ = history.RemoveGroupedTagWithId( - &HSTRING::from(identity), - &HSTRING::from("messages"), - &HSTRING::from(&app.config().identifier), - ); + let recent = app.state::(); + if recent.cleared_generation.load(Ordering::SeqCst) < generation { + if let Ok(history) = ToastNotificationManager::History() { + let _ = history.ClearWithId(&HSTRING::from(&app.config().identifier)); + } + recent + .cleared_generation + .store(generation, Ordering::SeqCst); } - native_report_if_needed(app, identity, "closed"); } + +/// Called immediately on lock. Old asynchronous work must remain cancelled +/// even if the user unlocks before its timer or icon conversion finishes. +pub fn on_lock(app: &AppHandle) { + let recent = app.state::(); + recent.generation.fetch_add(1, Ordering::SeqCst); + #[cfg(windows)] + { + let generation = recent.epoch(); + let toasts = std::mem::take(&mut recent.inner.lock().unwrap().toasts); + let handle = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let recent = handle.state::(); + let _gate = recent.delivery_gate.lock().unwrap(); + use windows::{core::HSTRING, UI::Notifications::ToastNotificationManager}; + if let Ok(notifier) = ToastNotificationManager::CreateToastNotifierWithId( + &HSTRING::from(&handle.config().identifier), + ) { + for toast in toasts.into_values() { + let _ = notifier.Hide(&toast); + } + } + clear_history_before_delivery(&handle, generation); + }); + } +} + +#[cfg(windows)] +pub fn cancel_native(app: &AppHandle, identity: &str) { + // CloseRequested runs on the WebView2 UI thread. WinRT history removal + // can wait for Shell callbacks; never do that in this browser callback. + let handle = app.clone(); + let identity = identity.to_string(); + tauri::async_runtime::spawn_blocking(move || { + use windows::{core::HSTRING, UI::Notifications::ToastNotificationManager}; + if let Ok(history) = ToastNotificationManager::History() { + let _ = history.RemoveGroupedTagWithId( + &HSTRING::from(&identity), + &HSTRING::from("messages"), + &HSTRING::from(&handle.config().identifier), + ); + } + native_report_if_needed(&handle, &identity, "closed"); + }); +} + #[cfg(windows)] fn native_report_if_needed(app: &AppHandle, id: &str, status: &str) { crate::window::native_report(app, id, status); @@ -451,11 +558,15 @@ fn windows_toast( let app = app.clone(); let title = title.to_string(); let body = body.to_string(); + let epoch = app.state::().epoch(); std::thread::spawn(move || { - if !crate::lock::is_unlocked(&app) { + let recent = app.state::(); + let _gate = recent.delivery_gate.lock().unwrap(); + clear_history_before_delivery(&app, epoch); + if !crate::lock::is_unlocked(&app) || app.state::().epoch() != epoch { if let Some(d) = &delivery { app.state::() - .finish(&d.label, &d.id, &d.identity, false); + .cancel_pending(&d.label, &d.id, &d.identity); if d.native { crate::window::native_report(&app, &d.identity, "discard"); } @@ -509,7 +620,7 @@ fn windows_toast( if let Some(d) = &dismissed { let r = dismiss.state::(); r.lifecycle(&d.id, "dismissed"); - r.0.lock().unwrap().toasts.remove(&d.id); + r.inner.lock().unwrap().toasts.remove(&d.id); if d.native { crate::window::native_report(&dismiss, &d.identity, "closed"); } @@ -523,7 +634,7 @@ fn windows_toast( if let Some(d) = &failed { let r = fail.state::(); r.finish(&d.label, &d.id, &d.identity, false); - r.0.lock().unwrap().toasts.remove(&d.id); + r.inner.lock().unwrap().toasts.remove(&d.id); if d.native { crate::window::native_report(&fail, &d.identity, "discard"); } @@ -532,29 +643,51 @@ fn windows_toast( Ok(()) }), )?; - ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from( + if !crate::lock::is_unlocked(&app) || app.state::().epoch() != epoch { + return Err(windows::core::Error::from_hresult(windows::core::HRESULT( + 0x800704C7u32 as i32, + ))); + } + let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from( &app.config().identifier, - ))? - .Show(&toast)?; + ))?; + notifier.Show(&toast)?; + if !crate::lock::is_unlocked(&app) || app.state::().epoch() != epoch { + let _ = notifier.Hide(&toast); + return Err(windows::core::Error::from_hresult(windows::core::HRESULT( + 0x800704C7u32 as i32, + ))); + } Ok(toast) })(); if let Some(d) = &delivery { let recent = app.state::(); - recent.finish(&d.label, &d.id, &d.identity, send.is_ok()); + if !crate::lock::is_unlocked(&app) || recent.epoch() != epoch { + recent.cancel_pending(&d.label, &d.id, &d.identity); + } else { + recent.finish(&d.label, &d.id, &d.identity, send.is_ok()); + } if let Ok(toast) = &send { - let mut i = recent.0.lock().unwrap(); + let mut i = recent.inner.lock().unwrap(); + if i.toasts.len() >= 100 { if let Some(key) = i.toasts.keys().next().cloned() { i.toasts.remove(&key); } } - i.toasts.insert(d.id.clone(), toast.clone()); + if crate::lock::is_unlocked(&app) && recent.epoch() == epoch { + i.toasts.insert(d.id.clone(), toast.clone()); + } } if d.native { crate::window::native_report( &app, &d.identity, - if send.is_ok() { "shown" } else { "discard" }, + if send.is_ok() && crate::lock::is_unlocked(&app) && recent.epoch() == epoch { + "shown" + } else { + "discard" + }, ); } } @@ -684,4 +817,16 @@ mod tests { assert!(!fallback_due(Some(1), 2, true, true)); assert!(!fallback_due(Some(1), 2, false, false)); } + #[test] + fn lock_generation_invalidates_queued_work_across_unlocks() { + let recent = Recent::default(); + let queued = recent.epoch(); + recent.generation.fetch_add(1, Ordering::SeqCst); + assert_ne!(queued, recent.epoch()); + recent.record("wa-a", "queued", Source::Page, "pending", None); + recent.cancel_pending("wa-a", "queued", "event"); + let record = &recent.records()[0]; + assert_eq!(record.status, "suppressed"); + assert_eq!(record.reason.as_deref(), Some("locked")); + } } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 4253606..71e09c2 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -14,12 +14,32 @@ pub struct Sessions { pending: Mutex>, panel_parent: Mutex>, menus_hidden: Mutex>, + view_visibility: Mutex>, } fn state(app: &AppHandle) -> tauri::State<'_, Sessions> { app.state::() } +fn set_view_visible(app: &AppHandle, view: &tauri::Webview, visible: bool) { + let changed = { + let s = state(app); + let mut map = s.view_visibility.lock().unwrap(); + map.insert(view.label().into(), visible) != Some(visible) + }; + // Release the cache lock before COM: showing can generate native events. + if changed { + let result = if visible { view.show() } else { view.hide() }; + if result.is_err() { + state(app) + .view_visibility + .lock() + .unwrap() + .remove(view.label()); + } + } +} + pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { if let Some(w) = app.get_window("main") { return Ok(w); @@ -60,7 +80,7 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { api.prevent_close(); let c = crate::applock::load(&a); if c.should_lock_on_hide() { - crate::lock::lock_now(&a); + crate::lock::lock_on_hide(&a); } if let Some(w) = a.get_window("main") { let _ = w.hide(); @@ -127,7 +147,7 @@ pub fn open_account(app: &AppHandle, account: &Account, hidden: bool) -> tauri:: let label = accounts::window_label(&account.id); if app.get_webview(&label).is_none() { let view = crate::window::build_account_view(app, &parent, account)?; - let _ = view.hide(); + set_view_visible(app, &view, false); } if state(app).main_active.lock().unwrap().is_none() { *state(app).main_active.lock().unwrap() = Some(label.clone()); @@ -154,6 +174,7 @@ pub fn activate(app: &AppHandle, label: &str) { } fn activate_on_main(app: &AppHandle, label: &str) { + let _health = crate::ui_health::enter("account activation"); if !crate::lock::is_unlocked(app) { *state(app).pending.lock().unwrap() = Some(label.to_string()); crate::lock::show_lock_window(app); @@ -168,13 +189,13 @@ fn activate_on_main(app: &AppHandle, label: &str) { } if let Some(panel) = app.get_webview("settings") { if panel.window().label() == parent.label() { - let _ = panel.hide(); + set_view_visible(app, &panel, false); *state(app).panel_parent.lock().unwrap() = None; } } for (other, v) in app.webviews() { if other.starts_with("wa-") && v.window().label() == parent.label() && other != label { - let _ = v.hide(); + set_view_visible(app, &v, false); } } *app.state::().lock().unwrap() = label.to_string(); @@ -191,7 +212,7 @@ fn activate_on_main(app: &AppHandle, label: &str) { let _ = parent.set_title(&format!("whatRust — {name}")); let _ = parent.show(); let _ = parent.unminimize(); - let _ = view.show(); + set_view_visible(app, &view, true); // WebView2 must be visible before its final bounds/viewport are applied. layout(app, parent.label()); let _ = parent.set_focus(); @@ -200,6 +221,7 @@ fn activate_on_main(app: &AppHandle, label: &str) { } pub fn layout(app: &AppHandle, parent: &str) { + let _health = crate::ui_health::enter("account layout"); let Some(w) = app.get_window(parent) else { return; }; @@ -215,18 +237,18 @@ pub fn layout(app: &AppHandle, parent: &str) { continue; } if !visible { - let _ = view.hide(); + set_view_visible(app, &view, false); } else if label.starts_with("wa-") { if !panel_open && active.as_deref() == Some(label.as_str()) { - let _ = view.show(); + set_view_visible(app, &view, true); } else { - let _ = view.hide(); + set_view_visible(app, &view, false); } } else if label == "settings" { if panel_open { - let _ = view.show(); + set_view_visible(app, &view, true); } else { - let _ = view.hide(); + set_view_visible(app, &view, false); } } } @@ -280,9 +302,9 @@ pub fn layout(app: &AppHandle, parent: &str) { let _=v.eval(format!("if(document.documentElement&&document.documentElement.dataset.theme!=={json})document.documentElement.dataset.theme={json}")); } if visible && !panel_open && (active.is_none() || offset > 0) { - let _ = v.show(); + set_view_visible(app, &v, true); } else { - let _ = v.hide(); + set_view_visible(app, &v, false); } } } @@ -292,6 +314,7 @@ pub fn layout(app: &AppHandle, parent: &str) { // Changing menus inside layout re-enters that mutex and freezes the UI thread. // Record transitions before invoking native APIs; resize only changes bounds. fn sync_menu(app: &AppHandle, parent: &str) { + let _health = crate::ui_health::enter("native menu"); let hidden = selected(app, parent).is_some_and(|label| { app.state::() .0 @@ -334,6 +357,7 @@ pub fn integration_changed(app: &AppHandle, label: &str, mounted: bool) { } pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { + let _health = crate::ui_health::enter("detach account"); crate::lock::require_unlocked(app)?; let label = accounts::window_label(id); let v = app.get_webview(&label).ok_or("Conta não está aberta")?; @@ -381,6 +405,7 @@ pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { } pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { + let _health = crate::ui_health::enter("attach account"); crate::lock::require_unlocked(app)?; let label = accounts::window_label(id); let v = app.get_webview(&label).ok_or("Conta não está aberta")?; @@ -391,7 +416,7 @@ pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { } if let Some(panel) = app.get_webview("settings") { if panel.window().label() == old.label() { - let _ = panel.hide(); + set_view_visible(app, &panel, false); panel .reparent(&initialize(app, true).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; @@ -433,7 +458,7 @@ fn choose_remaining(app: &AppHandle, exclude: Option<&str>) { sync_menu(app, "main"); layout(app, "main"); if let Some(v) = app.get_webview("shell-main") { - let _ = v.show(); + set_view_visible(app, &v, true); } } } @@ -442,6 +467,7 @@ pub fn remove_view(app: &AppHandle, id: &str) -> tauri::Result<()> { if let Some(v) = app.get_webview(&accounts::window_label(id)) { let owner = v.window(); v.close()?; + state(app).view_visibility.lock().unwrap().remove(v.label()); if owner.label().starts_with("detached-") { let _ = owner.destroy(); } @@ -451,6 +477,7 @@ pub fn remove_view(app: &AppHandle, id: &str) -> tauri::Result<()> { } pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { + let _health = crate::ui_health::enter("account panel"); if !crate::lock::is_unlocked(app) { crate::lock::show_lock_window(app); return Ok(()); @@ -493,7 +520,7 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { let _ = panel.eval(theme_script); for (l, v) in app.webviews() { if l.starts_with("wa-") && v.window().label() == owner.label() { - let _ = v.hide(); + set_view_visible(app, &v, false); } } *state(app).panel_parent.lock().unwrap() = Some(owner.label().into()); @@ -504,7 +531,7 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { crate::lock::show_lock_window(app); return Ok(()); } - let _ = panel.show(); + set_view_visible(app, &panel, true); let _ = owner.show(); let _ = owner.unminimize(); let _ = owner.set_focus(); @@ -516,7 +543,7 @@ pub fn close_panel(app: &AppHandle) { *state(app).panel_parent.lock().unwrap() = None; if let Some(v) = app.get_webview("settings") { let parent = v.window(); - let _ = v.hide(); + set_view_visible(app, &v, false); if let Some(label) = selected(app, parent.label()) { activate(app, &label); } @@ -534,7 +561,7 @@ pub fn toggle(app: &AppHandle) { if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) { let c = crate::applock::load(app); if c.should_lock_on_hide() { - crate::lock::lock_now(app); + crate::lock::lock_on_hide(app); } else { let _ = w.hide(); layout(app, w.label()); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b667039..49ac101 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -38,6 +38,10 @@ impl Default for Settings { } } +pub fn start_hidden(preference: bool, args: &[String]) -> bool { + !args.iter().any(|a| a == "--show") && (preference || args.iter().any(|a| a == "--minimized")) +} + /// Zoom bounds. The settings UI only offers four presets inside this range; the /// clamp exists so a hand-edited settings.json cannot leave the app at a zoom /// level from which the settings window is unreadable. @@ -313,4 +317,16 @@ mod tests { let back: Settings = serde_json::from_str(&json).unwrap(); assert_eq!(s, back); } + #[test] + fn explicit_foreground_launch_overrides_background_preference() { + let args = |items: &[&str]| items.iter().map(|s| s.to_string()).collect::>(); + assert!(super::start_hidden(true, &args(&[]))); + assert!(super::start_hidden(false, &args(&["--minimized"]))); + assert!(!super::start_hidden(true, &args(&["--show"]))); + assert!(!super::start_hidden( + true, + &args(&["--show", "--minimized"]) + )); + assert!(!super::start_hidden(false, &args(&[]))); + } } diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index c384076..95dc562 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -66,6 +66,11 @@ pub fn start(app: &AppHandle) { let a = &f.accounts[0]; let b = &f.accounts[1]; let label = crate::accounts::window_label(&a.id); + let startup_hidden = crate::settings::start_hidden( + crate::settings::load(&app).start_minimized, + &std::env::args().collect::>(), + ); + result.push(json!({"test":"startup_visibility_matches_explicit_foreground_request","pass":app.get_window("main").unwrap().is_visible().unwrap_or(false)!=startup_hidden})); crate::dlog::log("smoke: two_live_profiles"); result.push(json!({"test":"two_live_profiles","pass":app.get_webview(&label).is_some()&&app.get_webview(&crate::accounts::window_label(&b.id)).is_some()})); // Reproduce the installed-app regression: the page reports no @@ -293,6 +298,17 @@ pub fn start(app: &AppHandle) { crate::settings::save(&app, &s).map_err(|e| e.to_string())?; crate::sessions::activate(&app, &label); result.push(json!({"test":"restored_document_is_visible","pass":wait_until(||eval(&app,&label,"!document.hidden").ok().as_deref()==Some("true"))})); + for step in 0..12 { + eval(&app,&label,"window.__closeStress=new Notification('Teste de encerramento',{body:'Evento sintético para verificar a interface.'});window.__closeStress.close();window.__TAURI__.core.invoke('set_unread',{title:'WhatsApp',settled:true});true")?; + crate::sessions::activate(&app, &crate::accounts::window_label(&b.id)); + crate::sessions::open_panel(&app, Some("main")).map_err(|e| e.to_string())?; + crate::sessions::close_panel(&app); + crate::sessions::activate(&app, &label); + if eval(&app, &label, "1+1")? != "2" { + return Err(format!("UI stalled after notification close, cycle {step}")); + } + } + result.push(json!({"test":"notification_close_with_account_panel_navigation_12_cycles","pass":true})); let panel = app.get_webview("settings").ok_or("missing local panel")?; crate::dlog::log("smoke: password_validation"); result.push(json!({"test":"password_validation","pass":crate::commands::set_app_lock_password(panel.clone(),app.clone(),"abc".into(),"abc".into()).is_err()&&crate::commands::set_app_lock_password(panel.clone(),app.clone(),"test-pass".into(),"mismatch".into()).is_err()})); @@ -383,16 +399,75 @@ pub fn start(app: &AppHandle) { .map_err(|e| e.to_string())?; crate::dlog::log("smoke: close_to_tray_locks_when_enabled"); result.push(json!({"test":"close_to_tray_locks_when_enabled","pass":wait_until(||!crate::lock::is_unlocked(&app)&&!app.get_window("main").unwrap().is_visible().unwrap_or(true))})); + std::thread::sleep(Duration::from_millis(300)); + result.push(json!({"test":"hide_lock_does_not_prompt_until_reopen","pass":app.get_window("lock").is_none_or(|w|!w.is_visible().unwrap_or(false))})); + let before = app.state::().records().len(); + eval( + &app, + &label, + "new Notification('Teste bloqueado',{body:'Não deve aparecer no Windows.'});true", + )?; + result.push(json!({"test":"incoming_browser_notification_suppressed_while_locked","pass":wait_until(||app.state::().records().len()>before)&&app.state::().records().first().is_some_and(|n|n.status=="suppressed"&&n.reason.as_deref()==Some("locked"))})); + result.push(json!({"test":"lock_removes_existing_windows_notification_history","pass":wait_until(||{ + use windows::{core::HSTRING,UI::Notifications::ToastNotificationManager}; + ToastNotificationManager::History().and_then(|h|h.GetHistoryWithId(&HSTRING::from(&app.config().identifier))).and_then(|h|h.Size()).is_ok_and(|size|size==0) + })})); + crate::window::show_main(&app); + result.push(json!({"test":"reopen_locked_app_prompts_without_revealing_account","pass":wait_until(||app.get_window("lock").is_some_and(|w|w.is_visible().unwrap_or(false)))&&!app.get_window("main").unwrap().is_visible().unwrap_or(true)})); crate::lock::unlock(&app); if !wait_until(|| crate::lock::is_unlocked(&app)) { return Err("unlock did not complete".into()); } + let visual_count = app + .state::() + .records() + .iter() + .filter(|n| n.source == crate::notify::Source::Visual) + .count(); + crate::notify::visual_signal(&app, &label, "delayed-before-lock", true, Some(false)); + crate::lock::lock_on_hide(&app); + if !wait_until(|| !crate::lock::is_unlocked(&app)) { + return Err("silent lock did not complete".into()); + } + crate::lock::unlock(&app); + if !wait_until(|| crate::lock::is_unlocked(&app)) { + return Err("unlock did not complete".into()); + } + std::thread::sleep(Duration::from_millis(2100)); + result.push(json!({"test":"delayed_notification_not_replayed_after_lock_unlock","pass":app.state::().records().iter().filter(|n|n.source==crate::notify::Source::Visual).count()==visual_count})); + let stale = app + .state::() + .epoch() + .saturating_sub(1); + crate::commands::notify( + app.get_webview(&label).unwrap(), + app.clone(), + "Evento antigo".into(), + "Conversão de ícone atrasada".into(), + None, + Some(stale), + ); + result.push(json!({"test":"stale_page_icon_notification_not_replayed_after_unlock","pass":app.state::().records().first().is_some_and(|n|n.status=="suppressed"&&n.reason.as_deref()==Some("locked"))})); crate::dlog::log("smoke: disable_lock_requires_current_password"); result.push(json!({"test":"disable_lock_requires_current_password","pass":crate::commands::disable_app_lock(panel.clone(),app.clone(),"wrong".into()).is_err()})); crate::commands::disable_app_lock(panel, app.clone(), "smoke-new".into())?; crate::dlog::log("smoke: disable_clears_hash_and_biometrics"); result.push(json!({"test":"disable_clears_hash_and_biometrics","pass":crate::applock::load(&app)==crate::applock::AppLockConfig::default()})); + // Isolated, finite stall validates the diagnostic path itself. + let (tx, rx) = std::sync::mpsc::channel(); + app.run_on_main_thread(move || { + let _health = crate::ui_health::enter("synthetic stall test"); + std::thread::sleep(Duration::from_millis(7200)); + let _ = tx.send(()); + }) + .map_err(|e| e.to_string())?; + let recovered = rx.recv_timeout(Duration::from_secs(12)).is_ok(); + let log = std::env::var_os("WHATRUST_SMOKE_REPORT") + .map(std::path::PathBuf::from) + .map(|p| p.with_extension("log")); + result.push(json!({"test":"ui_heartbeat_detects_stall_and_recovers","pass":recovered&&wait_until(||log.as_ref().and_then(|p|std::fs::read_to_string(p).ok()).is_some_and(|s|s.contains("ui: heartbeat delayed;")&&s.contains("ui: heartbeat recovered")))})); + Ok(()) })(); if let Err(e) = run { diff --git a/src-tauri/src/ui_health.rs b/src-tauri/src/ui_health.rs new file mode 100644 index 0000000..378ad5e --- /dev/null +++ b/src-tauri/src/ui_health.rs @@ -0,0 +1,54 @@ +//! Bounded UI heartbeat diagnostics. Never kills/restarts or exposes content. +use std::sync::Mutex; +use std::time::{Duration, Instant}; +static ACTION: Mutex<&'static str> = Mutex::new("idle"); +pub struct Span(&'static str); +pub fn enter(action: &'static str) -> Span { + let mut value = ACTION.lock().unwrap_or_else(|e| e.into_inner()); + let previous = *value; + *value = action; + Span(previous) +} +impl Drop for Span { + fn drop(&mut self) { + *ACTION.lock().unwrap_or_else(|e| e.into_inner()) = self.0; + } +} +pub fn start(app: &tauri::AppHandle) { + let handle = app.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(Duration::from_secs(2)); + let (tx, rx) = std::sync::mpsc::channel(); + let started = Instant::now(); + if handle + .run_on_main_thread(move || { + let _ = tx.send(()); + }) + .is_err() + { + return; + } + let mut reported = false; + loop { + match rx.recv_timeout(Duration::from_secs(5)) { + Ok(()) => { + if reported { + crate::dlog::log(&format!( + "ui: heartbeat recovered after {}ms", + started.elapsed().as_millis() + )); + } + break; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if !reported { + let action = *ACTION.lock().unwrap_or_else(|e| e.into_inner()); + crate::dlog::log(&format!("ui: heartbeat delayed; action={action}")); + reported = true; + } + } + Err(_) => return, + } + } + }); +} diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index e207071..028d79f 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -78,9 +78,9 @@ macro_rules! account_webview { $builder .user_agent(&user_agent()) .initialization_script(if cfg!(windows) { - "window.__WHATRUST_WINDOWS__ = true;" + format!("window.__WHATRUST_WINDOWS__=true;window.__whatrustNotificationEpoch={};window.__whatrustLocked={};",$app.state::().epoch(),!crate::lock::is_unlocked($app)) } else { - "" + String::new() }) .initialization_script(include_str!("../resources/integration.js")) .initialization_script(BRIDGE_JS) @@ -251,7 +251,7 @@ pub(crate) fn build_account_window( if crate::settings::load(&app_handle).close_to_tray { let lc = crate::applock::load(&app_handle); if lc.should_lock_on_hide() { - crate::lock::lock_now(&app_handle); + crate::lock::lock_on_hide(&app_handle); } else if let Some(w) = app_handle.get_webview_window(&label_for_close) { let _ = w.hide(); } @@ -1175,35 +1175,40 @@ thread_local! { static NATIVE_NOTIFICATIONS: std::cell::RefCell" + StrCmp $R0 0 0 wix_loop_done + StrCpy $WixMode 1 + StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" + Goto compare_version + wix_loop_done: + + ; Check if there is an existing installation, if not, abort the reinstall page + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} "$R0$R1" == "" ${|} Abort ${|} + + ; Compare this installar version with the existing installation + ; and modify the messages presented to the user accordingly + compare_version: + StrCpy $R4 "$(older)" + ${If} $WixMode = 1 + ReadRegStr $R0 HKLM "$R6" "DisplayVersion" + ${Else} + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + ${EndIf} + ${IfThen} $R0 == "" ${|} StrCpy $R4 "$(unknown)" ${|} + + nsis_tauri_utils::SemverCompare "${VERSION}" $R0 + Pop $R0 + ; Reinstalling the same version + ${If} $R0 = 0 + StrCpy $R1 "$(alreadyInstalledLong)" + StrCpy $R2 "$(addOrReinstall)" + StrCpy $R3 "$(uninstallApp)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" + ; Upgrading + ${ElseIf} $R0 = 1 + StrCpy $R1 "$(olderOrUnknownVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + StrCpy $R3 "$(dontUninstall)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ; Downgrading + ${ElseIf} $R0 = -1 + StrCpy $R1 "$(newerVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + !if "${ALLOWDOWNGRADES}" == "true" + StrCpy $R3 "$(dontUninstall)" + !else + StrCpy $R3 "$(dontUninstallDowngrade)" + !endif + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ${Else} + Abort + ${EndIf} + + ; Skip showing the page if passive + ; + ; Note that we don't call this earlier at the beginning + ; of this function because we need to populate some variables + ; related to current installed version if detected and whether + ; we are downgrading or not. + ${If} $PassiveMode = 1 + Call PageLeaveReinstall + ${Else} + nsDialogs::Create 1018 + Pop $R4 + ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} + + ${NSD_CreateLabel} 0 0 100% 24u $R1 + Pop $R1 + + ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 + Pop $R2 + ${NSD_OnClick} $R2 PageReinstallUpdateSelection + + ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 + Pop $R3 + ; Disable this radio button if downgrading and downgrades are disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|} + !endif + ${NSD_OnClick} $R3 PageReinstallUpdateSelection + + ; Check the first radio button if this the first time + ; we enter this page or if the second button wasn't + ; selected the last time we were on this page + ${If} $ReinstallPageCheck <> 2 + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${Else} + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${EndIf} + + ${NSD_SetFocus} $R2 + nsDialogs::Show + ${EndIf} +FunctionEnd +Function PageReinstallUpdateSelection + ${NSD_GetState} $R2 $R1 + ${If} $R1 == ${BST_CHECKED} + StrCpy $ReinstallPageCheck 1 + ${Else} + StrCpy $ReinstallPageCheck 2 + ${EndIf} +FunctionEnd +Function PageLeaveReinstall + ${NSD_GetState} $R2 $R1 + + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} + + ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version + ; $R1 holds the radio buttons state: + ; 1 => first choice was selected + ; 0 => second choice was selected + ${If} $R0 = 0 ; Same version, proceed + ${If} $R1 = 1 ; User chose to add/reinstall + Goto reinst_done + ${Else} ; User chose to uninstall + Goto reinst_uninstall + ${EndIf} + ${ElseIf} $R0 = 1 ; Upgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${ElseIf} $R0 = -1 ; Downgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${EndIf} + + reinst_uninstall: + HideWindow + ClearErrors + + ${If} $WixMode = 1 + ReadRegStr $R1 HKLM "$R6" "UninstallString" + ExecWait '$R1' $0 + ${Else} + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 "$R1 /UPDATE" ${|} ; append /UPDATE + ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 "$R1 /P" ${|} ; append /P + StrCpy $R1 "$R1 _?=$4" ; append uninstall directory + ExecWait '$R1' $0 + ${EndIf} + + BringToFront + + ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code + + ${If} $0 <> 0 + ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" + ; User cancelled wix uninstaller? return to select un/reinstall page + ${If} $WixMode = 1 + ${AndIf} $0 = 1602 + Abort + ${EndIf} + + ; User cancelled NSIS uninstaller? return to select un/reinstall page + ${If} $0 = 1 + Abort + ${EndIf} + + ; Other errors? show generic error message and return to select un/reinstall page + MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" + Abort + ${EndIf} + + ; The NSIS uninstaller was run in place (`_?=`), so it cannot delete itself + ; and the install directory, clean them up here once the uninstall succeeded + ; https://nsis.sourceforge.io/Docs/Chapter3.html#:~:text=path%20contains%20spaces.-,3.2.3%20Examples,-installer.exe%20/NCRC + ${If} $WixMode = 0 + ${AndIf} $4 != "" + Delete "$4\uninstall.exe" + RMDir "$4" + ${EndIf} + reinst_done: +FunctionEnd + +; 5. Choose install directory page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_DIRECTORY + +; 6. Start menu shortcut page +Var AppStartMenuFolder +!if "${STARTMENUFOLDER}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" +!else + !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip +!endif +!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder + +; 7. Installation page +!insertmacro MUI_PAGE_INSTFILES + +; 8. Finish page +; +; Don't auto jump to finish page after installation page, +; because the installation page has useful info that can be used debug any issues with the installer. +!define MUI_FINISHPAGE_NOAUTOCLOSE +; Use show readme button in the finish page as a button create a desktop shortcut +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut +; Show run app after installation. +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_FINISH + +Function RunMainBinary + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "--show" +FunctionEnd + +; Uninstaller Pages +; 1. Confirm uninstall page +Var DeleteAppDataCheckbox +Var DeleteAppDataCheckboxState +!define /ifndef WS_EX_LAYOUTRTL 0x00400000 +!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow +Function un.ConfirmShow ; Add add a `Delete app data` check box + ; $1 inner dialog HWND + ; $2 window DPI + ; $3 style + ; $4 x + ; $5 y + ; $6 width + ; $7 height + FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog + System::Call "user32::GetDpiForWindow(p r1) i .r2" + ${If} $(^RTL) = 1 + StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" + IntOp $4 50 * $2 + ${Else} + StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" + IntOp $4 0 * $2 + ${EndIf} + IntOp $5 100 * $2 + IntOp $6 400 * $2 + IntOp $7 25 * $2 + IntOp $4 $4 / 96 + IntOp $5 $5 / 96 + IntOp $6 $6 / 96 + IntOp $7 $7 / 96 + System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' + Pop $DeleteAppDataCheckbox + SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 + SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave +Function un.ConfirmLeave + SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive +!insertmacro MUI_UNPAGE_CONFIRM + +; 2. Uninstalling Page +!insertmacro MUI_UNPAGE_INSTFILES + +;Languages +{{#each languages}} +!insertmacro MUI_LANGUAGE "{{this}}" +{{/each}} +!insertmacro MUI_RESERVEFILE_LANGDLL +{{#each language_files}} + !include "{{this}}" +{{/each}} + +Function .onInit + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/NS" $NoShortcutMode + ${IfNot} ${Errors} + StrCpy $NoShortcutMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} + + !if "${DISPLAYLANGUAGESELECTOR}" == "true" + !insertmacro MUI_LANGDLL_DISPLAY + !endif + + !insertmacro SetContext + + ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" + ; Set default install location + !if "${INSTALLMODE}" == "perMachine" + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else if "${ARCH}" == "arm64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + !endif + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + ${EndIf} + !else if "${INSTALLMODE}" == "currentUser" + StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" + !endif + + Call RestorePreviousInstallLocation + ${EndIf} + + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif + + ; Abort downgrades if they are disabled, in the modes that skip the reinstall page choice: + ; silent, passive and update + !if "${ALLOWDOWNGRADES}" == "false" + ${If} ${Silent} + ${OrIf} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + nsis_tauri_utils::SemverCompare "${VERSION}" $R0 + Pop $R0 + ; If downgrading + ${If} $R0 = -1 + ${If} ${Silent} + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 <> 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "$(silentDowngrades)" + ${EndIf} + ${Else} + MessageBox MB_ICONSTOP "$(silentDowngrades)" + ${EndIf} + Abort + ${EndIf} + ${EndIf} + !endif +FunctionEnd + +Section WebView2 + ; Check if Webview2 is already installed and skip this section + ${If} ${RunningX64} + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${Else} + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + ${If} $4 == "" + ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + + ${If} $4 == "" + ; Webview2 installation + ; + ; Skip if updating + ${If} $UpdateMode <> 1 + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "$(webview2Downloading)" + NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Pop $0 + ${If} $0 == "success" + DetailPrint "$(webview2DownloadSuccess)" + ${Else} + DetailPrint "$(webview2DownloadError)" + Abort "$(webview2AbortError)" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto webview2_done + + install_webview2: + DetailPrint "$(installingWebview2)" + ; $6 holds the path to the webview2 installer + ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + DetailPrint "$(webview2InstallError)" + Abort "$(webview2AbortError)" + ${EndIf} + webview2_done: + ${EndIf} + ${Else} + !if "${MINIMUMWEBVIEW2VERSION}" != "" + ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 + ${If} $R0 = 1 + update_webview: + DetailPrint "$(installingWebview2)" + ${If} ${RunningX64} + ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" + ${Else} + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 == "" + ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 != "" + ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md + ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" + ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview + Quit + ignore: + ${EndIf} + ${EndIf} + ${EndIf} + !endif + ${EndIf} +SectionEnd + +Section Install + SetOutPath $INSTDIR + + !ifmacrodef NSIS_HOOK_PREINSTALL + !insertmacro NSIS_HOOK_PREINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Copy main executable + File "${MAINBINARYSRCPATH}" + + ; Copy resources + {{#each resources_dirs}} + CreateDirectory "$INSTDIR\\{{this}}" + {{/each}} + {{#each resources}} + File /a "/oname={{this.[1]}}" "{{no-escape @key}}" + {{/each}} + + ; Copy external binaries + {{#each binaries}} + File /a "/oname={{this}}" "{{no-escape @key}}" + {{/each}} + + ; Create file associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" + {{/each}} + {{/each}} + + ; Register deep links + {{#each deep_link_protocols as |protocol| ~}} + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + {{/each}} + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Save $INSTDIR in registry for future installations + WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR + + !if "${INSTALLMODE}" == "both" + ; Save install mode to be selected by default for the next installation such as updating + ; or when uninstalling + WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 + !endif + + ; Remove old main binary if it doesn't match new main binary name + ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" + ${If} $OldMainBinaryName != "" + ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" + Delete "$INSTDIR\$OldMainBinaryName" + ${EndIf} + + ; Save current MAINBINARYNAME for future updates + WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" + + ; Registry information for add/remove programs + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" + WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" + + ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 + IntOp $0 $0 + ${ESTIMATEDSIZE} + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + !if "${HOMEPAGE}" != "" + WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" + !endif + + ; Create start menu shortcut + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + Call CreateOrUpdateStartMenuShortcut + !insertmacro MUI_STARTMENU_WRITE_END + + ; Create desktop shortcut for silent and passive installers + ; because finish page will be skipped + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + Call CreateOrUpdateDesktopShortcut + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTINSTALL + !insertmacro NSIS_HOOK_POSTINSTALL + !endif + + ; Auto close this page for passive mode + ${If} $PassiveMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function .onInstSuccess + ; Check for `/R` flag only in silent and passive installers because + ; GUI installer has a toggle for the user to (re)start the app + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + ${GetOptions} $CMDLINE "/R" $R0 + ${IfNot} ${Errors} + ${GetOptions} $CMDLINE "/ARGS" $R0 + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "--show $R0" + ${EndIf} + ${EndIf} +FunctionEnd + +Function un.onInit + !insertmacro SetContext + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_UNINIT + !endif + + !insertmacro MUI_UNGETLANGUAGE + + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} +FunctionEnd + +Section Uninstall + + !ifmacrodef NSIS_HOOK_PREUNINSTALL + !insertmacro NSIS_HOOK_PREUNINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Delete the app directory and its content from disk + ; Copy main executable + Delete "$INSTDIR\${MAINBINARYNAME}.exe" + + ; Delete resources + {{#each resources}} + Delete "$INSTDIR\\{{this.[1]}}" + {{/each}} + + ; Delete external binaries + {{#each binaries}} + Delete "$INSTDIR\\{{this}}" + {{/each}} + + ; Delete app associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" + {{/each}} + {{/each}} + + ; Delete deep links + {{#each deep_link_protocols as |protocol| ~}} + ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" + ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" + ${EndIf} + {{/each}} + + + ; Delete uninstaller + Delete "$INSTDIR\uninstall.exe" + + {{#each resources_ancestors}} + RMDir /REBOOTOK "$INSTDIR\\{{this}}" + {{/each}} + RMDir "$INSTDIR" + + ; Remove shortcuts if not updating + ${If} $UpdateMode <> 1 + !insertmacro DeleteAppUserModelId + + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + + ; Remove desktop shortcuts + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" + Delete "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + ${EndIf} + + ; Remove registry information for add/remove programs + !if "${INSTALLMODE}" == "both" + DeleteRegKey SHCTX "${UNINSTKEY}" + !else if "${INSTALLMODE}" == "perMachine" + DeleteRegKey HKLM "${UNINSTKEY}" + !else + DeleteRegKey HKCU "${UNINSTKEY}" + !endif + + ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. + ; This ensures the program does not launch automatically after uninstallation if it exists. + ; If it doesn't exist, it does nothing. + ; We do this when not updating (to preserve the registry value on updates) + ${If} $UpdateMode <> 1 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${EndIf} + + ; Delete app data if the checkbox is selected + ; and if not updating + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + ; Clear the install location $INSTDIR from registry + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + + ; Clear the install language from registry + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" + + SetShellVarContext current + RmDir /r "$APPDATA\${BUNDLEID}" + RmDir /r "$LOCALAPPDATA\${BUNDLEID}" + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTUNINSTALL + !insertmacro NSIS_HOOK_POSTUNINSTALL + !endif + + ; Auto close if passive mode or updating + ${If} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd + +Function Skip + Abort +FunctionEnd + +Function SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd +Function un.SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd + +Function CreateOrUpdateStartMenuShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + StrCpy $R0 0 + + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + ${If} $R0 = 1 + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + !if "${STARTMENUFOLDER}" != "" + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + !else + CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + !endif +FunctionEnd + +Function CreateOrUpdateDesktopShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" +FunctionEnd From 0de71bda7e233885e1b68671f2d75487d6679a22 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 17:19:27 -0300 Subject: [PATCH 13/22] Keep unit fixture logs separate from installed app diagnostics --- docs/windows-usabilidade.md | 2 ++ src-tauri/src/dlog.rs | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index d6f2640..eaed9de 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -113,3 +113,5 @@ O arquivo de diagnóstico anterior é preservado em `whatrust.previous.log`. Um A página final NSIS e `/R` passam `--show`. Esse argumento sobrepõe somente a preferência de iniciar oculto; não dispensa a senha quando há bloqueio ao iniciar. Autostart com `--minimized` continua oculto. O template está fixado no Tauri CLI 2.12.3, com origem, hash e licenças em `src-tauri/windows`; apenas as duas chamadas de execução são modificadas. A regressão nativa inclui 12 ciclos de criar/encerrar avisos enquanto troca conta, abre/fecha painel e altera não lidas; chegada bloqueada; remoção de histórico Windows; reabertura exigindo senha; não repetição de eventos atrasados; e início explícito visível com preferência persistida de segundo plano. Todos os testes usam contas e arquivos sintéticos. + +Os testes unitários desativam o arquivo de log instalado. As regressões nativas continuam usando seu próprio relatório/log isolado, evitando misturar arquivos sintéticos com evidência do aplicativo real. diff --git a/src-tauri/src/dlog.rs b/src-tauri/src/dlog.rs index f864b49..750d932 100644 --- a/src-tauri/src/dlog.rs +++ b/src-tauri/src/dlog.rs @@ -18,6 +18,9 @@ use std::sync::Mutex; static LOCK: Mutex<()> = Mutex::new(()); fn log_path() -> Option { + if cfg!(test) { + return None; + } // Unit fixtures must never pollute the installed app's log. if cfg!(debug_assertions) && std::env::var("WHATRUST_SMOKE").as_deref() == Ok("1") { return std::env::var_os("WHATRUST_SMOKE_REPORT") .map(PathBuf::from) @@ -70,3 +73,11 @@ pub fn log(msg: &str) { let _ = writeln!(f, "[{ts}] {msg}"); } } + +#[cfg(test)] +mod tests { + #[test] + fn unit_fixtures_never_use_the_installed_log() { + assert!(super::log_path().is_none()); + } +} From 0f04f2e1daa3b2757b13c8945aae03ced6996db3 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 17:29:33 -0300 Subject: [PATCH 14/22] Prevent native callback and reparent layout deadlocks; coalesce geometry updates --- docs/windows-usabilidade.md | 8 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/notify.rs | 56 ++++++++++---- src-tauri/src/sessions.rs | 147 ++++++++++++++++++++++++++++-------- src-tauri/src/smoke.rs | 12 +++ src-tauri/tauri.conf.json | 2 +- 7 files changed, 178 insertions(+), 51 deletions(-) diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md index eaed9de..f37e687 100644 --- a/docs/windows-usabilidade.md +++ b/docs/windows-usabilidade.md @@ -44,7 +44,7 @@ Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout ## Entrega e limites -O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.7`, acima da versão 0.6.8 anteriormente instalada. +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9-local.8`, acima da versão 0.6.8 anteriormente instalada. Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. @@ -115,3 +115,9 @@ A página final NSIS e `/R` passam `--show`. Esse argumento sobrepõe somente a A regressão nativa inclui 12 ciclos de criar/encerrar avisos enquanto troca conta, abre/fecha painel e altera não lidas; chegada bloqueada; remoção de histórico Windows; reabertura exigindo senha; não repetição de eventos atrasados; e início explícito visível com preferência persistida de segundo plano. Todos os testes usam contas e arquivos sintéticos. Os testes unitários desativam o arquivo de log instalado. As regressões nativas continuam usando seu próprio relatório/log isolado, evitando misturar arquivos sintéticos com evidência do aplicativo real. + +## Layout deferido (local.8) + +Um novo atraso real foi registrado em `account layout`. O runtime do Tauri despacha eventos com o mutex de listeners da janela adquirido; aplicar visibilidade/tamanho no callback de foco/redimensionamento permitia reentrada no mesmo caminho nativo. Esses callbacks agora apenas enfileiram um layout por janela; o fechamento também agenda suas mutações após liberar o callback. Limites físicos idênticos não são reaplicados. Mostrar uma visualização ou mover entre janelas invalida a geometria para reaplicar seu tamanho final uma vez. Essa correção é adicional às alterações de notificações da local.7. + +A regressão nativa também reproduziu o travamento ao reintegrar uma visualização com layout pendente. O dispatcher Wry segura seu mutex de ID de janela enquanto aguarda a mensagem de reparent; um layout anterior na fila tentava esse mesmo mutex, bloqueando a UI antes de processar a mudança. O reparent agora ocorre na própria tarefa de UI, com a visualização previamente oculta, sem adquirir o mutex no worker que aguardará a fila. Painel e contas compartilham esse caminho. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 963ef67..0e47083 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.7" +version = "0.6.9-local.8" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 333d933..990f780 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.7" +version = "0.6.9-local.8" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 970d8cd..8be901b 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -559,6 +559,10 @@ fn windows_toast( let title = title.to_string(); let body = body.to_string(); let epoch = app.state::().epoch(); + let toast_key = delivery + .as_ref() + .map(|d| d.id.clone()) + .unwrap_or_else(event_id); std::thread::spawn(move || { let recent = app.state::(); let _gate = recent.delivery_gate.lock().unwrap(); @@ -613,6 +617,7 @@ fn windows_toast( ))?; let dismiss = app.clone(); let dismissed = delivery.clone(); + let dismissed_key = toast_key.clone(); toast.Dismissed(&TypedEventHandler::< ToastNotification, ToastDismissedEventArgs, @@ -620,25 +625,40 @@ fn windows_toast( if let Some(d) = &dismissed { let r = dismiss.state::(); r.lifecycle(&d.id, "dismissed"); - r.inner.lock().unwrap().toasts.remove(&d.id); if d.native { crate::window::native_report(&dismiss, &d.identity, "closed"); } } + let retired = dismiss + .state::() + .inner + .lock() + .unwrap() + .toasts + .remove(&dismissed_key); + drop(retired); Ok(()) }))?; let fail = app.clone(); let failed = delivery.clone(); + let failed_key = toast_key.clone(); toast.Failed( &TypedEventHandler::::new(move |_, _| { if let Some(d) = &failed { let r = fail.state::(); r.finish(&d.label, &d.id, &d.identity, false); - r.inner.lock().unwrap().toasts.remove(&d.id); if d.native { crate::window::native_report(&fail, &d.identity, "discard"); } } + let retired = fail + .state::() + .inner + .lock() + .unwrap() + .toasts + .remove(&failed_key); + drop(retired); crate::dlog::log("notification: Windows reported asynchronous failure"); Ok(()) }), @@ -660,6 +680,26 @@ fn windows_toast( } Ok(toast) })(); + if let Ok(toast) = &send { + let retired = { + let mut inner = recent.inner.lock().unwrap(); + let retired = if inner.toasts.len() >= 100 { + inner + .toasts + .keys() + .next() + .cloned() + .and_then(|key| inner.toasts.remove(&key)) + } else { + None + }; + if crate::lock::is_unlocked(&app) && recent.epoch() == epoch { + inner.toasts.insert(toast_key, toast.clone()); + } + retired + }; + drop(retired); // Never release an external COM object while holding our state lock. + } if let Some(d) = &delivery { let recent = app.state::(); if !crate::lock::is_unlocked(&app) || recent.epoch() != epoch { @@ -667,18 +707,6 @@ fn windows_toast( } else { recent.finish(&d.label, &d.id, &d.identity, send.is_ok()); } - if let Ok(toast) = &send { - let mut i = recent.inner.lock().unwrap(); - - if i.toasts.len() >= 100 { - if let Some(key) = i.toasts.keys().next().cloned() { - i.toasts.remove(&key); - } - } - if crate::lock::is_unlocked(&app) && recent.epoch() == epoch { - i.toasts.insert(d.id.clone(), toast.clone()); - } - } if d.native { crate::window::native_report( &app, diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 71e09c2..d4879cd 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -15,6 +15,8 @@ pub struct Sessions { panel_parent: Mutex>, menus_hidden: Mutex>, view_visibility: Mutex>, + applied_bounds: Mutex>, + pending_layout: Mutex>, } fn state(app: &AppHandle) -> tauri::State<'_, Sessions> { @@ -29,6 +31,13 @@ fn set_view_visible(app: &AppHandle, view: &tauri::Webview, visible: bool) { }; // Release the cache lock before COM: showing can generate native events. if changed { + if visible { + state(app) + .applied_bounds + .lock() + .unwrap() + .remove(view.label()); + } let result = if visible { view.show() } else { view.hide() }; if result.is_err() { state(app) @@ -40,6 +49,51 @@ fn set_view_visible(app: &AppHandle, view: &tauri::Webview, visible: bool) { } } +/// Native focus/resize callbacks execute while Tauri owns listener locks. +/// Only queue here; COM geometry/visibility must run after that callback exits. +pub fn schedule_layout(app: &AppHandle, parent: &str) { + if !state(app) + .pending_layout + .lock() + .unwrap() + .insert(parent.into()) + { + return; + } + let handle = app.clone(); + let parent = parent.to_string(); + tauri::async_runtime::spawn_blocking(move || { + let ui = handle.clone(); + let _ = handle.run_on_main_thread(move || { + state(&ui).pending_layout.lock().unwrap().remove(&parent); + layout(&ui, &parent); + }); + }); +} +fn forget_view_geometry(app: &AppHandle, label: &str) { + state(app).applied_bounds.lock().unwrap().remove(label); + state(app).view_visibility.lock().unwrap().remove(label); +} + +fn reparent_on_ui(app: &AppHandle, view: &tauri::Webview, parent: &Window) -> Result<(), String> { + let handle = app.clone(); + let view = view.clone(); + let parent = parent.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + app.run_on_main_thread(move || { + let _health = crate::ui_health::enter("reparent webview"); + // Wry holds its window-id mutex until reparent returns. Doing that on + // a worker deadlocks if the UI is concurrently processing layout for + // that same dispatcher. Hide first so focus callbacks have completed. + set_view_visible(&handle, &view, false); + forget_view_geometry(&handle, view.label()); + let result = view.reparent(&parent).map_err(|e| e.to_string()); + let _ = tx.send(result); + }) + .map_err(|e| e.to_string())?; + rx.recv().map_err(|e| e.to_string())? +} + pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { if let Some(w) = app.get_window("main") { return Ok(w); @@ -66,29 +120,36 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { let a = app.clone(); w.on_window_event(move |event| match event { tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. } => { - layout(&a, "main") + schedule_layout(&a, "main") } - tauri::WindowEvent::Focused(true) => { - if let Some(label) = state(&a).main_active.lock().unwrap().clone() { - *a.state::().lock().unwrap() = label; + tauri::WindowEvent::Focused(focused) => { + if *focused { + let label = state(&a).main_active.lock().unwrap().clone(); + if let Some(label) = label { + *a.state::().lock().unwrap() = label; + } } - layout(&a, "main"); + schedule_layout(&a, "main"); } - tauri::WindowEvent::Focused(false) => layout(&a, "main"), tauri::WindowEvent::CloseRequested { api, .. } => { - if crate::settings::load(&a).close_to_tray { - api.prevent_close(); - let c = crate::applock::load(&a); - if c.should_lock_on_hide() { - crate::lock::lock_on_hide(&a); - } - if let Some(w) = a.get_window("main") { - let _ = w.hide(); - layout(&a, "main"); - } - } else { - a.exit(0); - } + api.prevent_close(); + let handle = a.clone(); + tauri::async_runtime::spawn_blocking(move || { + let ui = handle.clone(); + let _ = handle.run_on_main_thread(move || { + if crate::settings::load(&ui).close_to_tray { + if crate::applock::load(&ui).should_lock_on_hide() { + crate::lock::lock_on_hide(&ui); + } + if let Some(w) = ui.get_window("main") { + let _ = w.hide(); + layout(&ui, "main"); + } + } else { + ui.exit(0); + } + }); + }); } _ => {} }); @@ -221,6 +282,11 @@ fn activate_on_main(app: &AppHandle, label: &str) { } pub fn layout(app: &AppHandle, parent: &str) { + let handle = app.clone(); + let parent = parent.to_string(); + let _ = app.run_on_main_thread(move || layout_on_main(&handle, &parent)); +} +fn layout_on_main(app: &AppHandle, parent: &str) { let _health = crate::ui_health::enter("account layout"); let Some(w) = app.get_window(parent) else { return; @@ -282,10 +348,24 @@ pub fn layout(app: &AppHandle, parent: &str) { } else { size.height.saturating_sub(y).max(1) }; - let _ = v.set_bounds(tauri::Rect { - position: tauri::PhysicalPosition::new(0, y as i32).into(), - size: tauri::PhysicalSize::new(size.width.max(1), height).into(), - }); + let geometry = (0, y as i32, size.width.max(1), height); + let changed = state(app) + .applied_bounds + .lock() + .unwrap() + .insert(label.clone(), geometry) + != Some(geometry); + if changed { + let _stage = crate::ui_health::enter("apply webview bounds"); + if v.set_bounds(tauri::Rect { + position: tauri::PhysicalPosition::new(geometry.0, geometry.1).into(), + size: tauri::PhysicalSize::new(geometry.2, geometry.3).into(), + }) + .is_err() + { + state(app).applied_bounds.lock().unwrap().remove(&label); + } + } if shell { let theme = active .as_ref() @@ -375,20 +455,20 @@ pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { .build() .map_err(|e| e.to_string())?; install_menu(app, &w).map_err(|e| e.to_string())?; - v.reparent(&w).map_err(|e| e.to_string())?; + reparent_on_ui(app, &v, &w)?; let a = app.clone(); let account = id.to_string(); let p = parent_label.clone(); let l = label.clone(); w.on_window_event(move |event| match event { tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. } => { - layout(&a, &p) + schedule_layout(&a, &p) } tauri::WindowEvent::Focused(true) => { *a.state::().lock().unwrap() = l.clone(); - layout(&a, &p); + schedule_layout(&a, &p); } - tauri::WindowEvent::Focused(false) => layout(&a, &p), + tauri::WindowEvent::Focused(false) => schedule_layout(&a, &p), tauri::WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let a = a.clone(); @@ -417,13 +497,14 @@ pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { if let Some(panel) = app.get_webview("settings") { if panel.window().label() == old.label() { set_view_visible(app, &panel, false); - panel - .reparent(&initialize(app, true).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?; + reparent_on_ui( + app, + &panel, + &initialize(app, true).map_err(|e| e.to_string())?, + )?; } } - v.reparent(&initialize(app, true).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?; + reparent_on_ui(app, &v, &initialize(app, true).map_err(|e| e.to_string())?)?; state(app).menus_hidden.lock().unwrap().remove(old.label()); let _ = old.destroy(); activate(app, &label); @@ -504,7 +585,7 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { let theme_script = format!("document.documentElement.dataset.theme={encoded_theme}"); let panel = if let Some(v) = app.get_webview("settings") { if v.window().label() != owner.label() { - v.reparent(&owner)?; + reparent_on_ui(app, &v, &owner).map_err(std::io::Error::other)?; } v } else { diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index 95dc562..53c9c52 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -127,6 +127,18 @@ pub fn start(app: &AppHandle) { crate::sessions::attach(&app, &a.id)?; crate::dlog::log("smoke: reattach_preserves_view"); result.push(json!({"test":"reattach_preserves_view","pass":app.get_webview(&label).unwrap().window().label()=="main"&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); + for cycle in 0..12 { + crate::sessions::schedule_layout(&app, "main"); + crate::sessions::detach(&app, &b.id)?; + crate::sessions::schedule_layout(&app, &format!("detached-{}", b.id)); + crate::sessions::attach(&app, &b.id)?; + let target = crate::accounts::window_label(&b.id); + if eval(&app, &target, "2+2")? != "4" { + return Err(format!("reparent/layout circular wait at cycle {cycle}")); + } + } + result.push(json!({"test":"reparent_with_pending_layout_12_cycles","pass":true})); + crate::sessions::activate(&app, &label); crate::sessions::open_panel(&app, Some("main")).map_err(|e| e.to_string())?; std::thread::sleep(Duration::from_secs(2)); crate::dlog::log("smoke: local_panel_same_window"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d3a53fa..29eb166 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.7", + "version": "0.6.9-local.8", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From 56dd1c70706b464e3708d8e2fe0d724d12fcc3d9 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Tue, 20 Oct 2026 18:11:32 -0300 Subject: [PATCH 15/22] Preserve main account when closing detached window --- scripts/ui-audit.template.cjs | 8 +++- settings-ui/main.js | 5 +++ settings-ui/style.css | 8 +++- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 13 ++++-- src-tauri/src/sessions.rs | 75 +++++++++++++++++++++++++++++++++-- src-tauri/src/settings.rs | 36 +++++++++++++++-- src-tauri/src/smoke.rs | 21 ++++++++++ src-tauri/tauri.conf.json | 2 +- 10 files changed, 157 insertions(+), 15 deletions(-) diff --git a/scripts/ui-audit.template.cjs b/scripts/ui-audit.template.cjs index 6a4386f..e989fa3 100644 --- a/scripts/ui-audit.template.cjs +++ b/scripts/ui-audit.template.cjs @@ -12,7 +12,13 @@ async(page)=>{ await page.emulateMedia({colorScheme:scheme});await page.setViewportSize({width,height}); for(const name of names){await page.getByRole('link',{name,exact:true}).click();check('panel_'+scheme+'_'+width+'_'+name,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth&&Array.from(document.querySelectorAll('.prefs input,.prefs select,.prefs button')).filter(e=>e.getClientRects().length).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1;})));} await page.getByRole('link',{name:'Contas',exact:true}).click(); - const trigger=page.getByRole('button',{name:'Opções de Matheus',exact:true});await trigger.click(); + const trigger=page.getByRole('button',{name:'Opções de Matheus',exact:true}); + await trigger.hover(); + await page.waitForTimeout(160); + check('account_menu_hover_'+scheme+'_'+width,await trigger.evaluate(e=>getComputedStyle(e).backgroundColor!=='rgba(0, 0, 0, 0)')); + await trigger.click(); + await page.waitForFunction(()=>document.querySelector('[aria-label="Opções de Matheus"]').getAttribute('aria-expanded')==='true'); + check('account_menu_open_feedback_'+scheme+'_'+width,await trigger.evaluate(e=>getComputedStyle(e).backgroundColor!=='rgba(0, 0, 0, 0)')); check('menu_anchored_'+scheme+'_'+width,await page.evaluate(()=>{const b=document.querySelector('[aria-label="Opções de Matheus"]').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();const gap=Math.min(Math.abs(m.top-b.bottom-6),Math.abs(b.top-m.bottom-6));return m.left>=12&&m.right<=innerWidth-12+1&&m.top>=12&&m.bottom<=innerHeight-12+1&&Math.abs(m.right-b.right)<=1&&gap<=1;})); await page.keyboard.press('End');check('menu_keyboard_'+scheme+'_'+width,await page.evaluate(()=>document.activeElement===document.querySelector('#account-actions button:last-child'))); await page.keyboard.press('Escape');check('menu_focus_restore_'+scheme+'_'+width,await trigger.evaluate(e=>document.activeElement===e)); diff --git a/settings-ui/main.js b/settings-ui/main.js index eb1301b..3242bac 100644 --- a/settings-ui/main.js +++ b/settings-ui/main.js @@ -17,6 +17,11 @@ function selectNearestZoom(value) { async function load() { const s = await invoke("get_settings"); document.getElementById("menu_bar_icon_row").hidden = !IS_MAC; + if (/Win/.test(navigator.platform || navigator.userAgent)) { + const startupRow = document.getElementById("start_minimized").closest("label"); + startupRow.querySelector(".row-title").textContent = "Iniciar minimizado com o Windows"; + startupRow.querySelector(".row-subtitle").textContent = "Vale apenas ao entrar no Windows. Ao abrir pelo atalho, a janela sempre aparece."; + } for (const f of BOOLS) { const el = document.getElementById(f); if (el) el.checked = !!s[f]; diff --git a/settings-ui/style.css b/settings-ui/style.css index 5194b59..e1d6c7f 100644 --- a/settings-ui/style.css +++ b/settings-ui/style.css @@ -3,6 +3,7 @@ color-scheme: light dark; --bg: #fff; --card: #fff; --panel: #f7f8fa; --fg: #0a0a0a; --dim: #626262; --green: #1daa61; --on-green: #0a0a0a; --border: #e9ebeb; + --control-hover: #00000014; --control-active: #00000024; --line: #e9ebeb; --hover: #f1f2f2; --switch-off: #b8bdbb; --danger: #c52c3a; --warn: #996600; } @@ -11,6 +12,7 @@ color-scheme: dark; --bg: #161717; --card: #232626; --panel: #1d1f1f; --fg: #fafafa; --dim: #a1a5a5; --green: #21c063; --on-green: #0a0a0a; --border: #2c3030; + --control-hover: #ffffff14; --control-active: #ffffff24; --line: #2c3030; --hover: #2a2d2d; --switch-off: #626767; --danger: #ff707a; --warn: #e3b341; } @@ -19,6 +21,7 @@ color-scheme: dark; --bg: #161717; --card: #232626; --panel: #1d1f1f; --fg: #fafafa; --dim: #a1a5a5; --green: #21c063; --on-green: #0a0a0a; --border: #2c3030; + --control-hover: #ffffff14; --control-active: #ffffff24; --line: #2c3030; --hover: #2a2d2d; --switch-off: #626767; --danger: #ff707a; --warn: #e3b341; } @@ -77,7 +80,10 @@ select option { background: var(--bg); color: var(--fg); } .account-open { display: flex; flex: 1; min-width: 0; align-items: flex-start; flex-direction: column; gap: 5px; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; text-align: left; } .acct-name { font-size: 16px; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .acct-subtitle { font-size: 13px; color: var(--dim); font-weight: 400; } -.account-more { font-size: 26px; line-height: 1; width: 36px; height: 36px; border-radius: 50%; padding: 0; color: var(--dim); background: transparent; } +.account-more { display: grid; place-items: center; flex: 0 0 40px; font-size: 26px; line-height: 1; width: 40px; height: 40px; border-radius: 50%; padding: 0; color: var(--dim); background: transparent; transition: background-color .12s, color .12s; } +.account-more:hover:not(:disabled), .account-more:focus-visible, .account-more[aria-expanded="true"] { background: var(--control-hover); color: var(--fg); filter: none; } +.account-more:active:not(:disabled) { background: var(--control-active); } +@media(prefers-reduced-motion:reduce) { .account-more { transition:none; } } .acct-unread { background: var(--green); color: var(--on-green); font-size: 12px; padding: 2px 7px; border-radius: 10px; } .account-actions { position: fixed; inset: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--fg); box-shadow: 0 2px 12px #0004; padding: 6px 0; width: 245px; max-width:calc(100vw - 24px); margin: 0; max-height: calc(100vh - 24px);overflow-y:auto;overscroll-behavior:contain; } .account-actions button { display: block; width: 100%; border-radius: 0; background: transparent; color: inherit; text-align: left; font-size: 14px; font-weight: 400; padding: 12px 22px; } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e47083..91213cd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.6.9-local.8" +version = "0.6.9-local.10" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 990f780..5202b71 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.6.9-local.8" +version = "0.6.9-local.10" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8f38c4..8353bdd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,9 +74,10 @@ pub fn run() { // A whatsapp:// (or wa.me) link handed to a second launch by // the OS scheme handler (issue #23): open it in the running copy. links::open_from_outside(app, &link); - } else if args.iter().any(|a| a == "--show") - || !args.iter().any(|a| a == "--minimized") - { + } else if !settings::start_hidden( + cfg!(windows) && settings::load(app).start_minimized, + &args, + ) { window::show_main(app); } }, @@ -132,7 +133,11 @@ pub fn run() { ) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, - Some(vec!["--minimized"]), + Some(vec![if cfg!(windows) { + "--autostart" + } else { + "--minimized" + }]), )); } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index d4879cd..0a25a54 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -474,7 +474,7 @@ pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { let a = a.clone(); let id = account.clone(); tauri::async_runtime::spawn_blocking(move || { - let _ = attach(&a, &id); + let _ = attach_after_window_close(&a, &id); }); } _ => {} @@ -485,15 +485,29 @@ pub fn detach(app: &AppHandle, id: &str) -> Result<(), String> { } pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { + attach_inner(app, id, true) +} + +/// Closing a detached window returns its live WebView to the main window while +/// preserving the account that was already selected there. Explicitly choosing +/// “Reintegrar à janela” still activates the returned account via [`attach`]. +fn attach_after_window_close(app: &AppHandle, id: &str) -> Result<(), String> { + attach_inner(app, id, false) +} + +fn attach_inner(app: &AppHandle, id: &str, activate_returned_account: bool) -> Result<(), String> { let _health = crate::ui_health::enter("attach account"); crate::lock::require_unlocked(app)?; let label = accounts::window_label(id); let v = app.get_webview(&label).ok_or("Conta não está aberta")?; let old = v.window(); if old.label() == "main" { - activate(app, &label); + if activate_returned_account { + activate(app, &label); + } return Ok(()); } + let selected_before_close = state(app).main_active.lock().unwrap().clone(); if let Some(panel) = app.get_webview("settings") { if panel.window().label() == old.label() { set_view_visible(app, &panel, false); @@ -507,10 +521,65 @@ pub fn attach(app: &AppHandle, id: &str) -> Result<(), String> { reparent_on_ui(app, &v, &initialize(app, true).map_err(|e| e.to_string())?)?; state(app).menus_hidden.lock().unwrap().remove(old.label()); let _ = old.destroy(); - activate(app, &label); + if activate_returned_account { + activate(app, &label); + } else { + restore_main_after_detached_close(app, &label, selected_before_close); + } Ok(()) } +fn restore_main_after_detached_close( + app: &AppHandle, + returned_label: &str, + previously_selected: Option, +) { + let on_main = |label: &str| { + app.get_webview(label) + .is_some_and(|view| view.window().label() == "main") + }; + let selected = previously_selected + .filter(|label| label != returned_label && on_main(label)) + .or_else(|| { + accounts::load(app) + .accounts + .into_iter() + .find_map(|account| { + let label = accounts::window_label(&account.id); + (label != returned_label && on_main(&label)).then_some(label) + }) + }) + .unwrap_or_else(|| returned_label.to_string()); + + *state(app).main_active.lock().unwrap() = Some(selected.clone()); + *app.state::().lock().unwrap() = selected.clone(); + sync_menu(app, "main"); + if let (Some(window), Some(view)) = (app.get_window("main"), app.get_webview(&selected)) { + let name = accounts::id_from_label(&selected) + .and_then(|id| { + accounts::load(app) + .accounts + .into_iter() + .find(|a| a.id == id) + }) + .map(|account| account.name) + .unwrap_or_else(|| "WhatsApp".into()); + let _ = window.set_title(&format!("whatRust — {name}")); + let _ = window.show(); + let _ = window.unminimize(); + layout(app, "main"); + let _ = window.set_focus(); + if state(app).panel_parent.lock().unwrap().as_deref() == Some("main") { + if let Some(panel) = app.get_webview("settings") { + let _ = panel.set_focus(); + } + } else { + let _ = view.set_focus(); + } + } + crate::integration::broadcast(app); +} + fn choose_remaining(app: &AppHandle, exclude: Option<&str>) { let current = state(app).main_active.lock().unwrap().clone(); if let Some(label) = current { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 49ac101..73a6b12 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -39,7 +39,21 @@ impl Default for Settings { } pub fn start_hidden(preference: bool, args: &[String]) -> bool { - !args.iter().any(|a| a == "--show") && (preference || args.iter().any(|a| a == "--minimized")) + if args.iter().any(|a| a == "--show") { + return false; + } + if cfg!(windows) { + // A manual launch from Start or a desktop shortcut has no startup flag + // and should always show the window. Apply the preference only to OS + // startup. Accept --minimized for older Run entries, but still respect + // the saved preference; new entries use the explicit flag below. + preference + && args + .iter() + .any(|a| a == "--autostart" || a == "--minimized") + } else { + preference || args.iter().any(|a| a == "--minimized") + } } /// Zoom bounds. The settings UI only offers four presets inside this range; the @@ -320,8 +334,11 @@ mod tests { #[test] fn explicit_foreground_launch_overrides_background_preference() { let args = |items: &[&str]| items.iter().map(|s| s.to_string()).collect::>(); - assert!(super::start_hidden(true, &args(&[]))); - assert!(super::start_hidden(false, &args(&["--minimized"]))); + assert_eq!(super::start_hidden(true, &args(&[])), !cfg!(windows)); + assert_eq!( + super::start_hidden(false, &args(&["--minimized"])), + !cfg!(windows) + ); assert!(!super::start_hidden(true, &args(&["--show"]))); assert!(!super::start_hidden( true, @@ -329,4 +346,17 @@ mod tests { )); assert!(!super::start_hidden(false, &args(&[]))); } + + #[cfg(windows)] + #[test] + fn start_minimized_only_affects_system_autostart() { + assert!(!super::start_hidden(true, &[])); + assert!(super::start_hidden(true, &["--autostart".into()])); + assert!(!super::start_hidden(false, &["--autostart".into()])); + assert!(!super::start_hidden(false, &["--minimized".into()])); + assert!(!super::start_hidden( + true, + &["--autostart".into(), "--show".into()] + )); + } } diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index 53c9c52..f63d3e2 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -127,6 +127,27 @@ pub fn start(app: &AppHandle) { crate::sessions::attach(&app, &a.id)?; crate::dlog::log("smoke: reattach_preserves_view"); result.push(json!({"test":"reattach_preserves_view","pass":app.get_webview(&label).unwrap().window().label()=="main"&&eval(&app,&label,"window.__whatrustSmokeToken==='same-live-view'")?=="true"})); + let second_label = crate::accounts::window_label(&b.id); + crate::sessions::activate(&app, &label); + eval( + &app, + &second_label, + "window.__whatrustCloseSmokeToken='preserve-close-view'", + )?; + crate::sessions::detach(&app, &b.id)?; + app.get_window(&format!("detached-{}", b.id)) + .ok_or("missing detached window")? + .close() + .map_err(|e| e.to_string())?; + let returned_to_main_without_stealing_selection = wait_until(|| { + crate::sessions::selected(&app, "main").as_deref() == Some(label.as_str()) + && app + .get_webview(&second_label) + .is_some_and(|view| view.window().label() == "main") + && visible(&app, &label).unwrap_or(false) + && !visible(&app, &second_label).unwrap_or(true) + }); + result.push(json!({"test":"closing_detached_window_preserves_main_account","pass":returned_to_main_without_stealing_selection&&eval(&app,&second_label,"window.__whatrustCloseSmokeToken==='preserve-close-view'")?=="true"})); for cycle in 0..12 { crate::sessions::schedule_layout(&app, "main"); crate::sessions::detach(&app, &b.id)?; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 29eb166..83e6493 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.6.9-local.8", + "version": "0.6.9-local.10", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui" From 1533d966a76ed4f7a21a010c0011c1da7b7e3a36 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Thu, 22 Oct 2026 19:27:49 -0300 Subject: [PATCH 16/22] Persist config JSON atomically and fail closed on a corrupt app lock Add jsonfile.rs: write to a temp file, keep the last valid copy as .bak, rename into place, and recover from .bak when the main file is unreadable. settings, accounts and app-lock now use it instead of std::fs::write, and no longer panic on serialization errors. - accounts.json unreadable: keep a .corrupt copy before falling back - app-lock.json unreadable with no backup: keep the lock on instead of silently disabling it - factory reset also removes .bak/.corrupt/.tmp files - remove_account repoints ActiveAccount; delete_profile retries while WebView2 releases files Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/accounts.rs | 34 +++++-- src-tauri/src/applock.rs | 60 +++++++++--- src-tauri/src/commands.rs | 13 +++ src-tauri/src/jsonfile.rs | 198 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/settings.rs | 21 ++-- 6 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 src-tauri/src/jsonfile.rs diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index a967dd1..9414c41 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -168,7 +168,16 @@ pub fn profile_dir(app: &AppHandle, id: &str) -> tauri::Result { #[cfg(not(target_os = "macos"))] pub fn delete_profile(app: &AppHandle, id: &str) { if let Ok(dir) = profile_dir(app, id) { - let _ = std::fs::remove_dir_all(dir); + // WebView2 can still hold files for a moment after its window is + // destroyed, so retry briefly instead of leaving an orphaned profile. + for attempt in 0..5 { + if std::fs::remove_dir_all(&dir).is_ok() || !dir.exists() { + return; + } + if attempt < 4 { + std::thread::sleep(std::time::Duration::from_millis(200)); + } + } } } @@ -189,12 +198,20 @@ fn accounts_path(app: &AppHandle) -> tauri::Result { } pub fn load(app: &AppHandle) -> AccountsFile { - accounts_path(app) - .ok() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .map(ensure_account) - .unwrap_or_default() + use crate::jsonfile::Loaded; + let Ok(path) = accounts_path(app) else { + return AccountsFile::default(); + }; + match crate::jsonfile::read::(&path) { + Loaded::Ok(f) | Loaded::Recovered(f) => ensure_account(f), + Loaded::Missing => AccountsFile::default(), + Loaded::Corrupt => { + // Defaults will overwrite this on the next save; keep a copy so the + // account list (and its profile ids) can be restored by hand. + crate::jsonfile::preserve_corrupt(&path); + AccountsFile::default() + } + } } /// An empty configuration still needs one profile to display WhatsApp's login. @@ -227,8 +244,7 @@ mod empty_account_tests { pub fn save(app: &AppHandle, f: &AccountsFile) -> tauri::Result<()> { let path = accounts_path(app)?; - let json = serde_json::to_string_pretty(f).expect("serialize accounts"); - std::fs::write(path, json)?; + crate::jsonfile::save(&path, f)?; Ok(()) } diff --git a/src-tauri/src/applock.rs b/src-tauri/src/applock.rs index 54a477c..5e45ae7 100644 --- a/src-tauri/src/applock.rs +++ b/src-tauri/src/applock.rs @@ -54,18 +54,36 @@ fn config_path(app: &AppHandle) -> tauri::Result { Ok(dir.join("app-lock.json")) } +/// Stored in place of the hash when `app-lock.json` exists but cannot be read. +/// It never parses as a PHC string, so `verify_password` always fails. +const CORRUPT_PHC: &str = "!corrupt"; + +/// Fail closed: an unreadable lock file (with no usable backup) keeps the lock +/// ON with a hash nobody can match, instead of silently turning it off. The +/// forgot-password reset is then the way out. +fn corrupt_config() -> AppLockConfig { + AppLockConfig { + enabled: true, + password_phc: Some(CORRUPT_PHC.to_string()), + ..AppLockConfig::default() + } +} + pub fn load(app: &AppHandle) -> AppLockConfig { - config_path(app) - .ok() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() + use crate::jsonfile::Loaded; + let Ok(path) = config_path(app) else { + return AppLockConfig::default(); + }; + match crate::jsonfile::read::(&path) { + Loaded::Ok(c) | Loaded::Recovered(c) => c, + Loaded::Missing => AppLockConfig::default(), + Loaded::Corrupt => corrupt_config(), + } } pub fn save(app: &AppHandle, c: &AppLockConfig) -> tauri::Result<()> { let path = config_path(app)?; - let json = serde_json::to_string_pretty(c).expect("serialize app-lock config"); - std::fs::write(path, json)?; + crate::jsonfile::save(&path, c)?; Ok(()) } @@ -101,11 +119,13 @@ pub fn verify_password(password: &str, phc: &str) -> bool { /// holds BOTH the default account's webview store (directly under it, on Linux/Windows) /// and the per-account `profiles/` dirs — removing it logs every account out. fn reset_targets(config_dir: &Path, data_dir: &Path) -> Vec { - vec![ - config_dir.join("accounts.json"), - config_dir.join("app-lock.json"), - data_dir.to_path_buf(), - ] + let accounts = config_dir.join("accounts.json"); + let lock = config_dir.join("app-lock.json"); + // The backups must go too, or a later read could bring the old lock back. + let mut v = crate::jsonfile::sidecar_paths(&accounts); + v.extend(crate::jsonfile::sidecar_paths(&lock)); + v.extend([accounts, lock, data_dir.to_path_buf()]); + v } /// Request cleanup on the next launch, before any profile is opened. @@ -135,7 +155,10 @@ fn complete_reset_paths(config: &Path, data: &Path) -> std::io::Result { if data.exists() { std::fs::remove_dir_all(data)?; } - for p in reset_targets(config, data).into_iter().take(2) { + for p in reset_targets(config, data) + .into_iter() + .filter(|p| p.as_path() != data) + { match std::fs::remove_file(p) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -192,6 +215,15 @@ mod tests { assert_eq!(c, AppLockConfig::default()); } + #[test] + fn corrupt_config_is_active_and_unmatchable() { + let c = corrupt_config(); + assert!(c.is_active()); + assert!(!c.biometric_enabled); + assert!(!verify_password("", CORRUPT_PHC)); + assert!(!verify_password("!corrupt", CORRUPT_PHC)); + } + #[test] fn roundtrip() { let c = AppLockConfig { @@ -230,6 +262,8 @@ mod tests { let t = super::reset_targets(cfg, data); assert!(t.contains(&cfg.join("accounts.json"))); assert!(t.contains(&cfg.join("app-lock.json"))); + assert!(t.contains(&cfg.join("app-lock.json.bak"))); + assert!(t.contains(&cfg.join("accounts.json.bak"))); // The data root must be a target — this is what logs the DEFAULT account out. assert!(t.contains(&data.to_path_buf())); } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index eb92fef..abb4ddf 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -358,6 +358,19 @@ pub async fn remove_account( } accounts::delete_profile(&app, &removed.id); + // If the removed account was the active one, point at a surviving account + // so toggle/show don't target a window that no longer exists. (On Windows + // the active view is tracked by sessions.rs instead.) + #[cfg(not(windows))] + if let Some(active) = app.try_state::() { + let mut label = active.lock().unwrap_or_else(|e| e.into_inner()); + if *label == accounts::window_label(&removed.id) { + if let Some(next) = accounts::startup_account(&f) { + *label = accounts::window_label(&next.id); + } + } + } + // Recompute the aggregate badge and the menu. let total = { let state = app.state::(); diff --git a/src-tauri/src/jsonfile.rs b/src-tauri/src/jsonfile.rs new file mode 100644 index 0000000..a637165 --- /dev/null +++ b/src-tauri/src/jsonfile.rs @@ -0,0 +1,198 @@ +//! Crash-safe JSON persistence shared by `settings`, `accounts` and `applock`. +//! +//! A plain `std::fs::write` truncates the file first, so a crash or power loss +//! mid-write leaves it empty or half-written. `write_atomic` writes a temp file, +//! flushes it, keeps the last good copy as `.bak`, then renames over the +//! target. `read` falls back to that `.bak` when the main file does not parse. + +use serde::{de::DeserializeOwned, Serialize}; +use std::ffi::OsString; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Outcome of reading a JSON file. +#[derive(Debug, PartialEq)] +pub enum Loaded { + /// No file (first run). + Missing, + Ok(T), + /// The main file was unreadable; the value came from `.bak`. + Recovered(T), + /// The file exists but neither it nor its backup parses. + Corrupt, +} + +fn sibling(path: &Path, suffix: &str) -> PathBuf { + let mut name: OsString = path.file_name().unwrap_or_default().to_owned(); + name.push(suffix); + path.with_file_name(name) +} + +pub fn backup_path(path: &Path) -> PathBuf { + sibling(path, ".bak") +} + +fn parse(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +pub fn read(path: &Path) -> Loaded { + let bak = backup_path(path); + // A missing main file is deliberate (first run, or a factory reset), so the + // backup must not resurrect it. The rename in `write_atomic` leaves no + // window in which the main file is absent. + if !path.exists() { + return Loaded::Missing; + } + if let Some(v) = parse(path) { + return Loaded::Ok(v); + } + match parse(&bak) { + Some(v) => Loaded::Recovered(v), + None => Loaded::Corrupt, + } +} + +/// Files written next to `path` that a factory reset must remove as well. +pub fn sidecar_paths(path: &Path) -> Vec { + vec![ + backup_path(path), + sibling(path, ".corrupt"), + sibling(path, ".tmp"), + ] +} + +/// Atomically replace `path` with `contents`, keeping the previous valid +/// contents as `.bak`. +pub fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> { + let tmp = sibling(path, ".tmp"); + let result = (|| { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(contents)?; + f.sync_all()?; + drop(f); + // Only a file that still parses may replace the backup, so a corrupt + // main file never overwrites the last good copy. + if let Ok(old) = std::fs::read(path) { + if serde_json::from_slice::(&old).is_ok() { + let _ = std::fs::write(backup_path(path), old); + } + } + std::fs::rename(&tmp, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +pub fn save(path: &Path, value: &T) -> std::io::Result<()> { + let json = serde_json::to_vec_pretty(value) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + write_atomic(path, &json) +} + +/// Keep a copy of an unreadable file next to it (once), so a later save of +/// defaults cannot destroy the only evidence of what was there. +pub fn preserve_corrupt(path: &Path) { + let dest = sibling(path, ".corrupt"); + if !dest.exists() { + let _ = std::fs::copy(path, dest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct V { + n: u32, + } + + fn scratch(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("whatrust-jsonfile-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("f.json") + } + + #[test] + fn missing_file_is_missing() { + assert_eq!(read::(&scratch("missing")), Loaded::Missing); + } + + #[test] + fn save_then_read_roundtrips_and_leaves_no_tmp() { + let p = scratch("roundtrip"); + save(&p, &V { n: 1 }).unwrap(); + assert_eq!(read::(&p), Loaded::Ok(V { n: 1 })); + assert!(!sibling(&p, ".tmp").exists()); + } + + #[test] + fn second_save_keeps_previous_as_backup() { + let p = scratch("bak"); + save(&p, &V { n: 1 }).unwrap(); + save(&p, &V { n: 2 }).unwrap(); + assert_eq!(read::(&p), Loaded::Ok(V { n: 2 })); + assert_eq!(parse::(&backup_path(&p)), Some(V { n: 1 })); + } + + #[test] + fn truncated_file_recovers_from_backup() { + let p = scratch("recover"); + save(&p, &V { n: 1 }).unwrap(); + save(&p, &V { n: 2 }).unwrap(); + std::fs::write(&p, b"{\"n\": ").unwrap(); + assert_eq!(read::(&p), Loaded::Recovered(V { n: 1 })); + } + + #[test] + fn corrupt_main_does_not_overwrite_good_backup() { + let p = scratch("keepbak"); + save(&p, &V { n: 1 }).unwrap(); + save(&p, &V { n: 2 }).unwrap(); + std::fs::write(&p, b"").unwrap(); + save(&p, &V { n: 3 }).unwrap(); + assert_eq!(read::(&p), Loaded::Ok(V { n: 3 })); + assert_eq!(parse::(&backup_path(&p)), Some(V { n: 1 })); + } + + #[test] + fn corrupt_without_backup_is_corrupt() { + let p = scratch("corrupt"); + std::fs::write(&p, b"not json").unwrap(); + assert_eq!(read::(&p), Loaded::Corrupt); + } + + #[test] + fn deleted_main_is_not_resurrected_from_backup() { + let p = scratch("nomain"); + save(&p, &V { n: 1 }).unwrap(); + save(&p, &V { n: 2 }).unwrap(); + std::fs::remove_file(&p).unwrap(); + assert_eq!(read::(&p), Loaded::Missing); + } + + #[test] + fn sidecar_paths_lists_bak_and_corrupt() { + let p = Path::new("/x/accounts.json"); + let s = sidecar_paths(p); + assert!(s.contains(&PathBuf::from("/x/accounts.json.bak"))); + assert!(s.contains(&PathBuf::from("/x/accounts.json.corrupt"))); + } + + #[test] + fn preserve_corrupt_copies_once() { + let p = scratch("preserve"); + std::fs::write(&p, b"first").unwrap(); + preserve_corrupt(&p); + std::fs::write(&p, b"second").unwrap(); + preserve_corrupt(&p); + assert_eq!(std::fs::read(sibling(&p, ".corrupt")).unwrap(), b"first"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8353bdd..8a468fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod commands; mod dlog; mod downloads; mod integration; +mod jsonfile; mod links; mod lock; mod notif_icon; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 73a6b12..e5abe71 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -144,18 +144,23 @@ fn settings_path(app: &AppHandle) -> tauri::Result { } pub fn load(app: &AppHandle) -> Settings { - settings_path(app) - .ok() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str::(&s).ok()) - .map(Settings::sanitized) - .unwrap_or_default() + use crate::jsonfile::Loaded; + let Ok(path) = settings_path(app) else { + return Settings::default(); + }; + match crate::jsonfile::read::(&path) { + Loaded::Ok(s) | Loaded::Recovered(s) => s.sanitized(), + Loaded::Missing => Settings::default(), + Loaded::Corrupt => { + crate::jsonfile::preserve_corrupt(&path); + Settings::default() + } + } } pub fn save(app: &AppHandle, s: &Settings) -> tauri::Result<()> { let path = settings_path(app)?; - let json = serde_json::to_string_pretty(s).expect("serialize settings"); - std::fs::write(path, json)?; + crate::jsonfile::save(&path, s)?; Ok(()) } From ed63419fe5d7c7c2df9b4f187880185b0fed0ad2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Thu, 22 Oct 2026 19:44:53 -0300 Subject: [PATCH 17/22] Harden unlock, downloads and webview permissions; tighten CI - unlock_password: exponential delay after 3 wrong guesses, Argon2 off the UI thread; password minimum 6; reset requires explicit confirmation - downloads: open only an allowlist of file types, others reveal in folder - webview permissions granted to https://web.whatsapp.com only - CSP for the local UI; bounded dlog - poison-tolerant mutex helper; atomic lock_now test-and-set - JSON files tolerate a UTF-8 BOM - CI: fmt + clippy, actions pinned by SHA; install.ps1 verifies SHA-256 - a11y: focus-visible on lock screen, aria-labelledby on dialogs Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/check.yml | 17 +++- .github/workflows/release.yml | 10 +-- .github/workflows/smoke-windows.yml | 2 +- install.ps1 | 13 +++ settings-ui/dialog.js | 2 + settings-ui/lock.js | 2 +- src-tauri/src/commands.rs | 89 +++++++++++++-------- src-tauri/src/dlog.rs | 9 +++ src-tauri/src/downloads.rs | 112 ++++++-------------------- src-tauri/src/jsonfile.rs | 11 ++- src-tauri/src/lib.rs | 2 +- src-tauri/src/links.rs | 2 +- src-tauri/src/lock.rs | 118 +++++++++++++++++++++++++--- src-tauri/src/notify.rs | 20 ++--- src-tauri/src/sessions.rs | 56 ++++++------- src-tauri/src/smoke.rs | 22 +++--- src-tauri/src/tray.rs | 4 +- src-tauri/src/window.rs | 57 +++++++++++--- src-tauri/tauri.conf.json | 2 +- 19 files changed, 345 insertions(+), 205 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dc31da5..711102c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,7 +18,7 @@ jobs: - platform: macos-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Linux deps if: matrix.platform == 'ubuntu-22.04' @@ -27,12 +27,21 @@ jobs: sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ libxdo-dev libdbus-1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libhunspell-dev patchelf - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 with: workspaces: "./src-tauri -> target" + - name: cargo fmt + if: matrix.platform == 'ubuntu-22.04' + working-directory: src-tauri + run: cargo fmt --check + + - name: cargo clippy + working-directory: src-tauri + run: cargo clippy --locked -- -D warnings + - name: cargo build (compile check) working-directory: src-tauri run: cargo build --locked @@ -45,7 +54,7 @@ jobs: working-directory: src-tauri run: cargo test --locked - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35c6983..5c4d33f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Resolve the release tag id: tag env: @@ -59,7 +59,7 @@ jobs: - platform: macos-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Linux deps if: matrix.platform == 'ubuntu-22.04' @@ -69,9 +69,9 @@ jobs: libxdo-dev libdbus-1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libhunspell-dev patchelf \ desktop-file-utils - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 with: workspaces: "./src-tauri -> target" @@ -88,7 +88,7 @@ jobs: echo "WHATRUST_NOTES_EOF" } >> "$GITHUB_OUTPUT" - - uses: tauri-apps/tauri-action@v0 + - uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/smoke-windows.yml b/.github/workflows/smoke-windows.yml index d3d7b1d..cb70d40 100644 --- a/.github/workflows/smoke-windows.yml +++ b/.github/workflows/smoke-windows.yml @@ -109,7 +109,7 @@ jobs: shell: pwsh run: Get-Content "$env:LOCALAPPDATA\whatRust\logs\whatrust.log" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: smoke-windows-screenshots diff --git a/install.ps1 b/install.ps1 index 01457db..539f7c5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,6 +25,19 @@ $out = Join-Path $env:TEMP $asset.name Write-Host "Downloading $($asset.name)..." Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $out -UseBasicParsing +# GitHub publishes a SHA-256 digest for each release asset; refuse a mismatch. +$expected = if ($asset.digest -match '^sha256:(?[0-9a-fA-F]{64})$') { $Matches.h } else { $null } +$actual = (Get-FileHash -Algorithm SHA256 -Path $out).Hash +if ($expected) { + if ($actual -ne $expected) { + Remove-Item -Force $out + throw "Checksum mismatch for $($asset.name): expected $expected, got $actual. Aborting." + } + Write-Host "SHA-256 verified: $actual" +} else { + Write-Warning "GitHub published no digest for this asset; SHA-256 is $actual. Compare it with the release notes before trusting the installer." +} + Write-Host "Launching installer..." if ($asset.name -match '\.msi$') { Start-Process msiexec.exe -ArgumentList "/i `"$out`"" -Wait diff --git a/settings-ui/dialog.js b/settings-ui/dialog.js index 9162ba5..6fd2fc7 100644 --- a/settings-ui/dialog.js +++ b/settings-ui/dialog.js @@ -70,6 +70,8 @@ if (opts.title) { var h = el("h2", null, opts.title); + h.id = "dlg-title"; + box.setAttribute("aria-labelledby", "dlg-title"); box.appendChild(h); } if (message) box.appendChild(el("p", "dlg-message", message)); diff --git a/settings-ui/lock.js b/settings-ui/lock.js index a7abde2..0845767 100644 --- a/settings-ui/lock.js +++ b/settings-ui/lock.js @@ -62,7 +62,7 @@ async function init() { ); if (!ok) return; try { - await invoke("reset_app_lock"); + await invoke("reset_app_lock", { confirmed: true }); } catch (err) { errEl.textContent = "Não foi possível redefinir o aplicativo. Tente novamente."; } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index abb4ddf..45dfe6c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -103,7 +103,7 @@ pub fn set_unread( // to avoid a deadlock. let (previous, total) = { let state = app.state::(); - let mut map = state.lock().unwrap(); + let mut map = crate::lock::guard(&state); let previous = map.insert(id.to_string(), count); (previous, accounts::aggregate_unread(&map)) }; @@ -181,7 +181,7 @@ pub struct AccountView { fn account_views(app: &tauri::AppHandle) -> Vec { let f = accounts::load(app); let map = app.state::(); - let map = map.lock().unwrap(); + let map = crate::lock::guard(&map); let mut views: Vec = f .accounts .iter() @@ -247,7 +247,7 @@ pub async fn switch_account( crate::window::show_account(&app, &accounts::window_label(&acct.id)); #[cfg(not(windows))] if let Some(active) = app.try_state::() { - *active.lock().unwrap() = accounts::window_label(&acct.id); + *crate::lock::guard(&active) = accounts::window_label(&acct.id); } Ok(()) } @@ -353,7 +353,7 @@ pub async fn remove_account( // Drop the per-account unread count. { let state = app.state::(); - let mut map = state.lock().unwrap(); + let mut map = crate::lock::guard(&state); map.remove(&removed.id); } accounts::delete_profile(&app, &removed.id); @@ -374,7 +374,7 @@ pub async fn remove_account( // Recompute the aggregate badge and the menu. let total = { let state = app.state::(); - let map = state.lock().unwrap(); + let map = crate::lock::guard(&state); accounts::aggregate_unread(&map) }; crate::tray::update_badge(&app, total); @@ -442,7 +442,7 @@ pub async fn open_account( // Track as the active account. #[cfg(not(windows))] if let Some(active) = app.try_state::() { - *active.lock().unwrap() = accounts::window_label(&acct.id); + *crate::lock::guard(&active) = accounts::window_label(&acct.id); } Ok(()) } @@ -451,6 +451,19 @@ pub async fn open_account( // App-lock commands. // --------------------------------------------------------------------------- +/// Argon2 is deliberately slow; keep it off the thread that pumps UI events. +async fn hash_off_thread(password: String) -> Result { + tauri::async_runtime::spawn_blocking(move || applock::hash_password(&password)) + .await + .map_err(|e| e.to_string())? +} + +async fn verify_off_thread(password: String, phc: String) -> bool { + tauri::async_runtime::spawn_blocking(move || applock::verify_password(&password, &phc)) + .await + .unwrap_or(false) +} + #[derive(Debug, Clone, Serialize)] pub struct LockStatus { pub enabled: bool, @@ -491,7 +504,7 @@ pub fn get_lock_status( /// Enable the lock by setting the first password. Errors if already enabled (use /// `change_app_lock_password`). Runs only from an unlocked, local window. #[tauri::command] -pub fn set_app_lock_password( +pub async fn set_app_lock_password( window: tauri::Webview, app: tauri::AppHandle, new: String, @@ -505,13 +518,8 @@ pub fn set_app_lock_password( if c.is_active() { return Err("O bloqueio já está ativado.".into()); } - if new != confirm { - return Err("As senhas não coincidem.".into()); - } - if new.chars().count() < 4 { - return Err("A senha deve ter pelo menos 4 caracteres.".into()); - } - c.password_phc = Some(applock::hash_password(&new)?); + lock::validate_new_password(&new, &confirm)?; + c.password_phc = Some(hash_off_thread(new).await?); c.enabled = true; applock::save(&app, &c).map_err(|e| e.to_string())?; crate::tray::rebuild_menu(&app); @@ -520,7 +528,7 @@ pub fn set_app_lock_password( } #[tauri::command] -pub fn change_app_lock_password( +pub async fn change_app_lock_password( window: tauri::Webview, app: tauri::AppHandle, current: String, @@ -536,21 +544,16 @@ pub fn change_app_lock_password( .password_phc .clone() .ok_or("O bloqueio não está ativado.")?; - if !applock::verify_password(¤t, &phc) { + if !verify_off_thread(current, phc).await { return Err("A senha atual está incorreta.".into()); } - if new != confirm { - return Err("As senhas não coincidem.".into()); - } - if new.chars().count() < 4 { - return Err("A senha deve ter pelo menos 4 caracteres.".into()); - } - c.password_phc = Some(applock::hash_password(&new)?); + lock::validate_new_password(&new, &confirm)?; + c.password_phc = Some(hash_off_thread(new).await?); applock::save(&app, &c).map_err(|e| e.to_string()) } #[tauri::command] -pub fn disable_app_lock( +pub async fn disable_app_lock( window: tauri::Webview, app: tauri::AppHandle, current: String, @@ -564,7 +567,7 @@ pub fn disable_app_lock( .password_phc .clone() .ok_or("O bloqueio não está ativado.")?; - if !applock::verify_password(¤t, &phc) { + if !verify_off_thread(current, phc).await { return Err("A senha atual está incorreta.".into()); } c = AppLockConfig::default(); // fully reset: disabled, no hash, no biometric, default triggers @@ -638,8 +641,9 @@ pub async fn lock_app(window: tauri::Webview, app: tauri::AppHandle) -> Result<( } /// Unlock with the password. Lock-window only; works while locked (no require_unlocked). +/// Wrong guesses are rate-limited, and the Argon2 check runs off the UI thread. #[tauri::command] -pub fn unlock_password( +pub async fn unlock_password( window: tauri::Webview, app: tauri::AppHandle, password: String, @@ -649,16 +653,30 @@ pub fn unlock_password( } let c = applock::load(&app); let Some(phc) = c.password_phc else { - // No lock configured — treat as already unlocked. + // No lock configured: treat as already unlocked. lock::unlock(&app); return Ok(true); }; - if applock::verify_password(&password, &phc) { + { + let t = lock::guard(&lock::THROTTLE); + if let Some(Err(secs)) = t.as_ref().map(|t| t.check(std::time::Instant::now())) { + return Err(format!("Muitas tentativas. Tente de novo em {secs}s.")); + } + } + let ok = verify_off_thread(password, phc).await; + { + let mut t = lock::guard(&lock::THROTTLE); + let t = t.get_or_insert_with(Default::default); + if ok { + t.record_success(); + } else { + t.record_failure(std::time::Instant::now()); + } + } + if ok { lock::unlock(&app); - Ok(true) - } else { - Ok(false) } + Ok(ok) } /// Unlock with biometric. Lock-window only; works while locked. @@ -681,10 +699,17 @@ pub fn unlock_biometric(window: tauri::Webview, app: tauri::AppHandle) -> Result /// Forgot-password reset: wipe ALL account sessions + the app-lock config, then /// relaunch fresh (logged out, lock disabled). Lock-window only. #[tauri::command] -pub fn reset_app_lock(window: tauri::Webview, app: tauri::AppHandle) -> Result<(), String> { +pub fn reset_app_lock( + window: tauri::Webview, + app: tauri::AppHandle, + confirmed: bool, +) -> Result<(), String> { if !lock::is_lock_window(&window) { return Err("forbidden".into()); } + if !confirmed { + return Err("A redefinição precisa ser confirmada.".into()); + } crate::applock::request_reset(&app)?; app.restart(); } diff --git a/src-tauri/src/dlog.rs b/src-tauri/src/dlog.rs index 750d932..12bc874 100644 --- a/src-tauri/src/dlog.rs +++ b/src-tauri/src/dlog.rs @@ -14,6 +14,11 @@ use std::io::Write; use std::path::PathBuf; use std::sync::Mutex; +/// The page can reach `log`, so a session's log is capped instead of growing +/// until the disk is full. +const MAX_LOG_BYTES: u64 = 1024 * 1024; +const MAX_LINE_CHARS: usize = 500; + /// Serializes writes across the per-account notification threads. static LOCK: Mutex<()> = Mutex::new(()); @@ -65,6 +70,10 @@ pub fn log(msg: &str) { .map(|d| d.as_secs()) .unwrap_or(0); let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if std::fs::metadata(&p).is_ok_and(|m| m.len() >= MAX_LOG_BYTES) { + return; + } + let msg: String = msg.chars().take(MAX_LINE_CHARS).collect(); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) diff --git a/src-tauri/src/downloads.rs b/src-tauri/src/downloads.rs index 1dc73fa..18757ac 100644 --- a/src-tauri/src/downloads.rs +++ b/src-tauri/src/downloads.rs @@ -67,97 +67,28 @@ impl Downloads { } } -/// File types that run code when "opened". These are shown in their folder -/// instead, where the user can still choose to run them. +/// Extensions that are opened directly. Anything else (programs, scripts, +/// macro-enabled Office files, HTML/SVG, shortcuts, disk images, unknown types) +/// is shown in its folder instead, where the user can still choose to run it. +/// An allowlist, so a type nobody thought of fails safe. +const SAFE_TO_OPEN: &[&str] = &[ + // documents + "pdf", "txt", "md", "csv", "rtf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", + "odp", "vcf", // images + "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff", + // audio + "mp3", "m4a", "aac", "ogg", "oga", "opus", "wav", "flac", "amr", // video + "mp4", "m4v", "mov", "mkv", "webm", "avi", "3gp", // archives + "zip", "7z", "rar", "gz", "tar", +]; + +/// Whether "Open" must be replaced by "Show in folder" for this file. pub fn is_risky(path: &Path) -> bool { - const RISKY: &[&str] = &[ - // Windows - "exe", - "msi", - "msix", - "msixbundle", - "appx", - "appxbundle", - "appinstaller", - "bat", - "cmd", - "com", - "cpl", - "scr", - "pif", - "ps1", - "psm1", - "psd1", - "vbs", - "vbe", - "js", - "jse", - "wsf", - "wsh", - "hta", - "lnk", - "url", - "reg", - "dll", - "sys", - "inf", - "msc", - "msp", - "scf", - "gadget", - "application", - "settingcontent-ms", - "iso", - "img", - "vhd", - "vhdx", - "jar", - "jnlp", - // Linux - "sh", - "bash", - "zsh", - "csh", - "ksh", - "run", - "bin", - "appimage", - "desktop", - "deb", - "rpm", - "flatpak", - "flatpakref", - "flatpakrepo", - "snap", - "so", - "py", - "pl", - "rb", - "elf", - "out", - // macOS - "app", - "command", - "tool", - "dmg", - "pkg", - "mpkg", - "workflow", - "terminal", - "scpt", - "applescript", - "action", - "dylib", - "webloc", - "inetloc", - "fileloc", - ]; - // No extension: on Unix that can be an executable (or a script with a shebang). let Some(ext) = path.extension().and_then(|e| e.to_str()) else { - return !cfg!(windows); + return true; }; let ext = ext.to_ascii_lowercase(); - RISKY.contains(&ext.as_str()) + !SAFE_TO_OPEN.contains(&ext.as_str()) } /// The page call that shows the toast. Only the file *name* goes to the page. @@ -236,6 +167,13 @@ mod tests { "Disk.dmg", "a.command", "a.jar", + "macro.docm", + "macro.xlsm", + "page.html", + "image.svg", + "help.chm", + "noextension", + "weird.zzz", ] { assert!(is_risky(Path::new(risky)), "{risky}"); } diff --git a/src-tauri/src/jsonfile.rs b/src-tauri/src/jsonfile.rs index a637165..22ecedb 100644 --- a/src-tauri/src/jsonfile.rs +++ b/src-tauri/src/jsonfile.rs @@ -34,7 +34,9 @@ pub fn backup_path(path: &Path) -> PathBuf { fn parse(path: &Path) -> Option { let bytes = std::fs::read(path).ok()?; - serde_json::from_slice(&bytes).ok() + // Notepad and PowerShell 5 save UTF-8 with a BOM, which serde_json rejects. + let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes); + serde_json::from_slice(bytes).ok() } pub fn read(path: &Path) -> Loaded { @@ -162,6 +164,13 @@ mod tests { assert_eq!(parse::(&backup_path(&p)), Some(V { n: 1 })); } + #[test] + fn utf8_bom_is_tolerated() { + let p = scratch("bom"); + std::fs::write(&p, b"\xEF\xBB\xBF{\"n\": 7}").unwrap(); + assert_eq!(read::(&p), Loaded::Ok(V { n: 7 })); + } + #[test] fn corrupt_without_backup_is_corrupt() { let p = scratch("corrupt"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a468fd..97b74af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -247,7 +247,7 @@ pub fn run() { let startup_label = accounts::startup_account(&f).map(|account| accounts::window_label(&account.id)); if let Some(label) = &startup_label { - *handle.state::().lock().unwrap() = label.clone(); + *crate::lock::guard(&handle.state::()) = label.clone(); } // Build windows while Tauri is still on its UI thread. On Windows a diff --git a/src-tauri/src/links.rs b/src-tauri/src/links.rs index f7e0d3e..49801fb 100644 --- a/src-tauri/src/links.rs +++ b/src-tauri/src/links.rs @@ -89,7 +89,7 @@ pub fn open_from_outside(app: &AppHandle, raw: &str) { fn active_account_window(app: &AppHandle) -> Option { if let Some(active) = app.try_state::() { - let label = active.lock().unwrap().clone(); + let label = crate::lock::guard(&active).clone(); if let Some(w) = app.get_webview(&label) { return Some(w); } diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index dcb1490..7f2218c 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -1,7 +1,8 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, - Mutex, + Mutex, MutexGuard, }; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; /// Runtime lock state. `unlocked == false` means the app is locked. @@ -22,6 +23,67 @@ impl LockState { } } +/// Lock a mutex, recovering the data if another thread panicked while holding +/// it. Release builds use `panic = "abort"`, so a poisoned lock must never turn +/// into a second crash. +pub fn guard(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Delay after `failures` consecutive wrong passwords: free for the first +/// three, then 2s doubling up to a minute. +pub fn throttle_delay(failures: u32) -> Duration { + if failures < 3 { + return Duration::ZERO; + } + Duration::from_secs((1u64 << (failures - 2).min(6)).min(60)) +} + +/// Brute-force limiter for the lock screen. Lives in memory only: restarting the +/// app resets it, which still leaves each attempt costing an Argon2 hash. +#[derive(Default)] +pub struct UnlockThrottle { + failures: u32, + blocked_until: Option, +} + +impl UnlockThrottle { + /// `Err(seconds_left)` while attempts are blocked. + pub fn check(&self, now: Instant) -> Result<(), u64> { + match self.blocked_until { + Some(t) if t > now => Err((t - now).as_secs() + 1), + _ => Ok(()), + } + } + + pub fn record_failure(&mut self, now: Instant) { + self.failures = self.failures.saturating_add(1); + let d = throttle_delay(self.failures); + self.blocked_until = (!d.is_zero()).then(|| now + d); + } + + pub fn record_success(&mut self) { + *self = Self::default(); + } +} + +pub static THROTTLE: Mutex> = Mutex::new(None); + +pub const MIN_PASSWORD_LEN: usize = 6; + +/// Shared validation for choosing a new app-lock password. +pub fn validate_new_password(new: &str, confirm: &str) -> Result<(), String> { + if new != confirm { + return Err("As senhas não coincidem.".into()); + } + if new.chars().count() < MIN_PASSWORD_LEN { + return Err(format!( + "A senha deve ter pelo menos {MIN_PASSWORD_LEN} caracteres." + )); + } + Ok(()) +} + /// Whether a window label should be hidden behind the lock screen. Pure, unit-tested. /// The lock window itself is never hidden; everything user-facing (`wa-*` + settings) is. pub fn should_hide(label: &str) -> bool { @@ -45,7 +107,7 @@ pub fn is_lock_window(window: &tauri::Webview) -> bool { /// not yet managed, so nothing is accidentally gated before setup runs. pub fn is_unlocked(app: &AppHandle) -> bool { app.try_state::() - .map(|s| *s.unlocked.lock().unwrap()) + .map(|s| *guard(&s.unlocked)) .unwrap_or(true) } @@ -83,13 +145,18 @@ fn lock_on_main(app: &AppHandle, prompt: bool) { } // Idempotent: if already locked, just ensure the lock screen is up — do NOT // re-hide windows or clobber the recorded `hidden` list (which unlock restores). - if !*state.unlocked.lock().unwrap() { - if prompt { - show_lock_window(app); + { + // Test-and-set under one guard so two racing callers cannot both hide. + let mut unlocked = guard(&state.unlocked); + if !*unlocked { + drop(unlocked); + if prompt { + show_lock_window(app); + } + return; } - return; + *unlocked = false; } - *state.unlocked.lock().unwrap() = false; crate::notify::on_lock(app); crate::integration::broadcast(app); let mut hidden = Vec::new(); @@ -101,7 +168,7 @@ fn lock_on_main(app: &AppHandle, prompt: bool) { hidden.push(label); } } - *state.hidden.lock().unwrap() = hidden; + *guard(&state.hidden) = hidden; if prompt { show_lock_window(app); } @@ -126,11 +193,11 @@ fn unlock_on_main(app: &AppHandle) { }; state.prompt_requested.store(false, Ordering::SeqCst); crate::notify::on_unlock(app); - *state.unlocked.lock().unwrap() = true; + *guard(&state.unlocked) = true; if let Some(w) = app.get_webview_window("lock") { let _ = w.destroy(); } - let hidden = std::mem::take(&mut *state.hidden.lock().unwrap()); + let hidden = std::mem::take(&mut *guard(&state.hidden)); if hidden.is_empty() { crate::window::show_active(app); } else { @@ -226,6 +293,37 @@ mod tests { assert!(!should_hide("some-future-internal-panel")); } + #[test] + fn throttle_delay_grows_then_caps() { + assert_eq!(throttle_delay(0), Duration::ZERO); + assert_eq!(throttle_delay(2), Duration::ZERO); + assert_eq!(throttle_delay(3), Duration::from_secs(2)); + assert_eq!(throttle_delay(4), Duration::from_secs(4)); + assert_eq!(throttle_delay(50), Duration::from_secs(60)); + } + + #[test] + fn throttle_blocks_after_repeated_failures_and_resets() { + let t0 = Instant::now(); + let mut t = UnlockThrottle::default(); + for _ in 0..2 { + t.record_failure(t0); + assert!(t.check(t0).is_ok()); + } + t.record_failure(t0); + assert!(t.check(t0).is_err()); + assert!(t.check(t0 + Duration::from_secs(5)).is_ok()); + t.record_success(); + assert!(t.check(t0).is_ok()); + } + + #[test] + fn password_validation() { + assert!(validate_new_password("abcdef", "abcdef").is_ok()); + assert!(validate_new_password("abcdef", "abcdeg").is_err()); + assert!(validate_new_password("abc", "abc").is_err()); + } + #[test] fn is_lock_label_matches_only_lock() { assert!(is_lock_label("lock")); diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 8be901b..249131d 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -60,7 +60,7 @@ impl Recent { #[cfg(any(windows, test))] fn cancel_pending(&self, label: &str, id: &str, identity: &str) { self.finish(label, id, identity, false); - let mut inner = self.inner.lock().unwrap(); + let mut inner = crate::lock::guard(&self.inner); if let Some(n) = inner.records.iter_mut().find(|n| n.id == id) { n.status = "suppressed".into(); n.reason = Some("locked".into()); @@ -88,7 +88,7 @@ impl Recent { .is_some_and(|(_, t)| *t >= since) } pub fn consume_coverage(&self, label: &str, since: Instant) -> bool { - let mut inner = self.inner.lock().unwrap(); + let mut inner = crate::lock::guard(&self.inner); let receipts = inner.coverage.entry(label.into()).or_default(); while receipts.front().is_some_and(|(_, time)| *time < since) { receipts.pop_front(); @@ -96,10 +96,10 @@ impl Recent { receipts.pop_front().is_some() } pub fn native_ready(&self, label: &str) -> bool { - self.inner.lock().unwrap().native.contains(label) + crate::lock::guard(&self.inner).native.contains(label) } pub fn set_native(&self, label: &str, ready: bool) { - let mut i = self.inner.lock().unwrap(); + let mut i = crate::lock::guard(&self.inner); if ready { i.native.insert(label.into()); } else { @@ -117,7 +117,7 @@ impl Recent { .collect() } fn record(&self, label: &str, id: &str, source: Source, status: &str, reason: Option<&str>) { - let mut i = self.inner.lock().unwrap(); + let mut i = crate::lock::guard(&self.inner); i.records.push_back(Notice { id: id.into(), account_id: crate::accounts::id_from_label(label).unwrap_or("").into(), @@ -134,7 +134,7 @@ impl Recent { } } fn finish(&self, label: &str, id: &str, identity: &str, success: bool) { - let mut i = self.inner.lock().unwrap(); + let mut i = crate::lock::guard(&self.inner); let success = success && !i.records.iter().any(|r| r.id == id && r.status == "failed"); let real_source = i .records @@ -495,11 +495,11 @@ pub fn on_lock(app: &AppHandle) { #[cfg(windows)] { let generation = recent.epoch(); - let toasts = std::mem::take(&mut recent.inner.lock().unwrap().toasts); + let toasts = std::mem::take(&mut crate::lock::guard(&recent.inner).toasts); let handle = app.clone(); tauri::async_runtime::spawn_blocking(move || { let recent = handle.state::(); - let _gate = recent.delivery_gate.lock().unwrap(); + let _gate = crate::lock::guard(&recent.delivery_gate); use windows::{core::HSTRING, UI::Notifications::ToastNotificationManager}; if let Ok(notifier) = ToastNotificationManager::CreateToastNotifierWithId( &HSTRING::from(&handle.config().identifier), @@ -565,7 +565,7 @@ fn windows_toast( .unwrap_or_else(event_id); std::thread::spawn(move || { let recent = app.state::(); - let _gate = recent.delivery_gate.lock().unwrap(); + let _gate = crate::lock::guard(&recent.delivery_gate); clear_history_before_delivery(&app, epoch); if !crate::lock::is_unlocked(&app) || app.state::().epoch() != epoch { if let Some(d) = &delivery { @@ -682,7 +682,7 @@ fn windows_toast( })(); if let Ok(toast) = &send { let retired = { - let mut inner = recent.inner.lock().unwrap(); + let mut inner = crate::lock::guard(&recent.inner); let retired = if inner.toasts.len() >= 100 { inner .toasts diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 0a25a54..f8e4462 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -26,7 +26,7 @@ fn state(app: &AppHandle) -> tauri::State<'_, Sessions> { fn set_view_visible(app: &AppHandle, view: &tauri::Webview, visible: bool) { let changed = { let s = state(app); - let mut map = s.view_visibility.lock().unwrap(); + let mut map = crate::lock::guard(&s.view_visibility); map.insert(view.label().into(), visible) != Some(visible) }; // Release the cache lock before COM: showing can generate native events. @@ -65,14 +65,14 @@ pub fn schedule_layout(app: &AppHandle, parent: &str) { tauri::async_runtime::spawn_blocking(move || { let ui = handle.clone(); let _ = handle.run_on_main_thread(move || { - state(&ui).pending_layout.lock().unwrap().remove(&parent); + crate::lock::guard(&state(&ui).pending_layout).remove(&parent); layout(&ui, &parent); }); }); } fn forget_view_geometry(app: &AppHandle, label: &str) { - state(app).applied_bounds.lock().unwrap().remove(label); - state(app).view_visibility.lock().unwrap().remove(label); + crate::lock::guard(&state(app).applied_bounds).remove(label); + crate::lock::guard(&state(app).view_visibility).remove(label); } fn reparent_on_ui(app: &AppHandle, view: &tauri::Webview, parent: &Window) -> Result<(), String> { @@ -124,7 +124,7 @@ pub fn initialize(app: &AppHandle, hidden: bool) -> tauri::Result { } tauri::WindowEvent::Focused(focused) => { if *focused { - let label = state(&a).main_active.lock().unwrap().clone(); + let label = crate::lock::guard(&state(&a).main_active).clone(); if let Some(label) = label { *a.state::().lock().unwrap() = label; } @@ -195,7 +195,7 @@ fn install_menu(app: &AppHandle, w: &Window) -> tauri::Result<()> { pub fn selected(app: &AppHandle, parent: &str) -> Option { if parent == "main" { - return state(app).main_active.lock().unwrap().clone(); + return crate::lock::guard(&state(app).main_active).clone(); } app.webviews() .into_iter() @@ -210,8 +210,8 @@ pub fn open_account(app: &AppHandle, account: &Account, hidden: bool) -> tauri:: let view = crate::window::build_account_view(app, &parent, account)?; set_view_visible(app, &view, false); } - if state(app).main_active.lock().unwrap().is_none() { - *state(app).main_active.lock().unwrap() = Some(label.clone()); + if crate::lock::guard(&state(app).main_active).is_none() { + *crate::lock::guard(&state(app).main_active) = Some(label.clone()); } if !hidden { activate(app, &label); @@ -237,7 +237,7 @@ pub fn activate(app: &AppHandle, label: &str) { fn activate_on_main(app: &AppHandle, label: &str) { let _health = crate::ui_health::enter("account activation"); if !crate::lock::is_unlocked(app) { - *state(app).pending.lock().unwrap() = Some(label.to_string()); + *crate::lock::guard(&state(app).pending) = Some(label.to_string()); crate::lock::show_lock_window(app); return; } @@ -246,12 +246,12 @@ fn activate_on_main(app: &AppHandle, label: &str) { }; let parent = view.window(); if parent.label() == "main" { - *state(app).main_active.lock().unwrap() = Some(label.to_string()); + *crate::lock::guard(&state(app).main_active) = Some(label.to_string()); } if let Some(panel) = app.get_webview("settings") { if panel.window().label() == parent.label() { set_view_visible(app, &panel, false); - *state(app).panel_parent.lock().unwrap() = None; + *crate::lock::guard(&state(app).panel_parent) = None; } } for (other, v) in app.webviews() { @@ -297,7 +297,7 @@ fn layout_on_main(app: &AppHandle, parent: &str) { let minimized = w.is_minimized().unwrap_or(false); let visible = w.is_visible().unwrap_or(false) && !minimized && crate::lock::is_unlocked(app); let active = selected(app, parent); - let panel_open = state(app).panel_parent.lock().unwrap().as_deref() == Some(parent); + let panel_open = crate::lock::guard(&state(app).panel_parent).as_deref() == Some(parent); for (label, view) in app.webviews() { if view.window().label() != parent { continue; @@ -327,7 +327,7 @@ fn layout_on_main(app: &AppHandle, parent: &str) { let top = if parent == "main" && active .as_ref() - .is_some_and(|l| state(app).fallback.lock().unwrap().contains(l)) + .is_some_and(|l| crate::lock::guard(&state(app).fallback).contains(l)) { 48. } else { @@ -363,7 +363,7 @@ fn layout_on_main(app: &AppHandle, parent: &str) { }) .is_err() { - state(app).applied_bounds.lock().unwrap().remove(&label); + crate::lock::guard(&state(app).applied_bounds).remove(&label); } } if shell { @@ -405,14 +405,14 @@ fn sync_menu(app: &AppHandle, parent: &str) { }); let changed = { let s = state(app); - let mut menus = s.menus_hidden.lock().unwrap(); + let mut menus = crate::lock::guard(&s.menus_hidden); menus.insert(parent.into(), hidden) != Some(hidden) }; if changed { if let Some(w) = app.get_window(parent) { let result = if hidden { w.hide_menu() } else { w.show_menu() }; if result.is_err() { - state(app).menus_hidden.lock().unwrap().remove(parent); + crate::lock::guard(&state(app).menus_hidden).remove(parent); } } } @@ -420,7 +420,7 @@ fn sync_menu(app: &AppHandle, parent: &str) { pub fn integration_changed(app: &AppHandle, label: &str, mounted: bool) { let s = state(app); - let mut f = s.fallback.lock().unwrap(); + let mut f = crate::lock::guard(&s.fallback); if mounted { f.remove(label); } else { @@ -507,7 +507,7 @@ fn attach_inner(app: &AppHandle, id: &str, activate_returned_account: bool) -> R } return Ok(()); } - let selected_before_close = state(app).main_active.lock().unwrap().clone(); + let selected_before_close = crate::lock::guard(&state(app).main_active).clone(); if let Some(panel) = app.get_webview("settings") { if panel.window().label() == old.label() { set_view_visible(app, &panel, false); @@ -519,7 +519,7 @@ fn attach_inner(app: &AppHandle, id: &str, activate_returned_account: bool) -> R } } reparent_on_ui(app, &v, &initialize(app, true).map_err(|e| e.to_string())?)?; - state(app).menus_hidden.lock().unwrap().remove(old.label()); + crate::lock::guard(&state(app).menus_hidden).remove(old.label()); let _ = old.destroy(); if activate_returned_account { activate(app, &label); @@ -551,7 +551,7 @@ fn restore_main_after_detached_close( }) .unwrap_or_else(|| returned_label.to_string()); - *state(app).main_active.lock().unwrap() = Some(selected.clone()); + *crate::lock::guard(&state(app).main_active) = Some(selected.clone()); *app.state::().lock().unwrap() = selected.clone(); sync_menu(app, "main"); if let (Some(window), Some(view)) = (app.get_window("main"), app.get_webview(&selected)) { @@ -569,7 +569,7 @@ fn restore_main_after_detached_close( let _ = window.unminimize(); layout(app, "main"); let _ = window.set_focus(); - if state(app).panel_parent.lock().unwrap().as_deref() == Some("main") { + if crate::lock::guard(&state(app).panel_parent).as_deref() == Some("main") { if let Some(panel) = app.get_webview("settings") { let _ = panel.set_focus(); } @@ -581,7 +581,7 @@ fn restore_main_after_detached_close( } fn choose_remaining(app: &AppHandle, exclude: Option<&str>) { - let current = state(app).main_active.lock().unwrap().clone(); + let current = crate::lock::guard(&state(app).main_active).clone(); if let Some(label) = current { if exclude != Some(label.as_str()) && app @@ -601,7 +601,7 @@ fn choose_remaining(app: &AppHandle, exclude: Option<&str>) { .is_some_and(|v| v.window().label() == "main")) .then_some(label) }); - *state(app).main_active.lock().unwrap() = next.clone(); + *crate::lock::guard(&state(app).main_active) = next.clone(); if let Some(label) = next { activate(app, &label); } else { @@ -617,7 +617,7 @@ pub fn remove_view(app: &AppHandle, id: &str) -> tauri::Result<()> { if let Some(v) = app.get_webview(&accounts::window_label(id)) { let owner = v.window(); v.close()?; - state(app).view_visibility.lock().unwrap().remove(v.label()); + crate::lock::guard(&state(app).view_visibility).remove(v.label()); if owner.label().starts_with("detached-") { let _ = owner.destroy(); } @@ -673,7 +673,7 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { set_view_visible(app, &v, false); } } - *state(app).panel_parent.lock().unwrap() = Some(owner.label().into()); + *crate::lock::guard(&state(app).panel_parent) = Some(owner.label().into()); layout(app, owner.label()); // A worker can finish constructing a panel after a lock request. if !crate::lock::is_unlocked(app) { @@ -690,7 +690,7 @@ pub fn open_panel(app: &AppHandle, parent: Option<&str>) -> tauri::Result<()> { } pub fn close_panel(app: &AppHandle) { - *state(app).panel_parent.lock().unwrap() = None; + *crate::lock::guard(&state(app).panel_parent) = None; if let Some(v) = app.get_webview("settings") { let parent = v.window(); set_view_visible(app, &v, false); @@ -724,7 +724,7 @@ pub fn toggle(app: &AppHandle) { } } pub fn restore_pending(app: &AppHandle) { - let label = state(app).pending.lock().unwrap().take(); + let label = crate::lock::guard(&state(app).pending).take(); if let Some(label) = label { activate(app, &label); } @@ -737,6 +737,6 @@ pub fn account_focused(app: &AppHandle, label: &str) -> bool { && !parent.is_minimized().unwrap_or(false) && parent.is_focused().unwrap_or(false) && selected(app, parent.label()).as_deref() == Some(label) - && state(app).panel_parent.lock().unwrap().as_deref() != Some(parent.label()) + && crate::lock::guard(&state(app).panel_parent).as_deref() != Some(parent.label()) }) } diff --git a/src-tauri/src/smoke.rs b/src-tauri/src/smoke.rs index f63d3e2..0ece58e 100644 --- a/src-tauri/src/smoke.rs +++ b/src-tauri/src/smoke.rs @@ -344,24 +344,24 @@ pub fn start(app: &AppHandle) { result.push(json!({"test":"notification_close_with_account_panel_navigation_12_cycles","pass":true})); let panel = app.get_webview("settings").ok_or("missing local panel")?; crate::dlog::log("smoke: password_validation"); - result.push(json!({"test":"password_validation","pass":crate::commands::set_app_lock_password(panel.clone(),app.clone(),"abc".into(),"abc".into()).is_err()&&crate::commands::set_app_lock_password(panel.clone(),app.clone(),"test-pass".into(),"mismatch".into()).is_err()})); - crate::commands::set_app_lock_password( + result.push(json!({"test":"password_validation","pass":tauri::async_runtime::block_on(crate::commands::set_app_lock_password(panel.clone(),app.clone(),"abc".into(),"abc".into())).is_err()&&tauri::async_runtime::block_on(crate::commands::set_app_lock_password(panel.clone(),app.clone(),"test-pass".into(),"mismatch".into())).is_err()})); + tauri::async_runtime::block_on(crate::commands::set_app_lock_password( panel.clone(), app.clone(), "smoke-old".into(), "smoke-old".into(), - )?; + ))?; crate::dlog::log("smoke: password_stored_as_argon2id"); result.push(json!({"test":"password_stored_as_argon2id","pass":crate::applock::load(&app).password_phc.is_some_and(|p|p.starts_with("$argon2id$")&&!p.contains("smoke-old"))})); crate::dlog::log("smoke: password_change_requires_current"); - result.push(json!({"test":"password_change_requires_current","pass":crate::commands::change_app_lock_password(panel.clone(),app.clone(),"wrong".into(),"smoke-new".into(),"smoke-new".into()).is_err()})); - crate::commands::change_app_lock_password( + result.push(json!({"test":"password_change_requires_current","pass":tauri::async_runtime::block_on(crate::commands::change_app_lock_password(panel.clone(),app.clone(),"wrong".into(),"smoke-new".into(),"smoke-new".into())).is_err()})); + tauri::async_runtime::block_on(crate::commands::change_app_lock_password( panel.clone(), app.clone(), "smoke-old".into(), "smoke-new".into(), "smoke-new".into(), - )?; + ))?; crate::commands::set_app_lock_options(panel.clone(), app.clone(), true, true, 120)?; let config = crate::applock::load(&app); crate::dlog::log("smoke: lock_preferences_persist"); @@ -391,7 +391,7 @@ pub fn start(app: &AppHandle) { } let lock_view = app.get_webview("lock").ok_or("missing lock view")?; result.push(json!({"test":"disabled_biometric_cannot_unlock","pass":crate::commands::unlock_biometric(lock_view,app.clone()).is_err()&&!crate::lock::is_unlocked(&app)})); - result.push(json!({"test":"remote_cannot_unlock","pass":crate::commands::unlock_password(app.get_webview(&label).unwrap(),app.clone(),"smoke-new".into()).is_err()&&!crate::lock::is_unlocked(&app)})); + result.push(json!({"test":"remote_cannot_unlock","pass":tauri::async_runtime::block_on(crate::commands::unlock_password(app.get_webview(&label).unwrap(),app.clone(),"smoke-new".into())).is_err()&&!crate::lock::is_unlocked(&app)})); eval(&app,"lock","document.getElementById('password').value='wrong';document.getElementById('unlock').click();true")?; let wrong = wait_until(|| { eval( @@ -482,8 +482,12 @@ pub fn start(app: &AppHandle) { ); result.push(json!({"test":"stale_page_icon_notification_not_replayed_after_unlock","pass":app.state::().records().first().is_some_and(|n|n.status=="suppressed"&&n.reason.as_deref()==Some("locked"))})); crate::dlog::log("smoke: disable_lock_requires_current_password"); - result.push(json!({"test":"disable_lock_requires_current_password","pass":crate::commands::disable_app_lock(panel.clone(),app.clone(),"wrong".into()).is_err()})); - crate::commands::disable_app_lock(panel, app.clone(), "smoke-new".into())?; + result.push(json!({"test":"disable_lock_requires_current_password","pass":tauri::async_runtime::block_on(crate::commands::disable_app_lock(panel.clone(),app.clone(),"wrong".into())).is_err()})); + tauri::async_runtime::block_on(crate::commands::disable_app_lock( + panel, + app.clone(), + "smoke-new".into(), + ))?; crate::dlog::log("smoke: disable_clears_hash_and_biometrics"); result.push(json!({"test":"disable_clears_hash_and_biometrics","pass":crate::applock::load(&app)==crate::applock::AppLockConfig::default()})); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index de1ca02..4d136cf 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -64,7 +64,7 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { "accounts" | "settings" => crate::window::open_settings_window(app), "reload" => { if let Some(active) = app.try_state::() { - let label = active.lock().unwrap().clone(); + let label = crate::lock::guard(&active).clone(); if let Some(w) = app.get_webview(&label) { let _ = w.eval("window.location.reload()"); } @@ -105,7 +105,7 @@ pub fn rebuild_menu(app: &AppHandle) { // static items (update_badge / other paths may re-lock the map). let counts: std::collections::HashMap = { let state = app.state::(); - let map = state.lock().unwrap(); + let map = crate::lock::guard(&state); map.clone() }; diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 028d79f..ed9464f 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1023,7 +1023,7 @@ fn register_focus_listener(app: &AppHandle, win: &WebviewWindow) { win.on_window_event(move |event| { if let tauri::WindowEvent::Focused(true) = event { if let Some(active) = app_handle.try_state::() { - *active.lock().unwrap() = label.clone(); + *crate::lock::guard(&active) = label.clone(); } } }); @@ -1136,8 +1136,17 @@ fn enable_webview_media(win: &WebviewWindow) { settings.set_property("enable-developer-extras", true); } } - wv.connect_permission_request(|_wv, req| { - req.allow(); + wv.connect_permission_request(|wv, req| { + // Grant only to WhatsApp itself; any other page that ends up in + // this webview gets nothing. + let from_whatsapp = wv + .uri() + .is_some_and(|u| u.starts_with("https://web.whatsapp.com/")); + if from_whatsapp { + req.allow(); + } else { + req.deny(); + } true }); // The initial navigation was issued before this callback ran, i.e. @@ -1222,6 +1231,12 @@ pub fn native_report(app: &AppHandle, id: &str, action: &str) { /// but none for real messages. WebView2's `NotificationReceived` sees those too: /// it is turned into a whatRust toast (lock-aware, de-duplicated) instead of /// WebView2's own popup. +/// Whether a permission request comes from WhatsApp's own origin. +#[cfg(any(target_os = "windows", test))] +fn is_whatsapp_origin(uri: &str) -> bool { + uri == "https://web.whatsapp.com" || uri.starts_with("https://web.whatsapp.com/") +} + #[cfg(target_os = "windows")] fn enable_media_windows(webview: tauri::webview::PlatformWebview, app: AppHandle, label: String) { use webview2_com::Microsoft::Web::WebView2::Win32::{ @@ -1229,6 +1244,7 @@ fn enable_media_windows(webview: tauri::webview::PlatformWebview, app: AppHandle ICoreWebView2PermissionRequestedEventArgs, ICoreWebView2_24, COREWEBVIEW2_PERMISSION_KIND, COREWEBVIEW2_PERMISSION_KIND_CAMERA, COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS, COREWEBVIEW2_PERMISSION_STATE_ALLOW, + COREWEBVIEW2_PERMISSION_STATE_DENY, }; use webview2_com::{NotificationReceivedEventHandler, PermissionRequestedEventHandler}; use windows_core::Interface; @@ -1248,11 +1264,17 @@ fn enable_media_windows(webview: tauri::webview::PlatformWebview, app: AppHandle if let Some(args) = args { let mut kind = COREWEBVIEW2_PERMISSION_KIND::default(); args.PermissionKind(&mut kind)?; - if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE - || kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA - || kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS + let mut uri = windows_core::PWSTR::null(); + args.Uri(&mut uri)?; + let uri = webview2_com::take_pwstr(uri); + if is_whatsapp_origin(&uri) + && (kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE + || kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA + || kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS) { args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)?; + } else { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?; } } Ok(()) @@ -1372,7 +1394,7 @@ pub fn show_active(app: &AppHandle) { return; } if let Some(active) = app.try_state::() { - let label = active.lock().unwrap().clone(); + let label = crate::lock::guard(&active).clone(); if app.get_webview(&label).is_some() { show_account(app, &label); return; @@ -1438,7 +1460,7 @@ pub fn toggle_active(app: &AppHandle) { fn toggle_active_legacy(app: &AppHandle) { let label = app .try_state::() - .map(|a| a.lock().unwrap().clone()); + .map(|a| crate::lock::guard(&a).clone()); let visible = label .as_ref() .and_then(|l| app.get_webview_window(l)) @@ -1510,10 +1532,10 @@ fn open_settings_legacy(app: &AppHandle) { mod tests { use super::{ base64_encode, drop_ack_failed, drop_msg_begin, drop_msg_chunk_prefix_parts, - drop_msg_commit, drop_msg_end, ensure_download_destination, mime_for, plan_drop, - run_drop_queue, safari_ua, stream_chunks, summarize_skips, toggle_decision, DropSkips, - StreamAbort, ToggleAct, CHUNKS_PER_EVAL, DROP_CHUNK_BYTES, DROP_MSG_CHUNK_SUFFIX_PARTS, - MAX_DROP_FILES, + drop_msg_commit, drop_msg_end, ensure_download_destination, is_whatsapp_origin, mime_for, + plan_drop, run_drop_queue, safari_ua, stream_chunks, summarize_skips, toggle_decision, + DropSkips, StreamAbort, ToggleAct, CHUNKS_PER_EVAL, DROP_CHUNK_BYTES, + DROP_MSG_CHUNK_SUFFIX_PARTS, MAX_DROP_FILES, }; #[test] @@ -1902,4 +1924,15 @@ mod tests { fn no_active_window_is_shown() { assert_eq!(toggle_decision(None), ToggleAct::Show); } + + #[test] + fn permissions_are_granted_to_whatsapp_origin_only() { + assert!(is_whatsapp_origin("https://web.whatsapp.com/")); + assert!(is_whatsapp_origin("https://web.whatsapp.com")); + assert!(!is_whatsapp_origin("https://web.whatsapp.com.evil.test/")); + assert!(!is_whatsapp_origin("http://web.whatsapp.com/")); + assert!(!is_whatsapp_origin( + "https://evil.test/https://web.whatsapp.com/" + )); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 83e6493..79654ab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,7 +10,7 @@ "withGlobalTauri": true, "windows": [], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" } }, "plugins": { From ba796fea2859d7a1e938ad8a46dc09c16b0a7f06 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso de Jesus Date: Thu, 22 Oct 2026 20:05:04 -0300 Subject: [PATCH 18/22] =?UTF-8?q?Add=20interface=20language=20setting=20(P?= =?UTF-8?q?ortugu=C3=AAs=20/=20English)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portuguese (Brazil) stays the default. Settings > Aparência > Idioma switches the language immediately and persists it in settings.json (`language`). - i18n.rs: Portuguese source strings with an English table (tr/trf); a test fails when a translatable literal has no English entry - settings-ui/i18n.js: translates text nodes and aria/title/placeholder attributes for the settings, lock and shell pages, live on change - bridge.js / integration.js: account switcher and download toasts follow the language set by Rust (window.__WR_LANG) - tray, native menu, notifications, errors and window titles are translated; the language change re-applies them without a restart - lock screen shows the localized "too many attempts" wait - CI runs the language and notification test suites Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/check.yml | 6 +++ settings-ui/index.html | 11 +++- settings-ui/lock.html | 1 + settings-ui/lock.js | 10 ++-- settings-ui/main.js | 28 ++++++---- settings-ui/panel.js | 6 +-- settings-ui/shell.html | 2 +- src-tauri/resources/bridge.js | 28 +++++----- src-tauri/resources/integration.js | 23 ++++---- src-tauri/src/accounts.rs | 11 ++-- src-tauri/src/applock.rs | 3 +- src-tauri/src/biometric/macos.rs | 4 +- src-tauri/src/commands.rs | 50 +++++++++++------- src-tauri/src/integration.rs | 15 +++--- src-tauri/src/lib.rs | 2 + src-tauri/src/lock.rs | 11 ++-- src-tauri/src/notify.rs | 11 ++-- src-tauri/src/opener.rs | 10 ++-- src-tauri/src/sessions.rs | 34 ++++++++---- src-tauri/src/settings.rs | 25 ++++++--- src-tauri/src/tray.rs | 16 +++--- src-tauri/src/window.rs | 85 ++++++++++++++++-------------- src-tauri/tauri.conf.json | 2 +- 23 files changed, 242 insertions(+), 152 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 711102c..8299c3e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -69,3 +69,9 @@ jobs: # and shows up only as a dead button on macOS, so it needs a test. - name: settings-ui dialog tests run: node settings-ui/dialog.test.mjs + + - name: settings-ui language tests (every UI string has an English entry) + run: node settings-ui/i18n.test.mjs + + - name: notification tests + run: node src-tauri/resources/notifications.test.mjs diff --git a/settings-ui/index.html b/settings-ui/index.html index 591647e..6d86c2b 100644 --- a/settings-ui/index.html +++ b/settings-ui/index.html @@ -4,6 +4,7 @@ whatRust — Configurações + @@ -70,6 +71,14 @@

Aparência

+ @@ -100,7 +109,7 @@

Segurança