Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/release-notes/v0.6.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## whatRust v0.6.7

Profile pictures on notifications, and fixes for possible freezes on Windows.

### Profile pictures on notifications

- **New:** message and call notifications now show the sender's profile picture: round on Windows, as the notification icon on Linux. Thanks to @mhanxhan for the suggestion in #3.
- whatRust uses the picture WhatsApp attaches to its own notification, scaled down to a small image. It's kept in whatRust's cache folder only so the notification can show it, and removed after a day. When there's no picture, or it can't be loaded within a moment, the notification appears without one, as before.
- macOS notifications always show the app icon, so nothing changes there.
- The diagnostic log only records whether a picture was attached, never the picture or where it came from.

### Windows

- **Fixed:** possible freezes when adding or opening an account, opening Settings, or using "Lock now". Creating a new window from inside WebView2's message handler can deadlock, so these now run in the background. Thanks to @matheuscardosoj for finding this in #26.
- **Fixed:** saving Settings with "Launch at system startup" turned off no longer shows an error when it was already off.
- Two account changes made at the same moment can no longer overwrite each other, and an account whose window fails to open is removed again instead of being left behind.

### Install or update

```bash
# Linux (AppImage) and macOS
curl -fsSL https://raw.githubusercontent.com/karem505/whatRust/master/install.sh | sh
# Linux (Flatpak)
curl -fsSL https://raw.githubusercontent.com/karem505/whatRust/master/install-flatpak.sh | sh
```

On Windows, run the `x64-setup.exe` or `.msi` below.
6 changes: 6 additions & 0 deletions flatpak/io.github.karem505.whatRust.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@
</screenshots>

<releases>
<release type="stable" version="0.6.7" date="2026-10-10">
<url type="details">https://github.com/karem505/whatRust/releases/tag/v0.6.7</url>
<description>
<p>Shows the sender's profile picture on message notifications on Windows and Linux, and fixes possible freezes on Windows when adding or opening an account, opening Settings, or locking the app.</p>
</description>
</release>
<release type="stable" version="0.6.6" date="2026-10-09">
<url type="details">https://github.com/karem505/whatRust/releases/tag/v0.6.6</url>
<description>
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "whatrust"
version = "0.6.6"
version = "0.6.7"
edition = "2021"
rust-version = "1.82"

Expand Down
50 changes: 46 additions & 4 deletions src-tauri/resources/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,41 @@
seenPath[path] = true;
invoke("dlog", { msg: "notif path: " + path });
}
function nativeNotify(title, body) {
// The sender's picture, from the notification's `icon` (a blob:, data: or
// https: URL). Loaded, cropped to a square, scaled to ICON_PX and re-encoded
// as PNG, so Rust gets one small format every toast can show (Windows toasts
// can't show WebP). Resolves to base64 PNG, or null if it can't be had within
// ICON_WAIT_MS; the toast then goes out without it. The outcome is logged
// once per URL scheme, never the picture or its URL.
var ICON_PX = 96;
var ICON_WAIT_MS = 1500;
function iconPng(url) {
var load = Promise.resolve().then(function () {
if (typeof window.fetch !== "function" || typeof window.createImageBitmap !== "function") return null;
return window.fetch(url, { credentials: "omit" })
.then(function (r) { return r && r.ok ? r.blob() : null; })
.then(function (blob) { return blob ? window.createImageBitmap(blob) : null; })
.then(function (bmp) {
if (!bmp || !bmp.width || !bmp.height) return null;
var canvas = document.createElement("canvas");
canvas.width = ICON_PX;
canvas.height = ICON_PX;
var side = Math.min(bmp.width, bmp.height);
canvas.getContext("2d").drawImage(
bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, 0, 0, ICON_PX, ICON_PX
);
if (typeof bmp.close === "function") bmp.close();
var data = String(canvas.toDataURL("image/png"));
var at = data.indexOf("base64,");
return data.indexOf("data:image/png") === 0 && at > 0 ? data.slice(at + 7) : null;
});
}).catch(function () { return null; });
var timeout = new Promise(function (resolve) {
setTimeout(function () { resolve(null); }, ICON_WAIT_MS);
});
return Promise.race([load, timeout]);
}
function nativeNotify(title, body, icon) {
title = String(title || "WhatsApp");
body = String(body || "");
var now = Date.now();
Expand All @@ -46,7 +80,15 @@
var key = title + " " + body;
if (recentNotif[key] && now - recentNotif[key] <= DEDUP_MS) return false;
recentNotif[key] = now;
invoke("notify", { title: title, body: body });
var scheme = typeof icon === "string" && (/^(blob|data|https):/.exec(icon) || [])[1];
if (!scheme) {
invoke("notify", { 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 });
});
return true;
}

