Files
cinny-desktop/src-tauri/src/lib.rs
T
jaredandClaude Opus 4.8 33b1a45edd
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m50s
Build Lotus Chat Desktop / build-windows (push) Successful in 32m26s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
fix(windows): merge taskbar icon (AUMID) + focus window on notification click
Taskbar showed two icons because the AppUserModelID was (a) set too late — after
the main window was built, so its taskbar button grouped under a mismatched
implicit AUMID — and (b) valued 'LotusGuild.LotusChat', which differs from the
bundle identifier 'org.lotusguild.lotus-chat' that Tauri's NSIS installer stamps
on its shortcuts. Fix both: set the AUMID at the top of run() before the window
is built (new set_process_aumid, split out of ensure_app_user_model_id), and
align the constant to the bundle identifier so the running window and a pinned
installer shortcut group together.

Notification click didn't raise the window: add a focus_main_window command
(reuses show_main) for the web/service-worker path, and call show_main directly
in the rich-toast Activated body-click handler.

Note: existing users who pinned the old mis-grouped icon may need to re-pin once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:40:57 -04:00

793 lines
34 KiB
Rust

#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tauri::{
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder},
Manager, WebviewUrl,
};
use tauri_plugin_opener::OpenerExt;
mod native;
/// Bring the main window to the foreground from the tray / a hidden /
/// minimized state. Shared by the tray, single-instance, and deep-link paths.
fn show_main(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Hand a `matrix:` / `matrix.to` URL to the web app by dispatching a DOM
/// CustomEvent the client listens for (see useDeepLinkNavigate.ts). Uses
/// `eval` so we don't need the @tauri-apps/api event package on the web side.
fn forward_deeplink(app: &tauri::AppHandle, url: &str) {
// Dedupe: on a cold start the deep-link plugin's `on_open_url` AND the argv
// fallback (`matrix_url_from_args(std::env::args())`) can both forward the
// same launch URL, navigating the room twice (re-show/re-focus + a duplicate
// `lotus-deeplink`). Drop a repeat of the same URL within a short window.
{
use std::sync::Mutex;
use std::time::{Duration, Instant};
static LAST: Mutex<Option<(String, Instant)>> = Mutex::new(None);
if let Ok(mut last) = LAST.lock() {
let now = Instant::now();
if let Some((prev_url, prev_at)) = last.as_ref() {
if prev_url == url && now.duration_since(*prev_at) < Duration::from_millis(1000) {
return;
}
}
*last = Some((url.to_string(), now));
}
}
show_main(app);
if let Some(window) = app.get_webview_window("main") {
if let Ok(json) = serde_json::to_string(url) {
let _ = window.eval(&format!(
"window.dispatchEvent(new CustomEvent('lotus-deeplink',{{detail:{json}}}))"
));
}
}
}
/// Pull the first `matrix:` link out of a process's CLI args (Windows/Linux
/// pass deep-link URLs as argv to a freshly launched instance).
fn matrix_url_from_args(args: &[String]) -> Option<String> {
args.iter().find(|a| a.starts_with("matrix:")).cloned()
}
// Injected into every page before app scripts load.
// Patches window.Notification to route through tauri-plugin-notification so
// WebView2's default "denied" state never reaches cinny's permission check.
// Also patches navigator.permissions.query so the React hook sees "granted".
const NOTIFICATION_BRIDGE: &str = r#"(function(){
function TauriNotification(title,options){
var opts=options||{};
try{
var body=opts.body!=null?String(opts.body):undefined;
// cinny tags message notifications with the roomId (options.tag) and the
// in-app route (options.data.path). When present, route to the rich WinRT
// toast (click-opens-room + quick reply); otherwise a plain toast.
var roomId=opts.tag!=null?String(opts.tag):undefined;
var path=(opts.data&&opts.data.path!=null)?String(opts.data.path):undefined;
if(roomId){
window.__TAURI_INTERNALS__.invoke('show_rich_toast',{
title:String(title),body:body,roomId:roomId,path:path
}).catch(function(){});
}else{
window.__TAURI_INTERNALS__.invoke('send_notification',{
title:String(title),body:body
}).catch(function(){});
}
}catch(_){}
}
TauriNotification.prototype=Object.create(EventTarget.prototype);
TauriNotification.prototype.constructor=TauriNotification;
TauriNotification.prototype.close=function(){};
// get-only 'permission' threw "Cannot set property permission ... which has
// only a getter" when the notification plugin / a polyfill assigned it. Add a
// no-op setter so the value stays 'granted' but assignment can't crash.
Object.defineProperty(TauriNotification,'permission',{get:function(){return 'granted';},set:function(){},configurable:true});
TauriNotification.requestPermission=function(){return Promise.resolve('granted');};
TauriNotification.maxActions=0;
Object.defineProperty(window,'Notification',{value:TauriNotification,writable:true,configurable:true});
var _q=navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query=function(desc){
if(desc&&desc.name==='notifications'){
return Promise.resolve(Object.assign(new EventTarget(),{state:'granted',onchange:null}));
}
return _q(desc);
};
})();"#;
#[tauri::command]
fn send_notification(
app: tauri::AppHandle,
title: String,
body: Option<String>,
) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
let mut builder = app.notification().builder().title(&title);
if let Some(b) = &body {
builder = builder.body(b);
}
builder.show().map_err(|e| e.to_string())
}
#[derive(serde::Serialize)]
struct UpdateInfo {
available: bool,
version: Option<String>,
}
#[tauri::command]
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_updater::UpdaterExt;
return match app.updater().map_err(|e| e.to_string())?.check().await {
Ok(Some(update)) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
Ok(None) => Ok(UpdateInfo { available: false, version: None }),
Err(e) => Err(e.to_string()),
};
}
#[cfg(any(target_os = "android", target_os = "ios"))]
Ok(UpdateInfo { available: false, version: None })
}
#[tauri::command]
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_updater::UpdaterExt;
if let Some(update) = app
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
{
update
.download_and_install(|_chunk, _total| {}, || {})
.await
.map_err(|e| e.to_string())?;
// Only reached on a successful download+install (the `?` above bails
// otherwise). Relaunch so the freshly installed version actually
// runs — without this the UI hangs on "installing", and on a Linux
// AppImage the running process is still the old binary. `restart()`
// exits the current process and never returns, so nothing after it
// runs for the update case.
app.restart();
}
}
Ok(())
}
#[tauri::command]
fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
// `window` is only consulted on Windows (needs the HWND for the taskbar
// overlay). Bind it elsewhere so cross-platform builds don't warn.
#[cfg(not(target_os = "windows"))]
let _ = &window;
#[cfg(target_os = "windows")]
{
use windows::{
core::{BOOL, PCWSTR},
Win32::{
Foundation::{COLORREF, HWND, RECT},
Graphics::Gdi::{
CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreateFontW, CreatePen,
CreateSolidBrush, DeleteDC, DeleteObject, DIB_RGB_COLORS, DrawTextW, Ellipse,
ReleaseDC, SelectObject, SetBkMode, SetTextColor, BITMAPINFO,
BITMAPINFOHEADER, BI_RGB, CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_PITCH,
DEFAULT_QUALITY, DT_CENTER, DT_SINGLELINE, DT_VCENTER, FF_DONTCARE,
FW_BOLD, OUT_DEFAULT_PRECIS, PS_NULL, TRANSPARENT,
},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::{CreateIconIndirect, DestroyIcon, HICON, ICONINFO},
},
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
},
};
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
let hicon: Option<HICON> = if count > 0 {
let label = if count > 99 {
"99+".to_string()
} else {
count.to_string()
};
let mut label_wide: Vec<u16> = label.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
let size = 20i32;
let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(None);
let hdc = CreateCompatibleDC(Some(hdc_screen));
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size,
biHeight: -size,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
let hbm_color =
match CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) {
Ok(bm) => bm,
Err(e) => {
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return Err(e.to_string());
}
};
// Zero-init so undrawn pixels are fully transparent (CreateDIBSection
// does not guarantee zeroed memory; garbage bytes cause a black square).
if !bits.is_null() {
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
}
let old_bm = SelectObject(hdc, hbm_color.into());
let hbrush = CreateSolidBrush(COLORREF(0x003030DD));
let old_brush = SelectObject(hdc, hbrush.into());
let hpen = CreatePen(PS_NULL, 0, COLORREF(0));
let old_pen = SelectObject(hdc, hpen.into());
let _ = Ellipse(hdc, 0, 0, size, size);
let hfont = CreateFontW(
14,
0,
0,
0,
FW_BOLD.0 as i32,
0,
0,
0,
DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS,
DEFAULT_QUALITY,
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
windows::core::w!("Segoe UI"),
);
let old_font = SelectObject(hdc, hfont.into());
SetTextColor(hdc, COLORREF(0x00FFFFFF));
let _ = SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT { left: 0, top: 0, right: size, bottom: size };
let label_len = label_wide.len() - 1;
let _ = DrawTextW(
hdc,
&mut label_wide[..label_len],
&mut rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
);
SelectObject(hdc, old_brush);
SelectObject(hdc, old_pen);
SelectObject(hdc, old_font);
SelectObject(hdc, old_bm);
let _ = DeleteObject(hbrush.into());
let _ = DeleteObject(hpen.into());
let _ = DeleteObject(hfont.into());
// GDI drawing leaves the alpha channel at 0 for all pixels.
// Set alpha=255 for every painted pixel so Windows uses per-pixel
// alpha compositing instead of falling back to the opaque mask,
// which would render unpainted corner pixels as a black square.
let pixel_count = (size * size) as usize;
let pixels =
std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
for pixel in pixels.iter_mut() {
if *pixel != 0 {
*pixel |= 0xFF00_0000u32;
}
}
let hbm_mask = CreateBitmap(size, size, 1, 1, None);
if hbm_mask.0 as usize == 0 {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return Err("CreateBitmap failed".to_string());
}
let icon_info = ICONINFO {
fIcon: BOOL(1),
xHotspot: 0,
yHotspot: 0,
hbmMask: hbm_mask,
hbmColor: hbm_color,
};
let hicon = CreateIconIndirect(&icon_info).map_err(|e| {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
e.to_string()
})?;
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
Some(hicon)
}
} else {
None
};
unsafe {
let taskbar: ITaskbarList3 =
CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)
.map_err(|e| e.to_string())?;
taskbar.HrInit().map_err(|e| e.to_string())?;
taskbar
.SetOverlayIcon(hwnd, hicon.unwrap_or_default(), PCWSTR::null())
.map_err(|e| e.to_string())?;
if let Some(icon) = hicon {
let _ = DestroyIcon(icon);
}
}
}
// Linux (P6-1): emit the Unity `LauncherEntry.Update` broadcast signal so
// launchers/docks that speak the com.canonical.Unity.LauncherEntry protocol
// (GNOME "Dash to Dock", KDE task manager, Unity, etc.) render a count
// badge on the app's launcher icon. Best-effort: any D-Bus failure is
// logged and swallowed so a headless/unsupported environment never breaks
// the badge call.
#[cfg(target_os = "linux")]
{
use std::collections::HashMap;
use zbus::zvariant::Value;
// application://<desktop-file-id>.desktop — the installed .desktop
// basename. Tauri v2's Linux bundler names it after mainBinaryName
// ("cinny"), NOT the identifier, so the file is `cinny.desktop`. If the
// badge doesn't attach at runtime, verify against
// /usr/share/applications/ and adjust.
let app_uri = "application://cinny.desktop";
let mut props: HashMap<&str, Value> = HashMap::new();
props.insert("count", Value::from(count as i64));
props.insert("count-visible", Value::from(count > 0));
match zbus::blocking::Connection::session() {
Ok(conn) => {
if let Err(e) = conn.emit_signal(
None::<&str>,
"/com/canonical/unity/launcherentry/lotuschat",
"com.canonical.Unity.LauncherEntry",
"Update",
&(app_uri, props),
) {
eprintln!("badge: Unity LauncherEntry emit failed: {e}");
}
}
Err(e) => eprintln!("badge: D-Bus session connection failed: {e}"),
}
}
Ok(())
}
/// Held in managed state so the tray's unread overlay can be updated at runtime.
/// Keeping the TrayIcon handle here also keeps the tray alive.
struct TrayUnreadState {
tray: tauri::tray::TrayIcon,
base_rgba: Vec<u8>,
width: u32,
height: u32,
}
/// Holds a clone of the tray "Do Not Disturb" `CheckMenuItem` so `get_tray_dnd`
/// can read its live checkstate. The tray only emits `lotus-dnd-changed` on
/// click, but the web `manualDndAtom` is in-memory and resets on every reload,
/// so the web hook re-hydrates from this on mount. `CheckMenuItem` is a cheap
/// clonable handle to the same underlying menu item.
struct TrayDndState(CheckMenuItem<tauri::Wry>);
/// Return the tray DND toggle's current checkstate so the web side can
/// re-hydrate `manualDndAtom` after a reload. Returns `false` when the tray
/// wasn't created (e.g. missing bundled icon) rather than erroring the call.
#[tauri::command]
fn get_tray_dnd(app: tauri::AppHandle) -> bool {
app.try_state::<TrayDndState>()
.map(|s| s.0.is_checked().unwrap_or(false))
.unwrap_or(false)
}
/// Paint a small white-ringed red "unread" dot into the bottom-right corner of
/// an RGBA buffer, in place. Cross-platform (operates on raw pixels).
fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) {
let w = width as i32;
let h = height as i32;
if w <= 0 || h <= 0 {
return;
}
let r = ((w.min(h) as f32) * 0.30) as i32;
let ring = ((r as f32) * 0.18).max(1.0) as i32;
let cx = w - r - ((w as f32) * 0.06) as i32;
let cy = h - r - ((h as f32) * 0.06) as i32;
for y in (cy - r - ring).max(0)..(cy + r + ring).min(h) {
for x in (cx - r - ring).max(0)..(cx + r + ring).min(w) {
let dx = x - cx;
let dy = y - cy;
let dist2 = dx * dx + dy * dy;
let idx = ((y * w + x) as usize) * 4;
if idx + 3 >= rgba.len() {
continue;
}
if dist2 <= r * r {
rgba[idx] = 0xDD;
rgba[idx + 1] = 0x30;
rgba[idx + 2] = 0x30;
rgba[idx + 3] = 0xFF;
} else if dist2 <= (r + ring) * (r + ring) {
rgba[idx] = 0xFF;
rgba[idx + 1] = 0xFF;
rgba[idx + 2] = 0xFF;
rgba[idx + 3] = 0xFF;
}
}
}
}
/// Overlay (or clear) the unread dot on the tray icon.
#[tauri::command]
fn set_tray_unread(unread: bool, state: tauri::State<'_, TrayUnreadState>) -> Result<(), String> {
let mut rgba = state.base_rgba.clone();
if unread {
draw_unread_dot(&mut rgba, state.width, state.height);
}
let icon = tauri::image::Image::new_owned(rgba, state.width, state.height);
state.tray.set_icon(Some(icon)).map_err(|e| e.to_string())
}
/// Flash the taskbar button to draw attention (e.g. a new mention while the
/// window is unfocused). Clears automatically once the window is focused.
#[tauri::command]
fn flash_window(window: tauri::Window) -> Result<(), String> {
window
.request_user_attention(Some(tauri::UserAttentionType::Informational))
.map_err(|e| e.to_string())
}
/// Bring the main window to the foreground. Invoked from the web side when a
/// notification is clicked — the service-worker/page path can't raise the native
/// OS window on its own (a WebView2 `client.focus()` only focuses the document).
#[tauri::command]
fn focus_main_window(app: tauri::AppHandle) {
show_main(&app);
}
pub fn run() {
// Advertise the process AUMID BEFORE the main window is built (in `.setup`,
// below) so the window's taskbar button groups under the same identity as the
// pinned installer shortcut. Must precede the WebviewWindowBuilder.
native::aumid::set_process_aumid();
let port: u16 = 44548;
let context = tauri::generate_context!();
#[allow(unused_mut)]
let mut builder = tauri::Builder::default();
// Single-instance MUST be registered first: a second launch focuses the
// existing window (and forwards any matrix: link) instead of colliding on
// the localhost port. Desktop-only plugin.
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
show_main(app);
if let Some(url) = matrix_url_from_args(&argv) {
forward_deeplink(app, &url);
}
}));
}
builder = builder
.invoke_handler(tauri::generate_handler![
set_badge_count,
set_tray_unread,
get_tray_dnd,
flash_window,
focus_main_window,
send_notification,
check_for_update,
install_update,
native::power::set_call_active,
native::jumplist::set_jump_list,
native::thumbbar::set_thumbbar,
native::smtc::set_smtc_call_state,
native::chrome::set_custom_chrome,
native::chrome::window_minimize,
native::chrome::window_toggle_maximize,
native::chrome::window_start_drag,
native::chrome::window_close,
native::toast::show_rich_toast,
])
.plugin(tauri_plugin_localhost::Builder::new(port).build())
.plugin(
// DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome)
// owns the decorated flag. Letting window-state restore a saved
// decorated=false at startup would re-create the frameless window
// BEFORE lib.rs applies Mica (a broken combination) and before the web
// side has pushed the user's current setting.
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init());
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
// P6-1 launch-on-login. The web side drives it via the plugin's own
// `plugin:autostart|enable`/`disable`/`is-enabled` commands (no wrapper
// command). The MacosLauncher arg is mandatory by the plugin API even
// though macOS is out of scope; `None` = no extra launch args.
builder = builder.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
));
}
builder
.setup(move |app| {
// --- System tray: keeps Lotus Chat running in the background so
// notifications keep arriving after the window is closed-to-tray. ---
// Degrade gracefully if the bundled icon is missing rather than
// panicking at startup (the tray simply isn't created).
if let Some(base_icon) = app.default_window_icon().cloned() {
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?;
// P6-1: Do Not Disturb toggle. The CheckMenuItem auto-flips its
// own checkstate on click; we read the new state and push it to
// the web client, which owns the actual notification-muting.
let dnd_item =
CheckMenuItem::with_id(app, "dnd", "Do Not Disturb", true, false, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let tray_menu =
Menu::with_items(app, &[&open_item, &dnd_item, &separator, &quit_item])?;
// Clone the handle into the menu-event closure so we can query
// is_checked() after the auto-toggle. CheckMenuItem is a cheap
// clonable handle to the same underlying menu item.
let dnd_for_event = dnd_item.clone();
let tray = TrayIconBuilder::with_id("main-tray")
.icon(base_icon.clone())
.tooltip("Lotus Chat")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"open" => show_main(app),
"quit" => app.exit(0),
"dnd" => {
let checked = dnd_for_event.is_checked().unwrap_or(false);
native::emit_to_web(
app,
"lotus-dnd-changed",
&serde_json::to_string(&serde_json::json!({ "active": checked }))
.unwrap_or_default(),
);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
show_main(app);
}
}
}
})
.build(app)?;
// Keep the tray handle (and base icon pixels) in managed state so
// set_tray_unread can re-render the icon at runtime.
let base_rgba = base_icon.rgba().to_vec();
let (width, height) = (base_icon.width(), base_icon.height());
app.manage(TrayUnreadState {
tray,
base_rgba,
width,
height,
});
// Keep a handle to the DND CheckMenuItem so `get_tray_dnd` can
// report its live checkstate for web re-hydration after reload.
app.manage(TrayDndState(dnd_item));
} else {
eprintln!("tray: no bundled window icon; skipping system tray setup");
}
#[cfg(debug_assertions)]
let window_url = WebviewUrl::App(Default::default());
#[cfg(not(debug_assertions))]
let window_url = {
let url = format!("http://localhost:{}", port).parse().unwrap();
WebviewUrl::External(url)
};
let app_handle = app.handle().clone();
// Tracks whether the window's visibility has already been decided:
// set true by the on_page_load reveal (window shown) and by the
// close-to-tray handler (window intentionally hidden). The 8s failsafe
// below only reveals the window if neither of those has happened.
let window_settled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let settled_page_load = window_settled.clone();
let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url)
.title("Lotus Chat")
// First-run defaults; tauri-plugin-window-state restores geometry
// on later launches.
.inner_size(1100.0, 720.0)
.min_inner_size(480.0, 600.0)
.center()
// Start hidden and reveal once the page has painted, to avoid the
// white launch flash.
.visible(false)
.initialization_script(NOTIFICATION_BRIDGE)
.disable_drag_drop_handler()
// P5-42: keep the WebView2 renderer running full-speed while the
// app is closed to the tray, so the Matrix /sync loop and
// notifications aren't throttled/backgrounded by Chromium. Preserves
// Tauri's default WebView2 args (setting this overrides them) and
// appends the Chromium background-throttling disables. Windows-only
// in effect; harmless elsewhere. Does not block system sleep.
.additional_browser_args(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
)
.on_page_load(move |window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
// Reveal only on the FIRST settle: later page loads (e.g. a
// logout reload) must not re-show a window the user has
// since closed to the tray.
if !settled_page_load.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = window.show();
}
}
})
.on_new_window(move |url, _features| {
// Only hand well-known web/mail schemes to the OS opener.
// Forwarding arbitrary schemes (file://, custom protocols)
// bypasses the opener capability scope and reaches the OS.
match url.scheme() {
"http" | "https" | "mailto" => {
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
}
other => {
eprintln!("opener: refusing to open URL with scheme '{other}'");
}
}
NewWindowResponse::Deny
})
.build()?;
// Close-to-tray: hide instead of exiting; the app is quit explicitly
// from the tray menu.
let window_for_close = window.clone();
let settled_close = window_settled.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
// Mark the window state as settled so the failsafe below can't
// re-show a window the user just closed to the tray.
settled_close.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = window_for_close.hide();
}
});
// Failsafe: never leave the window stuck hidden if the page-load
// reveal never fires. Skips if the window state was already settled
// (revealed on page load, or intentionally hidden to the tray).
let window_for_show = window.clone();
let settled_failsafe = window_settled.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(8));
if !settled_failsafe.load(std::sync::atomic::Ordering::SeqCst) {
let _ = window_for_show.show();
}
});
// Deep links (matrix:): route both the cold-start case and the
// already-running case (forwarded via single-instance argv) into the
// web client.
{
use tauri_plugin_deep_link::DeepLinkExt;
// Runtime scheme registration is a Linux/Windows-only API; macOS
// registers the scheme from the bundle config at build time.
#[cfg(any(target_os = "linux", target_os = "windows"))]
let _ = app.deep_link().register_all();
let deep_link_handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
forward_deeplink(&deep_link_handle, url.as_str());
}
});
if let Some(url) = matrix_url_from_args(&std::env::args().collect::<Vec<_>>()) {
forward_deeplink(&app.handle().clone(), &url);
}
}
// Windows 11 Mica backdrop. The app paints an opaque TDS background,
// so this is subtle (mainly window chrome); harmless if unsupported.
#[cfg(target_os = "windows")]
{
let _ = window_vibrancy::apply_mica(&window, Some(true));
}
// Auto-grant camera, microphone, and notification permissions in WebView2.
#[cfg(target_os = "windows")]
window.with_webview(|webview| {
use webview2_com::{
Microsoft::Web::WebView2::Win32::{
COREWEBVIEW2_PERMISSION_KIND,
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
},
PermissionRequestedEventHandler,
};
let controller = webview.controller();
if let Ok(core) = unsafe { controller.CoreWebView2() } {
let handler = PermissionRequestedEventHandler::create(Box::new(
|_sender, args| {
if let Some(args) = args {
let mut kind = COREWEBVIEW2_PERMISSION_KIND(0);
unsafe { args.PermissionKind(&mut kind) }?;
if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA
|| kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS
{
unsafe {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
}?;
}
}
Ok(())
},
));
let mut token = Default::default();
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) };
}
})?;
// Native desktop feature modules (power/call-continuity, etc.).
native::setup(app.handle())?;
Ok(())
})
.run(context)
.expect("error while building tauri application");
}