feat: Windows-native desktop polish (tray, single-instance, deep links, Mica, installer)
- System tray with Open/Quit menu + left-click toggle; closing the window now minimizes to tray instead of quitting, so notifications keep arriving. - Single-instance: a second launch focuses the running window (and forwards a matrix: link) instead of colliding on the localhost port. - Window: 1100x720 default, 480x600 min, centered first run; starts hidden and shows on page-load to kill the white launch flash (8s failsafe). - matrix: deep links via tauri-plugin-deep-link -> dispatched to the web client (useDeepLinkNavigate) for both cold-start and already-running cases. - Windows 11 Mica backdrop (subtle; app paints opaque TDS bg). - NSIS installer: per-user install (no UAC), downloadBootstrapper. - Remove dead/broken src/menu.rs. - Bump cinny submodule to 053b364a (deep-link web handler). Note: Rust not compiled locally (no toolchain / Windows-only paths); verified by careful API review against tauri 2.10 — needs a real 'tauri build' to confirm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule cinny updated: 3282832a4a...053b364a44
@@ -29,6 +29,7 @@ tauri-plugin-process = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
@@ -41,9 +42,11 @@ custom-protocol = [ "tauri/custom-protocol" ]
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
webview2-com = "0.38"
|
||||
window-vibrancy = "0.6"
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"global-shortcut:default"
|
||||
"global-shortcut:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
+147
-3
@@ -3,9 +3,44 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use tauri::{webview::{NewWindowResponse, WebviewWindowBuilder}, WebviewUrl};
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder},
|
||||
Manager, WebviewUrl,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
/// 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) {
|
||||
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.
|
||||
@@ -242,7 +277,22 @@ pub fn run() {
|
||||
let context = tauri::generate_context!();
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = tauri::Builder::default()
|
||||
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,
|
||||
send_notification,
|
||||
@@ -252,7 +302,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_localhost::Builder::new(port).build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_deep_link::init());
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
@@ -261,6 +312,41 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.setup(move |app| {
|
||||
// --- System tray: keeps Lotus Chat running in the background so
|
||||
// notifications keep arriving after the window is closed-to-tray. ---
|
||||
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, 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, &separator, &quit_item])?;
|
||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||
.icon(app.default_window_icon().cloned().unwrap())
|
||||
.tooltip("Lotus Chat")
|
||||
.menu(&tray_menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.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)?;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let window_url = WebviewUrl::App(Default::default());
|
||||
|
||||
@@ -273,14 +359,72 @@ pub fn run() {
|
||||
let app_handle = app.handle().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()
|
||||
.on_page_load(|window, payload| {
|
||||
if matches!(payload.event(), PageLoadEvent::Finished) {
|
||||
let _ = window.show();
|
||||
}
|
||||
})
|
||||
.on_new_window(move |url, _features| {
|
||||
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
|
||||
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();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window_for_close.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Failsafe: never leave the window stuck hidden if the page-load
|
||||
// event doesn't fire for some reason.
|
||||
let window_for_show = window.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(8));
|
||||
let _ = window_for_show.show();
|
||||
});
|
||||
|
||||
// Deep links (matrix:): route both the cold-start case and the
|
||||
// already-running case (forwarded via single-instance argv) into the
|
||||
// web client.
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
// Runtime scheme registration is a Linux/Windows-only API; macOS
|
||||
// registers the scheme from the bundle config at build time.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
let _ = app.deep_link().register_all();
|
||||
let deep_link_handle = app.handle().clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
forward_deeplink(&deep_link_handle, url.as_str());
|
||||
}
|
||||
});
|
||||
if let Some(url) = matrix_url_from_args(&std::env::args().collect::<Vec<_>>()) {
|
||||
forward_deeplink(&app.handle().clone(), &url);
|
||||
}
|
||||
}
|
||||
|
||||
// Windows 11 Mica backdrop. The app paints an opaque TDS background,
|
||||
// so this is subtle (mainly window chrome); harmless if unsupported.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window_vibrancy::apply_mica(&window, Some(true));
|
||||
}
|
||||
|
||||
// Auto-grant camera, microphone, and notification permissions in WebView2.
|
||||
#[cfg(target_os = "windows")]
|
||||
window.with_webview(|webview| {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
use tauri::menu::{MenuBuilder, SubmenuBuilder};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub fn menu() -> tauri::menu::Menu {
|
||||
let app_menu = SubmenuBuilder::new(app, "Cinny")
|
||||
.about(Some(Default::default()))
|
||||
.separator()
|
||||
.hide()
|
||||
.hide_others()
|
||||
.show_all()
|
||||
.separator()
|
||||
.quit()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
.redo()
|
||||
.separator()
|
||||
.cut()
|
||||
.copy()
|
||||
.paste()
|
||||
.select_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let view_menu = SubmenuBuilder::new(app, "View")
|
||||
.fullscreen() // `.fullscreen()` works instead of `.enter_fullscreen()`
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.build() // no `.zoom()` method directly available
|
||||
.unwrap();
|
||||
|
||||
MenuBuilder::new(app)
|
||||
.item(&app_menu)
|
||||
.item(&edit_menu)
|
||||
.item(&view_menu)
|
||||
.item(&window_menu)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "",
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
},
|
||||
"wix": {
|
||||
"bannerPath": "wix/banner.bmp",
|
||||
"dialogImagePath": "wix/dialogImage.bmp"
|
||||
@@ -55,6 +61,11 @@
|
||||
"endpoints": [
|
||||
"https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/release.json"
|
||||
]
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["matrix"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
|
||||
Reference in New Issue
Block a user