Expand Down Expand Up @@ -181,7 +223,7 @@
this.onerror = null;
this.onshow = null;
notePath("page Notification()");
nativeNotify(title, options.body);
nativeNotify(title, options.body, options.icon);
}
ShimNotification.prototype.close = function () {
if (typeof this.onclose === "function") this.onclose();
Expand Down Expand Up @@ -241,7 +283,7 @@
notePath("page registration.showNotification()");
// Route through nativeNotify so the service-worker path shares the same de-dup
// window as window.Notification (an alert that fires on both paths shows once).
nativeNotify(title, options.body);
nativeNotify(title, options.body, options.icon);
// Real API resolves Promise<undefined>; match it so callers awaiting it don't break.
return Promise.resolve();
};
Expand Down
47 changes: 47 additions & 0 deletions src-tauri/resources/bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,52 @@ async function testNotificationsReachTheNativeNotifyCommand() {
assert(w.Notification.permission === "granted", "Notification.permission reads granted");
}

async function testNotificationPictureIsForwardedAsPng() {
console.log("the sender's picture goes to notify as a small PNG, or the toast goes without it");
const { window: w, document: d, invocations } = makeHarness();
const fetched = [];
const drawn = [];
w.fetch = (url, opts) => {
fetched.push({ url, opts });
return Promise.resolve({ ok: true, blob: () => Promise.resolve({ type: "image/webp" }) });
};
w.createImageBitmap = () => Promise.resolve({ width: 200, height: 100, close() {} });
d.createElement = (tag) =>
tag === "canvas"
? {
getContext: () => ({ drawImage: (...a) => drawn.push(a) }),
toDataURL: (type) => "data:" + type + ";base64,iVBORw0KGgo=",
}
: new FakeElement(tag);
new w.Notification("Bob", { body: "hi", icon: "blob:https://web.whatsapp.com/1234" });
await sleep(10);
const sent = invocations.filter((x) => x.cmd === "notify");
assert(sent.length === 1 && sent[0].args.icon === "iVBORw0KGgo=", "base64 PNG forwarded as icon");
assert(sent[0] && sent[0].args.title === "Bob" && sent[0].args.body === "hi", "title and body unchanged");
assert(fetched[0] && fetched[0].opts.credentials === "omit", "fetched without credentials");
// A 200x100 picture is cropped to its centre square and scaled to 96px.
assert(drawn[0] && drawn[0].slice(1).join() === "50,0,100,100,0,0,96,96", "centre square, 96px");
const logged = invocations.filter((x) => x.cmd === "dlog").map((x) => x.args.msg);
assert(logged.includes("notif path: icon blob ok"), "outcome logged by scheme only");
assert(!logged.some((m) => m.includes("1234")), "the picture's URL is never logged");

// Unloadable: the toast still goes out, just without a picture.
w.fetch = () => Promise.reject(new Error("blocked"));
new w.Notification("Carol", { body: "yo", icon: "https://pps.whatsapp.net/v/x.jpg" });
await sleep(10);
const carol = invocations.filter((x) => x.cmd === "notify" && x.args.title === "Carol");
assert(carol.length === 1 && !("icon" in carol[0].args), "notify without icon when it can't load");

// Anything but blob:, data: or https: is not fetched at all.
const before = fetched.length;
new w.Notification("Dan", { body: "x", icon: "file:///etc/passwd" });
assert(
invocations.some((x) => x.cmd === "notify" && x.args.title === "Dan" && !("icon" in x.args)),
"other schemes: sent at once, no picture"
);
assert(fetched.length === before, "other schemes are not fetched");
}

