2026-03-03 23:16:04 +11:00
|
|
|
#![cfg_attr(
|
|
|
|
|
all(not(debug_assertions), target_os = "windows"),
|
|
|
|
|
windows_subsystem = "windows"
|
|
|
|
|
)]
|
|
|
|
|
|
2026-04-13 15:20:44 +08:00
|
|
|
use tauri::{webview::{NewWindowResponse, WebviewWindowBuilder}, WebviewUrl};
|
|
|
|
|
use tauri_plugin_opener::OpenerExt;
|
2026-03-03 23:16:04 +11:00
|
|
|
|
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{
|
|
|
|
|
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<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>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
|
|
|
|
#[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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 17:32:53 -04:00
|
|
|
#[tauri::command]
|
|
|
|
|
fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
{
|
|
|
|
|
use windows::{
|
|
|
|
|
core::PCWSTR,
|
|
|
|
|
Win32::{
|
|
|
|
|
Foundation::{BOOL, COLORREF, HWND, RECT},
|
|
|
|
|
Graphics::Gdi::{
|
|
|
|
|
CreateCompatibleDC, CreateDIBSection, CreatePen, CreateSolidBrush,
|
|
|
|
|
DeleteDC, DeleteObject, DIB_RGB_COLORS, DrawTextW, Ellipse,
|
|
|
|
|
ReleaseDC, SelectObject, SetBkMode, SetTextColor, BITMAPINFO,
|
|
|
|
|
BITMAPINFOHEADER, BI_RGB, DT_CENTER, DT_SINGLELINE, DT_VCENTER,
|
|
|
|
|
PS_NULL, TRANSPARENT, CreateFontW, DEFAULT_CHARSET, DEFAULT_PITCH,
|
|
|
|
|
DEFAULT_QUALITY, CLIP_DEFAULT_PRECIS, FW_BOLD, FF_DONTCARE,
|
|
|
|
|
OUT_DEFAULT_PRECIS,
|
|
|
|
|
},
|
|
|
|
|
UI::{
|
|
|
|
|
Shell::{ITaskbarList3, TaskbarList},
|
|
|
|
|
WindowsAndMessaging::{CreateBitmap, CreateIconIndirect, DestroyIcon, ICONINFO},
|
|
|
|
|
},
|
|
|
|
|
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
|
|
|
|
|
|
|
|
|
|
let hicon = if count > 0 {
|
|
|
|
|
let label = if count > 99 {
|
|
|
|
|
"99+".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
count.to_string()
|
|
|
|
|
};
|
|
|
|
|
let label_wide: Vec<u16> = label.encode_utf16().chain(std::iter::once(0)).collect();
|
|
|
|
|
|
|
|
|
|
unsafe {
|
|
|
|
|
let size = 16i32;
|
|
|
|
|
let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(HWND(std::ptr::null_mut()));
|
|
|
|
|
let hdc = CreateCompatibleDC(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 = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
|
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
let old_bm = SelectObject(hdc, hbm_color);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
let hfont = CreateFontW(
|
|
|
|
|
11, 0, 0, 0, FW_BOLD.0 as i32, 0, 0, 0,
|
|
|
|
|
DEFAULT_CHARSET.0 as u32,
|
|
|
|
|
OUT_DEFAULT_PRECIS.0 as u32,
|
|
|
|
|
CLIP_DEFAULT_PRECIS.0 as u32,
|
|
|
|
|
DEFAULT_QUALITY.0 as u32,
|
|
|
|
|
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
|
|
|
|
|
windows::core::w!("Segoe UI"),
|
|
|
|
|
);
|
|
|
|
|
let old_font = SelectObject(hdc, hfont);
|
|
|
|
|
SetTextColor(hdc, COLORREF(0x00FFFFFF));
|
|
|
|
|
let _ = SetBkMode(hdc, TRANSPARENT);
|
|
|
|
|
let mut rect = RECT { left: 0, top: 0, right: size, bottom: size };
|
|
|
|
|
let _ = DrawTextW(
|
|
|
|
|
hdc,
|
|
|
|
|
&label_wide[..label_wide.len() - 1],
|
|
|
|
|
&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);
|
|
|
|
|
let _ = DeleteObject(hpen);
|
|
|
|
|
let _ = DeleteObject(hfont);
|
|
|
|
|
|
|
|
|
|
let hbm_mask = CreateBitmap(size, size, 1, 1, None)
|
|
|
|
|
.map_err(|e| { let _ = DeleteObject(hbm_color); e.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); let _ = DeleteObject(hbm_mask); e.to_string() })?;
|
|
|
|
|
|
|
|
|
|
let _ = DeleteObject(hbm_color);
|
|
|
|
|
let _ = DeleteObject(hbm_mask);
|
|
|
|
|
let _ = DeleteDC(hdc);
|
|
|
|
|
let _ = ReleaseDC(HWND(std::ptr::null_mut()), 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, PCWSTR::null())
|
|
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(icon) = hicon {
|
|
|
|
|
let _ = DestroyIcon(icon);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 23:16:04 +11:00
|
|
|
pub fn run() {
|
|
|
|
|
let port: u16 = 44548;
|
|
|
|
|
let context = tauri::generate_context!();
|
|
|
|
|
|
2026-06-10 20:31:25 -04:00
|
|
|
#[allow(unused_mut)]
|
|
|
|
|
let mut builder = tauri::Builder::default()
|
|
|
|
|
.invoke_handler(tauri::generate_handler![
|
|
|
|
|
set_badge_count,
|
|
|
|
|
send_notification,
|
|
|
|
|
check_for_update,
|
|
|
|
|
install_update,
|
|
|
|
|
])
|
2026-03-03 23:16:04 +11:00
|
|
|
.plugin(tauri_plugin_localhost::Builder::new(port).build())
|
|
|
|
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
2026-04-13 15:20:44 +08:00
|
|
|
.plugin(tauri_plugin_opener::init())
|
2026-06-10 20:31:25 -04:00
|
|
|
.plugin(tauri_plugin_notification::init());
|
|
|
|
|
|
|
|
|
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
|
|
|
{
|
|
|
|
|
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
builder
|
2026-03-03 23:16:04 +11:00
|
|
|
.setup(move |app| {
|
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-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-10 20:31:25 -04:00
|
|
|
.initialization_script(NOTIFICATION_BRIDGE)
|
2026-05-23 21:52:14 +10:00
|
|
|
.disable_drag_drop_handler()
|
2026-04-13 15:20:44 +08:00
|
|
|
.on_new_window(move |url, _features| {
|
|
|
|
|
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
|
|
|
|
|
NewWindowResponse::Deny
|
2026-06-10 10:05:49 -04:00
|
|
|
})
|
|
|
|
|
.build()?;
|
2026-06-09 18:27:12 -04:00
|
|
|
|
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-03-03 23:16:04 +11:00
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.run(context)
|
|
|
|
|
.expect("error while building tauri application");
|
|
|
|
|
}
|