#![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> = 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 { 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 coalesces notifications by options.tag and carries the in-app // route + real Matrix ids in options.data. Anything tagged goes to the // rich WinRT toast (coalescing + click-opens-room); only a real room id // gets a reply box. The tag is NOT a room id — thread toasts tag // `room:thread`, invites 'lotus-invites' (cinny-desktop #17). var data=opts.data||{}; var tag=opts.tag!=null?String(opts.tag):undefined; var roomId=data.roomId!=null?String(data.roomId):undefined; var threadId=data.threadId!=null?String(data.threadId):undefined; var path=data.path!=null?String(data.path):undefined; if(tag||roomId){ window.__TAURI_INTERNALS__.invoke('show_rich_toast',{ title:String(title),body:body,tag:tag,roomId:roomId,threadId:threadId,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, ) -> 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, } /// Update-flow resilience (Cole, 2026-09-23: ten failed "error sending request" /// attempts against a busy Gitea before the 11th went through). Network-level /// failures are retried with backoff inside one click; anything else (bad /// signature, broken package) fails at once so it is never masked. #[cfg(not(any(target_os = "android", target_os = "ios")))] mod update_retry { use std::time::Duration; /// Delays before attempts 2, 3, 4 (so four tries in all). pub const BACKOFF: [Duration; 3] = [ Duration::from_secs(3), Duration::from_secs(8), Duration::from_secs(15), ]; /// Per-request cap. reqwest has no default timeout, so a stalled connection /// could otherwise hang the "installing" state forever. Generous enough for /// the ~50 MB installer on a slow line. pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(600); /// Only transport errors are worth retrying. pub fn is_transient(err: &tauri_plugin_updater::Error) -> bool { use tauri_plugin_updater::Error; matches!( err, Error::Reqwest(_) | Error::Network(_) | Error::ReleaseNotFound ) } pub fn updater( app: &tauri::AppHandle, ) -> Result { use tauri_plugin_updater::UpdaterExt; app.updater_builder() .timeout(REQUEST_TIMEOUT) .build() .map_err(|e| e.to_string()) } /// `check()` with retries. `Ok(None)` = up to date. pub async fn check( app: &tauri::AppHandle, ) -> Result, String> { let updater = updater(app)?; let mut attempt = 0; loop { match updater.check().await { Ok(found) => return Ok(found), Err(e) if is_transient(&e) && attempt < BACKOFF.len() => { tokio::time::sleep(BACKOFF[attempt]).await; attempt += 1; } Err(e) => return Err(e.to_string()), } } } } #[tauri::command] async fn check_for_update(app: tauri::AppHandle) -> Result { #[cfg(not(any(target_os = "android", target_os = "ios")))] { return match update_retry::check(&app).await? { Some(update) => Ok(UpdateInfo { available: true, version: Some(update.version) }), None => Ok(UpdateInfo { available: false, version: None }), }; } #[cfg(any(target_os = "android", target_os = "ios"))] Ok(UpdateInfo { available: false, version: None }) } /// Download (with retries and `lotus-update-progress` events for the web UI), /// then install and relaunch. Errors are prefixed `check:` / `download:` / /// `install:` so the web side can say which step failed. #[tauri::command] async fn install_update(app: tauri::AppHandle) -> Result<(), String> { #[cfg(not(any(target_os = "android", target_os = "ios")))] { use std::time::{Duration, Instant}; let emit = |detail: serde_json::Value| { native::emit_to_web(&app, "lotus-update-progress", &detail.to_string()); }; let Some(update) = update_retry::check(&app) .await .map_err(|e| format!("check: {e}"))? else { return Ok(()); }; let max_attempts = update_retry::BACKOFF.len() + 1; let mut attempt = 0; let bytes = loop { emit(serde_json::json!({ "phase": "downloading", "attempt": attempt + 1, "maxAttempts": max_attempts, "downloaded": 0, "total": null, })); let mut downloaded: u64 = 0; let mut last_emit = Instant::now(); let result = update .download( |chunk, total| { downloaded += chunk as u64; // ~4 Hz is plenty for a progress line. if last_emit.elapsed() >= Duration::from_millis(250) { last_emit = Instant::now(); emit(serde_json::json!({ "phase": "downloading", "attempt": attempt + 1, "maxAttempts": max_attempts, "downloaded": downloaded, "total": total, })); } }, || {}, ) .await; match result { Ok(bytes) => break bytes, Err(e) if update_retry::is_transient(&e) && attempt < update_retry::BACKOFF.len() => { let wait = update_retry::BACKOFF[attempt]; emit(serde_json::json!({ "phase": "retrying", "attempt": attempt + 1, "maxAttempts": max_attempts, "waitSecs": wait.as_secs(), "error": e.to_string(), })); tokio::time::sleep(wait).await; attempt += 1; } Err(e) => return Err(format!("download: {e}")), } }; emit(serde_json::json!({ "phase": "installing" })); update.install(bytes).map_err(|e| format!("install: {e}"))?; // Only reached on a successful install. 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. On Windows the plugin has already exited the process to // run the installer, so this is not reached there. app.restart(); } #[allow(unreachable_code)] 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 = if count > 0 { let label = if count > 99 { "99+".to_string() } else { count.to_string() }; let mut label_wide: Vec = 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::() 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 — 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, width: u32, height: u32, /// Everything the icon + tooltip reflect, re-rendered together so the /// unread dot, call state (#4) and pending update (#6) never clobber /// each other. indicators: std::sync::Mutex, } /// cinny-desktop #4: the call state shown on the tray icon. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum TrayCall { #[default] None, InCall, Muted, Deafened, } #[derive(Default)] struct TrayIndicators { unread: bool, call: TrayCall, update: Option, } /// "Lotus Chat — in call, muted · update ready" fn tray_tooltip(ind: &TrayIndicators) -> String { let mut parts: Vec<&str> = Vec::new(); match ind.call { TrayCall::None => {} TrayCall::InCall => parts.push("in call"), TrayCall::Muted => parts.push("in call, muted"), TrayCall::Deafened => parts.push("in call, deafened"), } if ind.update.is_some() { parts.push("update ready"); } if parts.is_empty() { "Lotus Chat".to_string() } else { format!("Lotus Chat — {}", parts.join(" · ")) } } /// Re-render the tray icon (base + call dot bottom-left + unread dot /// bottom-right) and tooltip from the current indicators. fn refresh_tray(state: &TrayUnreadState) -> Result<(), String> { let ind = state.indicators.lock().map_err(|e| e.to_string())?; let mut rgba = state.base_rgba.clone(); let call_color = match ind.call { TrayCall::None => None, TrayCall::InCall => Some([0x2E, 0xB8, 0x5C]), TrayCall::Muted => Some([0xF0, 0xA0, 0x20]), TrayCall::Deafened => Some([0xD0, 0x30, 0x30]), }; if let Some(color) = call_color { draw_dot(&mut rgba, state.width, state.height, true, color); } if ind.unread { draw_dot(&mut rgba, state.width, state.height, false, [0xDD, 0x30, 0x30]); } let icon = tauri::image::Image::new_owned(rgba, state.width, state.height); state.tray.set_icon(Some(icon)).map_err(|e| e.to_string())?; state .tray .set_tooltip(Some(tray_tooltip(&ind))) .map_err(|e| e.to_string()) } /// 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); /// 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::() .map(|s| s.0.is_checked().unwrap_or(false)) .unwrap_or(false) } /// Paint a small white-ringed dot of `color` into the bottom-right (or, with /// `left`, bottom-left) corner of an RGBA buffer, in place. Cross-platform /// (operates on raw pixels). fn draw_dot(rgba: &mut [u8], width: u32, height: u32, left: bool, color: [u8; 3]) { 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 inset = ((w as f32) * 0.06) as i32; let cx = if left { r + inset } else { w - r - inset }; 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] = color[0]; rgba[idx + 1] = color[1]; rgba[idx + 2] = color[2]; 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> { state.indicators.lock().map_err(|e| e.to_string())?.unread = unread; refresh_tray(&state) } /// cinny-desktop #4: reflect the call state on the tray icon + tooltip. Sent /// from the same web hook as the taskbar thumbbar so the two can't drift. /// Deafened implies muted. #[tauri::command] fn set_tray_call_state( active: bool, muted: bool, deafened: bool, state: tauri::State<'_, TrayUnreadState>, ) -> Result<(), String> { let call = if !active { TrayCall::None } else if deafened { TrayCall::Deafened } else if muted { TrayCall::Muted } else { TrayCall::InCall }; { let mut ind = state.indicators.lock().map_err(|e| e.to_string())?; if ind.call == call { return Ok(()); } ind.call = call; } refresh_tray(&state) } /// cinny-desktop #6: the tray's "Restart to update" item. Inserted at the top /// of the menu only while an update is available, so it isn't a dead entry the /// rest of the time. struct TrayUpdateState { menu: Menu, item: MenuItem, shown: std::sync::Mutex, } /// Show (`Some(version)`) or clear (`None`) the tray's update affordance: the /// menu item and the tooltip. The web client drives it from its update status, /// and clicking the item hands the install back to the web flow /// (`lotus-tray-install-update`) so progress and failures are shown in-app. #[tauri::command] fn set_tray_update_ready(app: tauri::AppHandle, version: Option) -> Result<(), String> { let Some(state) = app.try_state::() else { return Ok(()); }; let mut shown = state.shown.lock().map_err(|e| e.to_string())?; match version.clone() { Some(version) => { state .item .set_text(format!("Restart to update (v{version})")) .map_err(|e| e.to_string())?; if !*shown { state.menu.insert(&state.item, 0).map_err(|e| e.to_string())?; *shown = true; } } None => { if *shown { state.menu.remove(&state.item).map_err(|e| e.to_string())?; *shown = false; } } } drop(shown); if let Some(tray) = app.try_state::() { tray.indicators.lock().map_err(|e| e.to_string())?.update = version; refresh_tray(&tray)?; } Ok(()) } /// cinny-desktop #10: upload progress on the taskbar button (Windows taskbar; /// the launcher/dock elsewhere, where Tauri supports it). `status` is `none`, /// `normal`, `indeterminate` or `error`; `progress` is 0-100. The web side /// aggregates every upload and throttles calls to ~4 Hz. #[tauri::command] fn set_taskbar_progress( app: tauri::AppHandle, status: String, progress: Option, ) -> Result<(), String> { use tauri::window::{ProgressBarState, ProgressBarStatus}; let status = match status.as_str() { "normal" => ProgressBarStatus::Normal, "indeterminate" => ProgressBarStatus::Indeterminate, "error" => ProgressBarStatus::Error, _ => ProgressBarStatus::None, }; let window = app .get_webview_window("main") .ok_or_else(|| "no main window".to_string())?; window .set_progress_bar(ProgressBarState { status: Some(status), progress: progress.map(|p| p.min(100)), }) .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, set_tray_update_ready, set_tray_call_state, set_taskbar_progress, 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, native::focus_assist::get_focus_assist, native::hotkeys::global_hotkeys_supported, native::hotkeys::set_global_hotkeys, ]) .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| { // cinny-desktop #2: system-wide PTT/deafen poll state (idle until the // web side registers bindings on call join). Managed unconditionally // so the commands can never hit a missing-state panic. app.manage(native::hotkeys::HotkeyPoll::default()); // --- 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), "update" => { // Show the window so the download progress (and any // failure) is visible; the web side runs the install. show_main(app); native::emit_to_web(app, "lotus-tray-install-update", "{}"); } "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, indicators: std::sync::Mutex::new(TrayIndicators::default()), }); // 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)); app.manage(TrayUpdateState { menu: tray_menu.clone(), item: MenuItem::with_id(app, "update", "Restart to update", true, None::<&str>)?, shown: std::sync::Mutex::new(false), }); } 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::>()) { 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) }; } })?; // WebKitGTK ships `enable-media-stream`/`enable-webrtc` OFF by // default (unlike WebView2/WKWebView), which leaves // `navigator.mediaDevices` undefined and makes Element Call // report "browser does not support WebRTC". Turn them on and // auto-grant the resulting camera/mic permission prompt, mirroring // the WebView2 handling above. #[cfg(target_os = "linux")] window.with_webview(|webview| { use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt}; let wv = webview.inner(); if let Some(settings) = WebViewExt::settings(&wv) { settings.set_enable_media_stream(true); settings.set_enable_webrtc(true); } wv.connect_permission_request(|_webview, request| { request.allow(); true }); })?; // Native desktop feature modules (power/call-continuity, etc.). native::setup(app.handle())?; Ok(()) }) .run(context) .expect("error while building tauri application"); } #[cfg(test)] mod tray_tests { use super::*; #[test] fn tooltip_reflects_call_and_update() { let mut ind = TrayIndicators::default(); assert_eq!(tray_tooltip(&ind), "Lotus Chat"); ind.call = TrayCall::Muted; assert_eq!(tray_tooltip(&ind), "Lotus Chat — in call, muted"); ind.update = Some("4.13.0".into()); assert_eq!(tray_tooltip(&ind), "Lotus Chat — in call, muted · update ready"); ind.call = TrayCall::None; assert_eq!(tray_tooltip(&ind), "Lotus Chat — update ready"); } #[test] fn dots_land_in_opposite_bottom_corners() { let (w, h) = (32u32, 32u32); let px = |rgba: &[u8], x: u32, y: u32| { let i = ((y * w + x) * 4) as usize; [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] }; let mut rgba = vec![0u8; (w * h * 4) as usize]; draw_dot(&mut rgba, w, h, true, [1, 2, 3]); draw_dot(&mut rgba, w, h, false, [4, 5, 6]); // Dot centres: r = 9, inset = 1 → left (10, 21), right (21, 21). assert_eq!(px(&rgba, 10, 21), [1, 2, 3, 0xFF]); assert_eq!(px(&rgba, 21, 21), [4, 5, 6, 0xFF]); // Top-left stays untouched. assert_eq!(px(&rgba, 2, 2), [0, 0, 0, 0]); } }