Files
cinny-desktop/src-tauri/src/lib.rs
Lotus CIandClaude Opus 5.5 e78b5b911d feat(tray): has_tray_host command so the first-close dialog can tell when there is no tray (#37)
On stock GNOME there is no StatusNotifier host, so choosing "Keep
running in the tray" hid the window with no icon to bring it back.
Expose whether org.kde.StatusNotifierWatcher has an owner on the
session bus (always true off Linux); cinny's dialog then defaults to
Quit and words the other option as "running in the background".

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 02:41:03 -04:00

1556 lines
65 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;
pub mod gpu_workarounds;
mod native;
mod secure_session;
#[cfg(any(target_os = "linux", target_os = "windows", test))]
mod webview_permissions;
/// 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 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<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>,
}
/// 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<tauri_plugin_updater::Updater, String> {
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<Option<tauri_plugin_updater::Update>, 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()),
}
}
}
}
/// How this copy of the app gets updated.
///
/// Tauri's updater can replace the Windows install and a Linux AppImage, but
/// not a copy installed by a package manager: it tries to write next to the
/// binary in /usr/bin and fails with "Permission denied (os error 13)"
/// (reported on CachyOS). Those installs update through their package
/// manager instead; the web UI shows the right command.
pub(crate) fn install_kind(linux: bool, appimage: bool, os_release: &str) -> &'static str {
if !linux || appimage {
return "in-app";
}
let field = |key: &str| {
os_release
.lines()
.find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('=')))
.map(|v| v.trim().trim_matches('"').to_ascii_lowercase())
.unwrap_or_default()
};
let ids = format!("{} {}", field("ID"), field("ID_LIKE"));
let has = |name: &str| ids.split_whitespace().any(|w| w == name);
if has("arch") {
"pacman"
} else if has("debian") || has("ubuntu") {
"deb"
} else {
"manual"
}
}
#[tauri::command]
fn update_install_kind() -> &'static str {
let linux = cfg!(target_os = "linux");
let appimage = std::env::var_os("APPIMAGE").is_some();
let os_release = if linux {
std::fs::read_to_string("/etc/os-release").unwrap_or_default()
} else {
String::new()
};
install_kind(linux, appimage, &os_release)
}
#[tauri::command]
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
#[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};
// A package-manager install can't be replaced in place (see
// install_kind); refuse before downloading anything.
let kind = update_install_kind();
if kind != "in-app" {
return Err(format!(
"install: package-managed ({kind}): update Lotus Chat with your package manager"
));
}
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<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,
/// 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<TrayIndicators>,
}
/// 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<String>,
}
/// "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<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)
}
/// cinny-desktop #3: launch-on-login starts the app with this arg, so a login
/// start can stay in the tray instead of opening the window.
const AUTOSTART_ARG: &str = "--autostart";
/// Where the "start minimized when launched at login" choice lives. A plain file
/// in the app config dir, because it is needed natively before the web client
/// (and its settings) has loaded.
fn start_minimized_file(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
app.path()
.app_config_dir()
.ok()
.map(|dir| dir.join("start-minimized"))
}
/// The saved choice; defaults to on (a login start lands in the tray).
fn read_start_minimized(app: &tauri::AppHandle) -> bool {
start_minimized_file(app)
.and_then(|path| std::fs::read_to_string(path).ok())
.map(|value| value.trim() != "0")
.unwrap_or(true)
}
/// cinny-desktop #37: whether a tray host exists to show our tray icon. On
/// stock GNOME there is no StatusNotifier host, so "keep running in the tray"
/// hides the window with no icon to bring it back. Always true off Linux.
#[tauri::command]
fn has_tray_host() -> bool {
#[cfg(target_os = "linux")]
{
let Ok(conn) = zbus::blocking::Connection::session() else {
return false;
};
let Ok(proxy) = zbus::blocking::fdo::DBusProxy::new(&conn) else {
return false;
};
let Ok(name) = zbus::names::BusName::try_from("org.kde.StatusNotifierWatcher") else {
return false;
};
proxy.name_has_owner(name).unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
#[tauri::command]
fn get_start_minimized(app: tauri::AppHandle) -> bool {
read_start_minimized(&app)
}
#[tauri::command]
fn set_start_minimized(app: tauri::AppHandle, value: bool) -> Result<(), String> {
let path = start_minimized_file(&app).ok_or("no app config dir")?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(path, if value { "1" } else { "0" }).map_err(|e| e.to_string())
}
/// cinny-desktop #5: what closing the window does. `Ask` until the user has
/// chosen (the web client shows a one-time dialog); then `Tray` or `Quit`.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CloseBehavior {
Ask,
Tray,
Quit,
}
impl CloseBehavior {
fn as_str(self) -> &'static str {
match self {
CloseBehavior::Ask => "ask",
CloseBehavior::Tray => "tray",
CloseBehavior::Quit => "quit",
}
}
fn parse(value: &str) -> Option<Self> {
match value.trim() {
"tray" => Some(CloseBehavior::Tray),
"quit" => Some(CloseBehavior::Quit),
"ask" => Some(CloseBehavior::Ask),
_ => None,
}
}
}
/// Set once the web client has asked for the close behavior, i.e. it is
/// listening for `lotus-close-requested`. Before that an `Ask` close just goes
/// to the tray (there is nobody to show the dialog).
static CLOSE_DIALOG_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn close_behavior_file(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
app.path()
.app_config_dir()
.ok()
.map(|dir| dir.join("close-behavior"))
}
fn read_close_behavior(app: &tauri::AppHandle) -> CloseBehavior {
close_behavior_file(app)
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|value| CloseBehavior::parse(&value))
.unwrap_or(CloseBehavior::Ask)
}
fn write_close_behavior(app: &tauri::AppHandle, behavior: CloseBehavior) -> Result<(), String> {
let path = close_behavior_file(app).ok_or("no app config dir")?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(path, behavior.as_str()).map_err(|e| e.to_string())
}
fn in_call(app: &tauri::AppHandle) -> bool {
app.try_state::<TrayUnreadState>()
.and_then(|s| s.indicators.lock().ok().map(|i| i.call != TrayCall::None))
.unwrap_or(false)
}
fn hide_to_tray(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
/// Shared by the OS close button (CloseRequested) and the custom title bar's
/// close. A call in progress always goes to the tray so it isn't dropped.
pub(crate) fn handle_close_request(app: &tauri::AppHandle) {
if in_call(app) {
hide_to_tray(app);
return;
}
match read_close_behavior(app) {
CloseBehavior::Tray => hide_to_tray(app),
CloseBehavior::Quit => app.exit(0),
CloseBehavior::Ask => {
if CLOSE_DIALOG_READY.load(std::sync::atomic::Ordering::SeqCst) {
native::emit_to_web(app, "lotus-close-requested", "{}");
} else {
hide_to_tray(app);
}
}
}
}
/// The saved close behavior ("ask" | "tray" | "quit"). Calling it also marks
/// the web client as ready to show the first-close dialog.
#[tauri::command]
fn get_close_behavior(app: tauri::AppHandle) -> String {
CLOSE_DIALOG_READY.store(true, std::sync::atomic::Ordering::SeqCst);
read_close_behavior(&app).as_str().to_string()
}
/// Save the close behavior (from Settings), without closing anything.
#[tauri::command]
fn set_close_behavior(app: tauri::AppHandle, value: String) -> Result<(), String> {
let behavior = CloseBehavior::parse(&value).ok_or("expected ask, tray or quit")?;
write_close_behavior(&app, behavior)
}
/// The first-close dialog's answer: save it and carry out the close.
#[tauri::command]
fn resolve_close_request(app: tauri::AppHandle, value: String) -> Result<(), String> {
let behavior = CloseBehavior::parse(&value).ok_or("expected tray or quit")?;
write_close_behavior(&app, behavior)?;
match behavior {
CloseBehavior::Quit => app.exit(0),
_ => hide_to_tray(&app),
}
Ok(())
}
/// 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<tauri::Wry>,
item: MenuItem<tauri::Wry>,
shown: std::sync::Mutex<bool>,
}
/// 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<String>) -> Result<(), String> {
let Some(state) = app.try_state::<TrayUpdateState>() 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::<TrayUnreadState>() {
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<u64>,
) -> 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,
has_tray_host,
set_tray_unread,
get_tray_dnd,
set_tray_update_ready,
set_tray_call_state,
set_taskbar_progress,
get_close_behavior,
set_close_behavior,
resolve_close_request,
get_start_minimized,
set_start_minimized,
flash_window,
focus_main_window,
send_notification,
check_for_update,
install_update,
update_install_kind,
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,
secure_session::secure_session_supported,
secure_session::secure_session_set,
secure_session::secure_session_get,
secure_session::secure_session_clear,
])
// Bound to 127.0.0.1 explicitly (cinny #43). The app is still loaded as
// http://localhost:{port} (its storage lives under that origin, and the
// engines try 127.0.0.1 for `localhost`); the bundled call page is
// loaded as http://127.0.0.1:{port}, a separate origin on the same
// server. Binding the name `localhost` could pick ::1 only (Windows
// lists it first), and then the call page wouldn't load.
.plugin(
tauri_plugin_localhost::Builder::new(port)
.host("127.0.0.1")
.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.
// VISIBLE is excluded too: the plugin `show()`s the window as soon as
// it is created whenever the saved state was visible (or nothing was
// saved yet) — before the page paints, and even on a launch-on-login
// start that should stay in the tray (cinny-desktop #3). Visibility is
// owned by the page-load reveal + failsafe in `setup`.
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::DECORATIONS
& !tauri_plugin_window_state::StateFlags::VISIBLE,
)
.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. `--autostart` marks a login start
// (cinny-desktop #3).
builder = builder.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]),
));
}
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.
//
// cinny-desktop #3: a launch-on-login start with "start minimized" on
// begins settled, so neither the reveal nor the failsafe shows the
// window — it stays in the tray (sync + notifications running) until
// the tray icon, a second launch or a deep link reveals it.
let hide_on_launch = std::env::args().any(|arg| arg == AUTOSTART_ARG)
&& read_start_minimized(app.handle());
let window_settled =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(hide_on_launch));
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(&webview2_browser_args(
std::env::var("LOTUS_WEBVIEW2_DEBUG_PORT").ok().as_deref(),
))
.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);
handle_close_request(window_for_close.app_handle());
}
});
// 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();
}
});
// Registrations made before the `--autostart` arg existed have no
// args; re-enabling rewrites the OS entry with the current ones.
#[cfg(desktop)]
{
use tauri_plugin_autostart::ManagerExt;
let autolaunch = app.autolaunch();
if autolaunch.is_enabled().unwrap_or(false) {
let _ = autolaunch.enable();
}
}
// 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));
}
// cinny-desktop #22: the app's own page gets the microphone, camera
// and notifications without a prompt; other origins (room widgets,
// link-preview embeds) are refused them. See webview_permissions.
#[cfg(any(target_os = "linux", target_os = "windows"))]
let app_origins = webview_permissions::AppOrigins::new(
port,
app.config().build.dev_url.as_ref(),
);
#[cfg(target_os = "windows")]
window.with_webview({
let app_origins = app_origins.clone();
move |webview| {
use webview2_com::{
Microsoft::Web::WebView2::Win32::{
COREWEBVIEW2_PERMISSION_KIND,
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION,
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
COREWEBVIEW2_PERMISSION_STATE_DENY,
},
PermissionRequestedEventHandler,
};
use webview_permissions::{decide, Decision, Kind, WINDOWS_GRANTS};
let controller = webview.controller();
if let Ok(core) = unsafe { controller.CoreWebView2() } {
let handler = PermissionRequestedEventHandler::create(Box::new(
move |_sender, args| {
if let Some(args) = args {
let mut raw = COREWEBVIEW2_PERMISSION_KIND(0);
unsafe { args.PermissionKind(&mut raw) }?;
let kind = if raw == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|| raw == COREWEBVIEW2_PERMISSION_KIND_CAMERA
{
Kind::Media
} else if raw == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS {
Kind::Notifications
} else if raw == COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION {
Kind::Geolocation
} else {
Kind::Other
};
// The origin of the frame that asked.
let mut uri = windows::core::PWSTR::null();
unsafe { args.Uri(&mut uri) }?;
let uri = webview2_com::take_pwstr(uri);
match decide(kind, &uri, &app_origins, WINDOWS_GRANTS) {
Decision::Allow => unsafe {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
}?,
Decision::Deny => {
eprintln!(
"webview: denied {kind:?} permission to {uri}"
);
unsafe {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)
}?
}
Decision::Default => {}
}
}
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
// answer the permission requests, mirroring the WebView2 handling
// above. WebKitGTK doesn't say which frame asked, so the origin
// checked is the page in the window (see webview_permissions).
#[cfg(target_os = "linux")]
window.with_webview(move |webview| {
use webkit2gtk::glib::prelude::ObjectExt;
use webkit2gtk::{
DeviceInfoPermissionRequest, GeolocationPermissionRequest,
NotificationPermissionRequest, PermissionRequestExt, SettingsExt,
UserMediaPermissionRequest, WebViewExt,
};
use webview_permissions::{decide, Decision, Kind, LINUX_GRANTS};
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(move |wv, request| {
let kind = if request.is::<UserMediaPermissionRequest>() {
Kind::Media
} else if request.is::<DeviceInfoPermissionRequest>() {
Kind::DeviceInfo
} else if request.is::<NotificationPermissionRequest>() {
Kind::Notifications
} else if request.is::<GeolocationPermissionRequest>() {
Kind::Geolocation
} else {
Kind::Other
};
let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default();
match decide(kind, &uri, &app_origins, LINUX_GRANTS) {
Decision::Allow => request.allow(),
// No prompt of our own: anything not granted is denied.
Decision::Deny | Decision::Default => {
eprintln!(
"webview: denied {} to {uri}",
request.type_().name()
);
request.deny();
}
}
true
});
})?;
// Native desktop feature modules (power/call-continuity, etc.).
native::setup(app.handle())?;
Ok(())
})
.run(context)
.expect("error while building tauri application");
}
/// WebView2 browser arguments. Setting them replaces Tauri's defaults, so
/// they're kept, plus the Chromium background-throttling disables (P5-42).
///
/// cinny-desktop #19: `LOTUS_WEBVIEW2_DEBUG_PORT=<port>` in the environment
/// opens WebView2's DevTools port on localhost so the Windows smoke test can
/// drive the installed app, and gives it Chromium's fake capture devices (the
/// CI VM has no microphone; permission requests still go through the app's
/// real PermissionRequested handler). WebView2's own
/// `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` doesn't apply because the app sets
/// its arguments explicitly. Off unless the variable holds a valid port; only
/// whoever launches the app can set it.
pub(crate) fn webview2_browser_args(debug_port: Option<&str>) -> String {
let mut args = String::from(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
);
if let Some(port) = debug_port
.and_then(|p| p.trim().parse::<u16>().ok())
.filter(|p| *p >= 1024)
{
eprintln!("webview: DevTools port {port} open (LOTUS_WEBVIEW2_DEBUG_PORT)");
args.push_str(&format!(
" --remote-debugging-port={port} --use-fake-device-for-media-stream"
));
}
args
}
#[cfg(test)]
mod webview2_args_tests {
use super::webview2_browser_args;
#[test]
fn debug_port_only_when_asked_for_and_valid() {
let base = webview2_browser_args(None);
assert!(base.contains("--disable-renderer-backgrounding"));
assert!(!base.contains("remote-debugging"));
assert!(webview2_browser_args(Some("9222"))
.ends_with(" --remote-debugging-port=9222 --use-fake-device-for-media-stream"));
for bad in ["", "abc", "80", "70000", "9222 --evil", "-1"] {
assert_eq!(webview2_browser_args(Some(bad)), base, "{bad}");
}
}
}
#[cfg(test)]
mod install_kind_tests {
use super::install_kind;
#[test]
fn package_installs_update_through_their_package_manager() {
let cachy = "NAME=\"CachyOS Linux\"\nID=cachyos\nID_LIKE=arch\n";
let arch = "NAME=\"Arch Linux\"\nID=arch\n";
let ubuntu = "NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n";
let debian = "ID=debian\n";
let mint = "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
let fedora = "ID=fedora\n";
assert_eq!(install_kind(true, false, cachy), "pacman");
assert_eq!(install_kind(true, false, arch), "pacman");
assert_eq!(install_kind(true, false, ubuntu), "deb");
assert_eq!(install_kind(true, false, debian), "deb");
assert_eq!(install_kind(true, false, mint), "deb");
assert_eq!(install_kind(true, false, fedora), "manual");
assert_eq!(install_kind(true, false, ""), "manual");
// ID_LIKE mentioning arch only as part of a longer word doesn't count.
assert_eq!(install_kind(true, false, "ID=x\nID_LIKE=archlike\n"), "manual");
}
#[test]
fn appimage_and_windows_update_in_app() {
assert_eq!(install_kind(true, true, "ID=cachyos\nID_LIKE=arch\n"), "in-app");
assert_eq!(install_kind(false, false, ""), "in-app");
}
}
#[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]);
}
}