From 32286d03de23ea32bf7dcefd2560ae18fb66dea7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 10 Jun 2026 20:31:25 -0400 Subject: [PATCH] feat: native notifications + in-app update checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register tauri-plugin-notification; inject initialization script that patches window.Notification to route through the native plugin and always report permission as granted — bypasses WebView2's default deny - Also grant COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS via existing PermissionRequestedEventHandler - Register tauri-plugin-updater; add check_for_update and install_update commands using the pubkey/endpoint already in tauri.conf.json Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/lib.rs | 114 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2583639..21d2d35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,93 @@ use tauri::{webview::{NewWindowResponse, WebviewWindowBuilder}, WebviewUrl}; use tauri_plugin_opener::OpenerExt; +// 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{ + window.__TAURI_INTERNALS__.invoke('send_notification',{ + title:String(title), + body:opts.body!=null?String(opts.body):undefined + }).catch(function(){}); + }catch(_){} + } + TauriNotification.prototype=Object.create(EventTarget.prototype); + TauriNotification.prototype.constructor=TauriNotification; + TauriNotification.prototype.close=function(){}; + Object.defineProperty(TauriNotification,'permission',{get:function(){return 'granted';},configurable:true}); + TauriNotification.requestPermission=function(){return Promise.resolve('granted');}; + TauriNotification.maxActions=0; + Object.defineProperty(window,'Notification',{value:TauriNotification,writable:true,configurable:true}); + var _q=navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query=function(desc){ + if(desc&&desc.name==='notifications'){ + return Promise.resolve(Object.assign(new EventTarget(),{state:'granted',onchange:null})); + } + return _q(desc); + }; +})();"#; + +#[tauri::command] +fn send_notification( + app: tauri::AppHandle, + title: String, + body: Option, +) -> Result<(), String> { + use tauri_plugin_notification::NotificationExt; + let mut builder = app.notification().builder().title(&title); + if let Some(b) = &body { + builder = builder.body(b); + } + builder.show().map_err(|e| e.to_string()) +} + +#[derive(serde::Serialize)] +struct UpdateInfo { + available: bool, + version: Option, +} + +#[tauri::command] +async fn check_for_update(app: tauri::AppHandle) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use tauri_plugin_updater::UpdaterExt; + match app.updater().map_err(|e| e.to_string())?.check().await { + Ok(Some(update)) => { + return Ok(UpdateInfo { available: true, version: Some(update.version) }); + } + Ok(None) => return Ok(UpdateInfo { available: false, version: None }), + Err(e) => return Err(e.to_string()), + } + } + Ok(UpdateInfo { available: false, version: None }) +} + +#[tauri::command] +async fn install_update(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use tauri_plugin_updater::UpdaterExt; + if let Some(update) = app + .updater() + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string())? + { + update + .download_and_install(|_chunk, _total| {}, || {}) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + #[tauri::command] fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { #[cfg(target_os = "windows")] @@ -63,14 +150,12 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { .map_err(|e| e.to_string())?; let old_bm = SelectObject(hdc, hbm_color); - // Red circle (COLORREF is 0x00BBGGRR) let hbrush = CreateSolidBrush(COLORREF(0x003030DD)); let old_brush = SelectObject(hdc, hbrush); let hpen = CreatePen(PS_NULL, 0, COLORREF(0)); let old_pen = SelectObject(hdc, hpen); let _ = Ellipse(hdc, 0, 0, size, size); - // White bold text centered let hfont = CreateFontW( 11, 0, 0, 0, FW_BOLD.0 as i32, 0, 0, 0, DEFAULT_CHARSET.0 as u32, @@ -142,13 +227,26 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { pub fn run() { let port: u16 = 44548; let context = tauri::generate_context!(); - let builder = tauri::Builder::default(); - builder - .invoke_handler(tauri::generate_handler![set_badge_count]) + #[allow(unused_mut)] + let mut builder = tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + set_badge_count, + send_notification, + check_for_update, + install_update, + ]) .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()); + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + } + + builder .setup(move |app| { #[cfg(debug_assertions)] let window_url = WebviewUrl::App(Default::default()); @@ -162,6 +260,7 @@ pub fn run() { let app_handle = app.handle().clone(); let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url) .title("Lotus Chat") + .initialization_script(NOTIFICATION_BRIDGE) .disable_drag_drop_handler() .on_new_window(move |url, _features| { let _ = app_handle.opener().open_url(url.as_str(), None::<&str>); @@ -169,8 +268,7 @@ pub fn run() { }) .build()?; - // Grant camera and microphone to WebView2 automatically. - // Windows requires an explicit PermissionRequested COM event handler. + // Auto-grant camera, microphone, and notification permissions in WebView2. #[cfg(target_os = "windows")] window.with_webview(|webview| { use webview2_com::{ @@ -178,6 +276,7 @@ pub fn run() { COREWEBVIEW2_PERMISSION_KIND, COREWEBVIEW2_PERMISSION_KIND_CAMERA, COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, + COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS, COREWEBVIEW2_PERMISSION_STATE_ALLOW, }, PermissionRequestedEventHandler, @@ -192,6 +291,7 @@ pub fn run() { unsafe { args.PermissionKind(&mut kind) }?; if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE || kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA + || kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS { unsafe { args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)