2026-03-03 23:16:04 +11:00
|
|
|
#![cfg_attr(
|
|
|
|
|
all(not(debug_assertions), target_os = "windows"),
|
|
|
|
|
windows_subsystem = "windows"
|
|
|
|
|
)]
|
|
|
|
|
|
2026-06-13 18:52:37 -04:00
|
|
|
use tauri::{
|
2026-07-02 13:30:24 -04:00
|
|
|
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
|
2026-06-13 18:52:37 -04:00
|
|
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
|
|
|
|
webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder},
|
|
|
|
|
Manager, WebviewUrl,
|
|
|
|
|
};
|
2026-04-13 15:20:44 +08:00
|
|
|
use tauri_plugin_opener::OpenerExt;
|
2026-03-03 23:16:04 +11:00
|
|
|
|
2026-07-01 09:07:38 -04:00
|
|
|
mod native;
|
|
|
|
|
|
2026-06-13 18:52:37 -04:00
|
|
|
/// 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) {
|
2026-07-02 22:57:09 -04:00
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-13 18:52:37 -04:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 20:31:25 -04:00
|
|
|
// 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{
|
2026-07-01 10:01:31 -04:00
|
|
|
var body=opts.body!=null?String(opts.body):undefined;
|
2026-09-23 19:33:30 -04:00
|
|
|
// 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){
|
2026-07-01 10:01:31 -04:00
|
|
|
window.__TAURI_INTERNALS__.invoke('show_rich_toast',{
|
2026-09-23 19:33:30 -04:00
|
|
|
title:String(title),body:body,tag:tag,roomId:roomId,threadId:threadId,path:path
|
2026-07-01 10:01:31 -04:00
|
|
|
}).catch(function(){});
|
|
|
|
|
}else{
|
|
|
|
|
window.__TAURI_INTERNALS__.invoke('send_notification',{
|
|
|
|
|
title:String(title),body:body
|
|
|
|
|
}).catch(function(){});
|
|
|
|
|
}
|
2026-06-10 20:31:25 -04:00
|
|
|
}catch(_){}
|
|
|
|
|
}
|
|
|
|
|
TauriNotification.prototype=Object.create(EventTarget.prototype);
|
|
|
|
|
TauriNotification.prototype.constructor=TauriNotification;
|
|
|
|
|
TauriNotification.prototype.close=function(){};
|
2026-06-30 18:39:50 -04:00
|
|
|
// 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});
|
2026-06-10 20:31:25 -04:00
|
|
|
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>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 20:54:19 -04:00
|
|
|
/// 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()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 20:31:25 -04:00
|
|
|
#[tauri::command]
|
|
|
|
|
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
|
|
|
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
|
|
|
{
|
2026-09-23 20:54:19 -04:00
|
|
|
return match update_retry::check(&app).await? {
|
|
|
|
|
Some(update) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
|
|
|
|
|
None => Ok(UpdateInfo { available: false, version: None }),
|
2026-06-10 22:52:54 -04:00
|
|
|
};
|
2026-06-10 20:31:25 -04:00
|
|
|
}
|
2026-06-10 22:52:54 -04:00
|
|
|
#[cfg(any(target_os = "android", target_os = "ios"))]
|
2026-06-10 20:31:25 -04:00
|
|
|
Ok(UpdateInfo { available: false, version: None })
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 20:54:19 -04:00
|
|
|
/// 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.
|
2026-06-10 20:31:25 -04:00
|
|
|
#[tauri::command]
|
|
|
|
|
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
|
|
|
|
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
|
|
|
{
|
2026-09-23 20:54:19 -04:00
|
|
|
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)
|
2026-06-10 20:31:25 -04:00
|
|
|
.await
|
2026-09-23 20:54:19 -04:00
|
|
|
.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();
|
2026-06-10 20:31:25 -04:00
|
|
|
}
|
2026-09-23 20:54:19 -04:00
|
|
|
#[allow(unreachable_code)]
|
2026-06-10 20:31:25 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 17:32:53 -04:00
|
|
|
#[tauri::command]
|
|
|
|
|
fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
|
2026-07-02 13:30:24 -04:00
|
|
|
// `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;
|
|
|
|
|
|
2026-06-10 17:32:53 -04:00
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
{
|
|
|
|
|
use windows::{
|
2026-06-11 10:19:25 -04:00
|
|
|
core::{BOOL, PCWSTR},
|
2026-06-10 17:32:53 -04:00
|
|
|
Win32::{
|
2026-06-11 10:19:25 -04:00
|
|
|
Foundation::{COLORREF, HWND, RECT},
|
2026-06-10 17:32:53 -04:00
|
|
|
Graphics::Gdi::{
|
2026-06-11 10:19:25 -04:00
|
|
|
CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreateFontW, CreatePen,
|
|
|
|
|
CreateSolidBrush, DeleteDC, DeleteObject, DIB_RGB_COLORS, DrawTextW, Ellipse,
|
2026-06-10 17:32:53 -04:00
|
|
|
ReleaseDC, SelectObject, SetBkMode, SetTextColor, BITMAPINFO,
|
2026-06-11 10:19:25 -04:00
|
|
|
BITMAPINFOHEADER, BI_RGB, CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_PITCH,
|
|
|
|
|
DEFAULT_QUALITY, DT_CENTER, DT_SINGLELINE, DT_VCENTER, FF_DONTCARE,
|
2026-06-11 11:27:06 -04:00
|
|
|
FW_BOLD, OUT_DEFAULT_PRECIS, PS_NULL, TRANSPARENT,
|
2026-06-10 17:32:53 -04:00
|
|
|
},
|
|
|
|
|
UI::{
|
|
|
|
|
Shell::{ITaskbarList3, TaskbarList},
|
2026-06-11 10:19:25 -04:00
|
|
|
WindowsAndMessaging::{CreateIconIndirect, DestroyIcon, HICON, ICONINFO},
|
2026-06-10 17:32:53 -04:00
|
|
|
},
|
|
|
|
|
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
|
|
|
|
|
|
2026-06-11 10:19:25 -04:00
|
|
|
let hicon: Option<HICON> = if count > 0 {
|
2026-06-10 17:32:53 -04:00
|
|
|
let label = if count > 99 {
|
|
|
|
|
"99+".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
count.to_string()
|
|
|
|
|
};
|
2026-06-11 10:19:25 -04:00
|
|
|
let mut label_wide: Vec<u16> = label.encode_utf16().chain(std::iter::once(0)).collect();
|
2026-06-10 17:32:53 -04:00
|
|
|
|
|
|
|
|
unsafe {
|
2026-06-14 13:19:20 -04:00
|
|
|
let size = 20i32;
|
2026-06-11 10:19:25 -04:00
|
|
|
let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(None);
|
|
|
|
|
let hdc = CreateCompatibleDC(Some(hdc_screen));
|
2026-06-10 17:32:53 -04:00
|
|
|
|
|
|
|
|
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();
|
2026-06-11 10:19:25 -04:00
|
|
|
let hbm_color =
|
2026-07-02 00:21:55 -04:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-06-15 00:10:01 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-06-11 10:19:25 -04:00
|
|
|
let old_bm = SelectObject(hdc, hbm_color.into());
|
2026-06-10 17:32:53 -04:00
|
|
|
|
|
|
|
|
let hbrush = CreateSolidBrush(COLORREF(0x003030DD));
|
2026-06-11 10:19:25 -04:00
|
|
|
let old_brush = SelectObject(hdc, hbrush.into());
|
2026-06-10 17:32:53 -04:00
|
|
|
let hpen = CreatePen(PS_NULL, 0, COLORREF(0));
|
2026-06-11 10:19:25 -04:00
|
|
|
let old_pen = SelectObject(hdc, hpen.into());
|
2026-06-10 17:32:53 -04:00
|
|
|
let _ = Ellipse(hdc, 0, 0, size, size);
|
|
|
|
|
|
|
|
|
|
let hfont = CreateFontW(
|
2026-06-14 13:19:20 -04:00
|
|
|
14,
|
2026-06-11 10:19:25 -04:00
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
FW_BOLD.0 as i32,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
DEFAULT_CHARSET,
|
|
|
|
|
OUT_DEFAULT_PRECIS,
|
|
|
|
|
CLIP_DEFAULT_PRECIS,
|
|
|
|
|
DEFAULT_QUALITY,
|
2026-06-11 11:27:06 -04:00
|
|
|
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
|
2026-06-10 17:32:53 -04:00
|
|
|
windows::core::w!("Segoe UI"),
|
|
|
|
|
);
|
2026-06-11 10:19:25 -04:00
|
|
|
let old_font = SelectObject(hdc, hfont.into());
|
2026-06-10 17:32:53 -04:00
|
|
|
SetTextColor(hdc, COLORREF(0x00FFFFFF));
|
|
|
|
|
let _ = SetBkMode(hdc, TRANSPARENT);
|
|
|
|
|
let mut rect = RECT { left: 0, top: 0, right: size, bottom: size };
|
2026-06-11 10:19:25 -04:00
|
|
|
let label_len = label_wide.len() - 1;
|
2026-06-10 17:32:53 -04:00
|
|
|
let _ = DrawTextW(
|
|
|
|
|
hdc,
|
2026-06-11 10:19:25 -04:00
|
|
|
&mut label_wide[..label_len],
|
2026-06-10 17:32:53 -04:00
|
|
|
&mut rect,
|
|
|
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
SelectObject(hdc, old_brush);
|
|
|
|
|
SelectObject(hdc, old_pen);
|
|
|
|
|
SelectObject(hdc, old_font);
|
|
|
|
|
SelectObject(hdc, old_bm);
|
2026-06-11 10:19:25 -04:00
|
|
|
let _ = DeleteObject(hbrush.into());
|
|
|
|
|
let _ = DeleteObject(hpen.into());
|
|
|
|
|
let _ = DeleteObject(hfont.into());
|
2026-06-10 17:32:53 -04:00
|
|
|
|
2026-06-14 13:19:20 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 11:27:06 -04:00
|
|
|
let hbm_mask = CreateBitmap(size, size, 1, 1, None);
|
|
|
|
|
if hbm_mask.0 as usize == 0 {
|
|
|
|
|
let _ = DeleteObject(hbm_color.into());
|
2026-07-02 00:21:55 -04:00
|
|
|
let _ = DeleteDC(hdc);
|
|
|
|
|
let _ = ReleaseDC(None, hdc_screen);
|
2026-06-11 11:27:06 -04:00
|
|
|
return Err("CreateBitmap failed".to_string());
|
|
|
|
|
}
|
2026-06-10 17:32:53 -04:00
|
|
|
|
|
|
|
|
let icon_info = ICONINFO {
|
|
|
|
|
fIcon: BOOL(1),
|
|
|
|
|
xHotspot: 0,
|
|
|
|
|
yHotspot: 0,
|
|
|
|
|
hbmMask: hbm_mask,
|
|
|
|
|
hbmColor: hbm_color,
|
|
|
|
|
};
|
2026-06-11 10:19:25 -04:00
|
|
|
let hicon = CreateIconIndirect(&icon_info).map_err(|e| {
|
|
|
|
|
let _ = DeleteObject(hbm_color.into());
|
|
|
|
|
let _ = DeleteObject(hbm_mask.into());
|
2026-07-02 00:21:55 -04:00
|
|
|
let _ = DeleteDC(hdc);
|
|
|
|
|
let _ = ReleaseDC(None, hdc_screen);
|
2026-06-11 10:19:25 -04:00
|
|
|
e.to_string()
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let _ = DeleteObject(hbm_color.into());
|
|
|
|
|
let _ = DeleteObject(hbm_mask.into());
|
2026-06-10 17:32:53 -04:00
|
|
|
let _ = DeleteDC(hdc);
|
2026-06-11 10:19:25 -04:00
|
|
|
let _ = ReleaseDC(None, hdc_screen);
|
2026-06-10 17:32:53 -04:00
|
|
|
|
|
|
|
|
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
|
2026-06-11 10:19:25 -04:00
|
|
|
.SetOverlayIcon(hwnd, hicon.unwrap_or_default(), PCWSTR::null())
|
2026-06-10 17:32:53 -04:00
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(icon) = hicon {
|
|
|
|
|
let _ = DestroyIcon(icon);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-02 13:30:24 -04:00
|
|
|
|
|
|
|
|
// 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}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 17:32:53 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 23:53:52 -04:00
|
|
|
/// 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,
|
2026-09-23 21:54:04 -04:00
|
|
|
/// 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())
|
2026-06-13 23:53:52 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 20:46:52 -04:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 21:54:04 -04:00
|
|
|
/// 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]) {
|
2026-06-13 23:53:52 -04:00
|
|
|
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;
|
2026-09-23 21:54:04 -04:00
|
|
|
let inset = ((w as f32) * 0.06) as i32;
|
|
|
|
|
let cx = if left { r + inset } else { w - r - inset };
|
2026-06-13 23:53:52 -04:00
|
|
|
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 {
|
2026-09-23 21:54:04 -04:00
|
|
|
rgba[idx] = color[0];
|
|
|
|
|
rgba[idx + 1] = color[1];
|
|
|
|
|
rgba[idx + 2] = color[2];
|
2026-06-13 23:53:52 -04:00
|
|
|
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> {
|
2026-09-23 21:54:04 -04:00
|
|
|
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;
|
2026-06-13 23:53:52 -04:00
|
|
|
}
|
2026-09-23 21:54:04 -04:00
|
|
|
refresh_tray(&state)
|
2026-06-13 23:53:52 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-23 21:32:39 -04:00
|
|
|
/// 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())?;
|
2026-09-23 21:54:04 -04:00
|
|
|
match version.clone() {
|
2026-09-23 21:32:39 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-23 21:54:04 -04:00
|
|
|
drop(shown);
|
|
|
|
|
if let Some(tray) = app.try_state::<TrayUnreadState>() {
|
|
|
|
|
tray.indicators.lock().map_err(|e| e.to_string())?.update = version;
|
|
|
|
|
refresh_tray(&tray)?;
|
|
|
|
|
}
|
2026-09-23 21:32:39 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 22:04:54 -04:00
|
|
|
/// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 23:53:52 -04:00
|
|
|
/// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 23:35:57 -04:00
|
|
|
/// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 23:16:04 +11:00
|
|
|
pub fn run() {
|
2026-07-15 23:35:57 -04:00
|
|
|
// 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();
|
|
|
|
|
|
2026-03-03 23:16:04 +11:00
|
|
|
let port: u16 = 44548;
|
|
|
|
|
let context = tauri::generate_context!();
|
|
|
|
|
|
2026-06-10 20:31:25 -04:00
|
|
|
#[allow(unused_mut)]
|
2026-06-13 18:52:37 -04:00
|
|
|
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
|
2026-06-10 20:31:25 -04:00
|
|
|
.invoke_handler(tauri::generate_handler![
|
|
|
|
|
set_badge_count,
|
2026-06-13 23:53:52 -04:00
|
|
|
set_tray_unread,
|
2026-07-02 20:46:52 -04:00
|
|
|
get_tray_dnd,
|
2026-09-23 21:32:39 -04:00
|
|
|
set_tray_update_ready,
|
2026-09-23 21:54:04 -04:00
|
|
|
set_tray_call_state,
|
2026-09-23 22:04:54 -04:00
|
|
|
set_taskbar_progress,
|
2026-06-13 23:53:52 -04:00
|
|
|
flash_window,
|
2026-07-15 23:35:57 -04:00
|
|
|
focus_main_window,
|
2026-06-10 20:31:25 -04:00
|
|
|
send_notification,
|
|
|
|
|
check_for_update,
|
|
|
|
|
install_update,
|
2026-07-01 09:07:38 -04:00
|
|
|
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,
|
2026-07-01 10:01:31 -04:00
|
|
|
native::toast::show_rich_toast,
|
2026-09-23 19:33:30 -04:00
|
|
|
native::focus_assist::get_focus_assist,
|
2026-09-17 13:40:42 -04:00
|
|
|
native::hotkeys::global_hotkeys_supported,
|
|
|
|
|
native::hotkeys::set_global_hotkeys,
|
2026-06-10 20:31:25 -04:00
|
|
|
])
|
2026-03-03 23:16:04 +11:00
|
|
|
.plugin(tauri_plugin_localhost::Builder::new(port).build())
|
2026-07-01 16:42:17 -04:00
|
|
|
.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(),
|
|
|
|
|
)
|
2026-04-13 15:20:44 +08:00
|
|
|
.plugin(tauri_plugin_opener::init())
|
2026-06-13 18:52:37 -04:00
|
|
|
.plugin(tauri_plugin_notification::init())
|
|
|
|
|
.plugin(tauri_plugin_deep_link::init());
|
2026-06-10 20:31:25 -04:00
|
|
|
|
|
|
|
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
|
|
|
{
|
|
|
|
|
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
2026-07-02 13:30:24 -04:00
|
|
|
// 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,
|
|
|
|
|
));
|
2026-06-10 20:31:25 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
builder
|
2026-03-03 23:16:04 +11:00
|
|
|
.setup(move |app| {
|
2026-09-17 13:40:42 -04:00
|
|
|
// 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());
|
|
|
|
|
|
2026-06-13 18:52:37 -04:00
|
|
|
// --- System tray: keeps Lotus Chat running in the background so
|
|
|
|
|
// notifications keep arriving after the window is closed-to-tray. ---
|
2026-07-02 00:21:55 -04:00
|
|
|
// 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>)?;
|
2026-07-02 13:30:24 -04:00
|
|
|
// 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>)?;
|
2026-07-02 00:21:55 -04:00
|
|
|
let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
|
|
|
|
|
let separator = PredefinedMenuItem::separator(app)?;
|
2026-07-02 13:30:24 -04:00
|
|
|
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();
|
2026-07-02 00:21:55 -04:00
|
|
|
let tray = TrayIconBuilder::with_id("main-tray")
|
|
|
|
|
.icon(base_icon.clone())
|
|
|
|
|
.tooltip("Lotus Chat")
|
|
|
|
|
.menu(&tray_menu)
|
|
|
|
|
.show_menu_on_left_click(false)
|
2026-07-02 13:30:24 -04:00
|
|
|
.on_menu_event(move |app, event| match event.id.as_ref() {
|
2026-07-02 00:21:55 -04:00
|
|
|
"open" => show_main(app),
|
|
|
|
|
"quit" => app.exit(0),
|
2026-09-23 21:32:39 -04:00
|
|
|
"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", "{}");
|
|
|
|
|
}
|
2026-07-02 13:30:24 -04:00
|
|
|
"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(),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-02 00:21:55 -04:00
|
|
|
_ => {}
|
|
|
|
|
})
|
|
|
|
|
.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);
|
|
|
|
|
}
|
2026-06-13 18:52:37 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-02 00:21:55 -04:00
|
|
|
})
|
|
|
|
|
.build(app)?;
|
2026-06-13 18:52:37 -04:00
|
|
|
|
2026-07-02 00:21:55 -04:00
|
|
|
// 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());
|
2026-06-13 23:53:52 -04:00
|
|
|
app.manage(TrayUnreadState {
|
|
|
|
|
tray,
|
|
|
|
|
base_rgba,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
2026-09-23 21:54:04 -04:00
|
|
|
indicators: std::sync::Mutex::new(TrayIndicators::default()),
|
2026-06-13 23:53:52 -04:00
|
|
|
});
|
2026-07-02 20:46:52 -04:00
|
|
|
|
|
|
|
|
// 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));
|
2026-09-23 21:32:39 -04:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
});
|
2026-07-02 00:21:55 -04:00
|
|
|
} else {
|
|
|
|
|
eprintln!("tray: no bundled window icon; skipping system tray setup");
|
2026-06-13 23:53:52 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 21:29:22 +08:00
|
|
|
#[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)
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 15:20:44 +08:00
|
|
|
let app_handle = app.handle().clone();
|
2026-07-02 00:21:55 -04:00
|
|
|
// 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();
|
2026-06-10 10:05:49 -04:00
|
|
|
let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url)
|
2026-06-10 16:56:18 -04:00
|
|
|
.title("Lotus Chat")
|
2026-06-13 18:52:37 -04:00
|
|
|
// 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)
|
2026-06-10 20:31:25 -04:00
|
|
|
.initialization_script(NOTIFICATION_BRIDGE)
|
2026-05-23 21:52:14 +10:00
|
|
|
.disable_drag_drop_handler()
|
2026-07-01 10:25:16 -04:00
|
|
|
// 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(
|
2026-07-02 00:21:55 -04:00
|
|
|
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
|
2026-07-01 10:25:16 -04:00
|
|
|
)
|
2026-07-02 00:21:55 -04:00
|
|
|
.on_page_load(move |window, payload| {
|
2026-06-13 18:52:37 -04:00
|
|
|
if matches!(payload.event(), PageLoadEvent::Finished) {
|
2026-07-02 00:21:55 -04:00
|
|
|
// 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();
|
|
|
|
|
}
|
2026-06-13 18:52:37 -04:00
|
|
|
}
|
|
|
|
|
})
|
2026-04-13 15:20:44 +08:00
|
|
|
.on_new_window(move |url, _features| {
|
2026-07-02 00:21:55 -04:00
|
|
|
// 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}'");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-13 15:20:44 +08:00
|
|
|
NewWindowResponse::Deny
|
2026-06-10 10:05:49 -04:00
|
|
|
})
|
|
|
|
|
.build()?;
|
2026-06-09 18:27:12 -04:00
|
|
|
|
2026-06-13 18:52:37 -04:00
|
|
|
// Close-to-tray: hide instead of exiting; the app is quit explicitly
|
|
|
|
|
// from the tray menu.
|
|
|
|
|
let window_for_close = window.clone();
|
2026-07-02 00:21:55 -04:00
|
|
|
let settled_close = window_settled.clone();
|
2026-06-13 18:52:37 -04:00
|
|
|
window.on_window_event(move |event| {
|
|
|
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
|
|
|
api.prevent_close();
|
2026-07-02 00:21:55 -04:00
|
|
|
// 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);
|
2026-06-13 18:52:37 -04:00
|
|
|
let _ = window_for_close.hide();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Failsafe: never leave the window stuck hidden if the page-load
|
2026-07-02 00:21:55 -04:00
|
|
|
// reveal never fires. Skips if the window state was already settled
|
|
|
|
|
// (revealed on page load, or intentionally hidden to the tray).
|
2026-06-13 18:52:37 -04:00
|
|
|
let window_for_show = window.clone();
|
2026-07-02 00:21:55 -04:00
|
|
|
let settled_failsafe = window_settled.clone();
|
2026-06-13 18:52:37 -04:00
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(8));
|
2026-07-02 00:21:55 -04:00
|
|
|
if !settled_failsafe.load(std::sync::atomic::Ordering::SeqCst) {
|
|
|
|
|
let _ = window_for_show.show();
|
|
|
|
|
}
|
2026-06-13 18:52:37 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 20:31:25 -04:00
|
|
|
// Auto-grant camera, microphone, and notification permissions in WebView2.
|
2026-06-09 21:51:56 -04:00
|
|
|
#[cfg(target_os = "windows")]
|
2026-06-10 10:05:49 -04:00
|
|
|
window.with_webview(|webview| {
|
2026-06-09 23:31:03 -04:00
|
|
|
use webview2_com::{
|
|
|
|
|
Microsoft::Web::WebView2::Win32::{
|
2026-06-10 10:05:49 -04:00
|
|
|
COREWEBVIEW2_PERMISSION_KIND,
|
2026-06-09 23:31:03 -04:00
|
|
|
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
|
|
|
|
|
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
|
2026-06-10 20:31:25 -04:00
|
|
|
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
|
2026-06-09 23:31:03 -04:00
|
|
|
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
|
|
|
|
|
},
|
|
|
|
|
PermissionRequestedEventHandler,
|
|
|
|
|
};
|
2026-06-09 21:51:56 -04:00
|
|
|
|
|
|
|
|
let controller = webview.controller();
|
|
|
|
|
if let Ok(core) = unsafe { controller.CoreWebView2() } {
|
2026-06-09 23:31:03 -04:00
|
|
|
let handler = PermissionRequestedEventHandler::create(Box::new(
|
|
|
|
|
|_sender, args| {
|
|
|
|
|
if let Some(args) = args {
|
2026-06-10 00:06:12 -04:00
|
|
|
let mut kind = COREWEBVIEW2_PERMISSION_KIND(0);
|
|
|
|
|
unsafe { args.PermissionKind(&mut kind) }?;
|
2026-06-09 23:31:03 -04:00
|
|
|
if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|
|
|
|
|
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA
|
2026-06-10 20:31:25 -04:00
|
|
|
|| kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS
|
2026-06-09 23:31:03 -04:00
|
|
|
{
|
|
|
|
|
unsafe {
|
|
|
|
|
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
|
|
|
|
|
}?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
},
|
|
|
|
|
));
|
|
|
|
|
let mut token = Default::default();
|
2026-06-09 21:51:56 -04:00
|
|
|
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) };
|
|
|
|
|
}
|
2026-06-10 10:05:49 -04:00
|
|
|
})?;
|
2026-06-09 21:51:56 -04:00
|
|
|
|
2026-08-24 22:08:21 -04:00
|
|
|
// 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
|
|
|
|
|
});
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-07-01 09:07:38 -04:00
|
|
|
// Native desktop feature modules (power/call-continuity, etc.).
|
|
|
|
|
native::setup(app.handle())?;
|
|
|
|
|
|
2026-03-03 23:16:04 +11:00
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.run(context)
|
|
|
|
|
.expect("error while building tauri application");
|
|
|
|
|
}
|
2026-09-23 21:54:04 -04:00
|
|
|
|
|
|
|
|
#[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]);
|
|
|
|
|
}
|
|
|
|
|
}
|