async function testPermissionsApiAgreesNotificationsAreGranted() {
console.log("navigator.permissions reports notifications granted, passes others through");
const asked = [];
Expand Down Expand Up @@ -545,6 +591,7 @@ const tests = [
testRiskyDownloadsAndFailures,
testChromeShimsOnlyForAChromeUserAgent,
testNotificationsReachTheNativeNotifyCommand,
testNotificationPictureIsForwardedAsPng,
testPermissionsApiAgreesNotificationsAreGranted,
testRefusedCommandsWarnOnceAndNeverThrow,
testMixedDropLosesNothing,
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ pub type UnreadMap = Mutex<HashMap<String, u32>>;
/// Window label of the last-focused account window (e.g. `wa-default`). Managed app state.
pub type ActiveAccount = Mutex<String>;

/// Held across each account command's load-modify-save (and its window build),
/// so two account commands running at once can't interleave and lose a change.
static MUTATIONS: Mutex<()> = Mutex::new(());

pub fn lock_mutations() -> std::sync::MutexGuard<'static, ()> {
MUTATIONS.lock().unwrap_or_else(|e| e.into_inner())
}

/// A single WhatsApp account.
///
/// `store_uuid` is `Some` only for non-default accounts (used on macOS >= 14 as the
Expand Down
42 changes: 33 additions & 9 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ fn is_remote_label(label: &str) -> bool {
label.starts_with("wa-")
}

/// `icon` is the sender's picture as a small base64 PNG (bridge.js), when
/// WhatsApp gave one and it could be loaded.
#[tauri::command]
pub fn notify(window: tauri::Window, app: tauri::AppHandle, title: String, body: String) {
pub fn notify(
window: tauri::Window,
app: tauri::AppHandle,
title: String,
body: String,
icon: Option<String>,
) {
// 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");
Expand All @@ -30,6 +38,7 @@ pub fn notify(window: tauri::Window, app: tauri::AppHandle, title: String, body:
window.label(),
&title,
&body,
icon.as_deref(),
crate::notify::Source::Page,
);
}
Expand Down Expand Up @@ -134,8 +143,12 @@ pub fn set_settings(
Ok(crate::settings::apply(&app, &settings))
}

// Commands that build a window are `async`. Tauri runs a synchronous command
// inside WebView2's IPC callback, and creating another WebView2 there deadlocks
// on Windows (wry#583); an async command runs on the async runtime instead.
// Keep every window-creating command async.
#[tauri::command]
pub fn open_settings(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> {
pub async fn open_settings(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> {
if is_remote(&window) {
return Err("forbidden".into());
}
Expand Down Expand Up @@ -191,7 +204,7 @@ pub fn list_accounts(
}

#[tauri::command]
pub fn add_account(
pub async fn add_account(
window: tauri::Window,
app: tauri::AppHandle,
name: String,
Expand All @@ -208,11 +221,19 @@ pub fn add_account(
return Err("account name cannot be empty".into());
}

let _guard = accounts::lock_mutations();
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(e) = crate::window::open_account_window(&app, &acct, false) {
// Don't leave an account behind that never got a window. The sequence
// 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}"))?;
return Err(e.to_string());
}
crate::tray::rebuild_menu(&app);

Ok(AccountView {
Expand All @@ -225,7 +246,7 @@ pub fn add_account(
}

#[tauri::command]
pub fn remove_account(
pub async fn remove_account(
window: tauri::Window,
app: tauri::AppHandle,
id: String,
Expand All @@ -235,6 +256,7 @@ pub fn remove_account(
}
lock::require_unlocked(&app)?;

let _guard = accounts::lock_mutations();
let mut f = accounts::load(&app);
let removed = accounts::remove(&mut f, &id)?;
accounts::save(&app, &f).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -263,7 +285,7 @@ pub fn remove_account(
}

#[tauri::command]
pub fn rename_account(
pub async fn rename_account(
window: tauri::Window,
app: tauri::AppHandle,
id: String,
Expand All @@ -278,6 +300,7 @@ pub fn rename_account(
return Err("account name cannot be empty".into());
}

let _guard = accounts::lock_mutations();
let mut f = accounts::load(&app);
accounts::rename(&mut f, &id, name)?;
accounts::save(&app, &f).map_err(|e| e.to_string())?;
Expand All @@ -290,7 +313,7 @@ pub fn rename_account(
}

#[tauri::command]
pub fn open_account(
pub async fn open_account(
window: tauri::Window,
app: tauri::AppHandle,
id: String,
Expand All @@ -299,6 +322,7 @@ pub fn open_account(
return Err("forbidden".into());
}
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(format!("unknown account: {id}"));
Expand Down Expand Up @@ -484,9 +508,9 @@ pub fn set_biometric_enabled(
applock::save(&app, &c).map_err(|e| e.to_string())
}

/// Manual "Lock now" from the settings window.
/// Manual "Lock now" from the settings window. Async: it builds the lock window.
#[tauri::command]
pub fn lock_app(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> {
pub async fn lock_app(window: tauri::Window, app: tauri::AppHandle) -> Result<(), String> {
if is_remote(&window) {
return Err("forbidden".into());
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod dlog;
mod downloads;
mod links;
mod lock;
mod notif_icon;
mod notify;
mod opener;
mod settings;
Expand Down
Loading
Loading