diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ecaa65f..e45218d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,9 +48,19 @@ tauri-plugin-single-instance = "2" webview2-com = "0.38" window-vibrancy = "0.6" windows = { version = "0.61", features = [ + # WinRT namespaces (SMTC — P5-43) + "Foundation", + "Media", + # Win32 namespaces + "Win32_Foundation", "Win32_Graphics_Gdi", + "Win32_Networking_NetworkListManager", # P5-49 network awareness "Win32_System_Com", + "Win32_System_Com_StructuredStorage", # P5-36 jump list (PROPVARIANT) + "Win32_System_Power", # P5-46 no-sleep + "Win32_System_WinRT", # P5-43 SMTC interop "Win32_UI_Shell", + "Win32_UI_Shell_PropertiesSystem", # P5-36 jump list (IPropertyStore/PKEY_Title) "Win32_UI_WindowsAndMessaging", ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 306f17f..7474b02 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ use tauri::{ }; use tauri_plugin_opener::OpenerExt; +mod native; + /// 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) { @@ -386,6 +388,15 @@ pub fn run() { send_notification, check_for_update, install_update, + 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, ]) .plugin(tauri_plugin_localhost::Builder::new(port).build()) .plugin(tauri_plugin_window_state::Builder::default().build()) @@ -565,6 +576,9 @@ pub fn run() { } })?; + // Native desktop feature modules (power/call-continuity, etc.). + native::setup(app.handle())?; + Ok(()) }) .run(context) diff --git a/src-tauri/src/native/chrome.rs b/src-tauri/src/native/chrome.rs new file mode 100644 index 0000000..2a80cc5 --- /dev/null +++ b/src-tauri/src/native/chrome.rs @@ -0,0 +1,68 @@ +//! P5-47 — TDS Custom Window Chrome (opt-in, runtime-reversible). +//! +//! When the user opts into custom window chrome, the web client renders its own +//! `` (folds/TDS styled) and we strip the OS-native window frame so +//! the two don't stack. This is entirely opt-in: the window is built with native +//! `decorations(true)` and only `set_custom_chrome(true)` makes it frameless, so +//! the safe default is the untouched native frame. +//! +//! Everything here goes through the cross-platform Tauri v2 window API — there is +//! no `windows` crate dependency, so the same code path runs on Windows, macOS +//! and Linux. Each command resolves the "main" window and silently no-ops if it +//! isn't present (e.g. during teardown); the `Result`s are intentionally ignored +//! since a failed chrome tweak should never surface as an error to the user. + +use tauri::{AppHandle, Manager}; + +/// Toggle the native window frame. `enabled` = custom chrome on, which means the +/// OS decorations must come **off** (`set_decorations(!enabled)`). Passing +/// `false` restores the native frame, making the feature fully reversible at +/// runtime without a restart. +#[tauri::command] +pub fn set_custom_chrome(app: AppHandle, enabled: bool) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_decorations(!enabled); + } +} + +/// Minimize the main window (custom titlebar min button). +#[tauri::command] +pub fn window_minimize(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.minimize(); + } +} + +/// Toggle maximize/restore the main window (custom titlebar max button and +/// drag-region double-click). +#[tauri::command] +pub fn window_toggle_maximize(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if window.is_maximized().unwrap_or(false) { + let _ = window.unmaximize(); + } else { + let _ = window.maximize(); + } + } +} + +/// Begin an OS-level window drag from the custom titlebar drag region. The web +/// side also marks the drag area with `data-tauri-drag-region`; this command is +/// the explicit fallback so behaviour is identical across platforms. +#[tauri::command] +pub fn window_start_drag(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.start_dragging(); + } +} + +/// Close from the custom titlebar. Mirrors the app's close-to-tray behaviour +/// (see the `CloseRequested` handler in `lib.rs`): we `hide()` the window rather +/// than exiting, so the tray keeps the app running and the tray menu remains the +/// single explicit quit path. +#[tauri::command] +pub fn window_close(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } +} diff --git a/src-tauri/src/native/jumplist.rs b/src-tauri/src/native/jumplist.rs new file mode 100644 index 0000000..d33a79c --- /dev/null +++ b/src-tauri/src/native/jumplist.rs @@ -0,0 +1,143 @@ +//! P5-36 — Windows taskbar Jump List ("Recent Rooms"). +//! +//! Publishes a custom Jump List category so users can right-click the taskbar +//! (or Start) icon and jump straight into a recently-active room. The web client +//! calls `set_jump_list([{ title, uri }])` from `useTauriJumpList` whenever its +//! recent-room list changes; each `uri` is a `matrix:` deep link. +//! +//! Windows: builds an `ICustomDestinationList` with an `IObjectCollection` of +//! `IShellLinkW` task links. Each link relaunches the current executable with the +//! room's `matrix:` URI as its single argument — the existing deep-link handler +//! in lib.rs (`forward_deeplink` → `lotus-deeplink`) then routes it to the room. +//! The link's visible label is set via `IPropertyStore` + `PKEY_Title` +//! (System.Title) using a `PROPVARIANT`. +//! +//! COM here runs on the command's (thread-pool) thread, so we initialize an STA +//! apartment with `CoInitializeEx` and balance it with `CoUninitialize` only when +//! we were the ones that initialized it (mirrors the COM usage in +//! `set_badge_count`). All COM interfaces are scoped so they release before the +//! apartment is torn down. +//! +//! Other platforms are a no-op (macOS has no direct equivalent; Linux desktop +//! files differ) — the command stays cross-platform so the web side is +//! unconditional. + +use tauri::AppHandle; + +/// One Jump List entry supplied by the web client. `uri` is a `matrix:` deep +/// link accepted by the deep-link handler in lib.rs. +#[derive(serde::Deserialize)] +pub struct JumpItem { + pub title: String, + pub uri: String, +} + +#[tauri::command] +pub fn set_jump_list(app: AppHandle, items: Vec) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + // `app` is unused on Windows (COM runs on the calling thread); bind it so + // the signature stays identical cross-platform and no warning fires. + let _ = &app; + + use std::os::windows::ffi::OsStrExt; + use windows::{ + core::{w, Interface, HSTRING, PCWSTR, PROPVARIANT}, + Win32::{ + System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, + }, + UI::Shell::{ + DestinationList, EnumerableObjectCollection, ICustomDestinationList, + IObjectArray, IObjectCollection, IShellLinkW, ShellLink, + PropertiesSystem::{IPropertyStore, PKEY_Title}, + }, + }, + }; + + // Wide, NUL-terminated path to the running executable; reused for every + // link's target and icon. Computed before touching COM so a failure here + // doesn't leak an initialized apartment. + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let exe_wide: Vec = exe + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // STA is required for the shell Jump List objects. S_OK means we + // initialized (and must uninitialize); S_FALSE means it was already + // initialized on this thread (still balance it); RPC_E_CHANGED_MODE (an + // error) means don't touch it. Note: `unsafe` does not reach into the + // closure below, so its body carries its own `unsafe` block; the COM + // interfaces it creates are all released (dropped) before we uninitialize. + let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + + let result = (|| -> windows::core::Result<()> { + unsafe { + let list: ICustomDestinationList = + CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?; + + if items.is_empty() { + // Nothing to show — clear any list we previously published. + list.DeleteList(PCWSTR::null())?; + return Ok(()); + } + + // BeginList hands back the items the user manually removed; we + // don't re-add anything, so we can ignore it. `min_slots` is the + // max entries the shell will display. + let mut min_slots: u32 = 0; + let _removed: IObjectArray = list.BeginList(&mut min_slots)?; + + let collection: IObjectCollection = + CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?; + + for item in &items { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + + // Relaunch this exe with the matrix: URI as its only argument. + link.SetPath(PCWSTR(exe_wide.as_ptr()))?; + let arg_wide: Vec = + item.uri.encode_utf16().chain(std::iter::once(0)).collect(); + link.SetArguments(PCWSTR(arg_wide.as_ptr()))?; + // Use the app's own icon for the entry. + link.SetIconLocation(PCWSTR(exe_wide.as_ptr()), 0)?; + + // The visible label comes from System.Title on the link's + // property store (a bare IShellLink has no display name). + let store: IPropertyStore = link.cast()?; + let title = PROPVARIANT::from(&HSTRING::from(item.title.as_str())); + store.SetValue(&PKEY_Title, &title)?; + store.Commit()?; + + collection.AddObject(&link)?; + } + + let array: IObjectArray = collection.cast()?; + list.AppendCategory(w!("Recent Rooms"), &array)?; + list.CommitList()?; + Ok(()) + } + })(); + + // All interfaces above are dropped (released) by the time we get here, so + // it's safe to tear the apartment down. + if hr.is_ok() { + unsafe { CoUninitialize() }; + } + + result.map_err(|e| e.to_string())?; + } + + #[cfg(not(target_os = "windows"))] + { + // No-op on non-Windows platforms (see module docs). Bind the args so the + // signature stays identical cross-platform and no unused warnings fire. + let _ = (&app, &items); + } + + Ok(()) +} diff --git a/src-tauri/src/native/mod.rs b/src-tauri/src/native/mod.rs new file mode 100644 index 0000000..fb2d6b3 --- /dev/null +++ b/src-tauri/src/native/mod.rs @@ -0,0 +1,39 @@ +//! Native desktop feature modules (Lotus Chat). +//! +//! Each feature lives in its own submodule exposing `#[tauri::command]`(s) and, +//! when it needs to register listeners/state, a `setup(&AppHandle)`. lib.rs adds +//! the commands to `generate_handler!` and calls `native::setup()` once during +//! app setup. Windows-only pieces are guarded with `#[cfg(target_os = "windows")]` +//! and compile-verified in CI (Gitea `windows` runner / GitHub `windows-latest`). + +use tauri::{AppHandle, Manager}; + +pub mod chrome; +pub mod jumplist; +pub mod network; +pub mod power; +pub mod smtc; +pub mod thumbbar; + +/// Dispatch a DOM `CustomEvent` to the web client (mirrors `forward_deeplink` in +/// lib.rs) so native modules can push data to the frontend without pulling in +/// `@tauri-apps/api` on the web side. `detail_json` MUST be valid JSON (use +/// `serde_json::to_string`). `event` is a static, trusted name. +#[allow(dead_code)] +pub fn emit_to_web(app: &AppHandle, event: &str, detail_json: &str) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.eval(&format!( + "window.dispatchEvent(new CustomEvent('{event}',{{detail:{detail_json}}}))" + )); + } +} + +/// Called once from lib.rs `.setup()`. Feature modules that need to register OS +/// listeners or managed state get initialized here. (power/jumplist/chrome are +/// command-only and need no setup.) +pub fn setup(app: &AppHandle) -> tauri::Result<()> { + thumbbar::setup(app)?; + smtc::setup(app)?; + network::setup(app)?; + Ok(()) +} diff --git a/src-tauri/src/native/network.rs b/src-tauri/src/native/network.rs new file mode 100644 index 0000000..fea3df2 --- /dev/null +++ b/src-tauri/src/native/network.rs @@ -0,0 +1,109 @@ +//! P5-49 — Network awareness (Windows connectivity / NCSI). +//! +//! Proactively detects when the machine gains or loses internet connectivity so +//! the web client can surface an offline state and, more importantly, nudge the +//! matrix client to retry its backed-off `/sync` the instant the network comes +//! back instead of waiting out the sync-loop backoff timer. +//! +//! Windows: a lightweight background thread polls the Network List Manager +//! (`INetworkListManager::IsConnectedToInternet`, the same NCSI signal the shell +//! uses) every ~3 seconds. We prefer a robust poll over a COM event sink +//! (`INetworkEvents`) — the poll is far simpler to reason about, needs no +//! connection-point plumbing, and a 3s cadence is more than responsive enough +//! for a "retry sync now" hint. We emit **only on a state transition**, so the +//! web side gets one event per change rather than a steady heartbeat. +//! +//! Other platforms are a no-op: the browser already fires `online`/`offline` +//! events, and the desktop shells (macOS/Linux) can adopt their own reachability +//! APIs later; the web hook stays unconditional so nothing there needs guarding. + +use tauri::AppHandle; + +/// Payload for the `network-changed` DOM event (`{ online: bool }`). +#[cfg(target_os = "windows")] +#[derive(serde::Serialize)] +struct NetworkState { + online: bool, +} + +/// Called once from lib.rs `native::setup()`. On Windows, spawns the poll +/// thread; elsewhere it does nothing. +pub fn setup(app: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "windows")] + { + // Own a handle inside the thread so the poll outlives this call and runs + // for the lifetime of the app. + let app = app.clone(); + std::thread::spawn(move || watch_network(app)); + } + + #[cfg(not(target_os = "windows"))] + { + // No-op on non-Windows platforms (see module docs). Bind the arg so the + // signature stays identical cross-platform with no unused warning. + let _ = app; + } + + Ok(()) +} + +/// Poll loop, runs on its own thread for the app's lifetime. +#[cfg(target_os = "windows")] +fn watch_network(app: AppHandle) { + use std::time::Duration; + use windows::Win32::Networking::NetworkListManager::{INetworkListManager, NetworkListManager}; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_MULTITHREADED, + }; + + // Initialize COM for this thread in the multithreaded apartment. This is a + // dedicated thread, so it should be the first to init and succeed (S_FALSE — + // "already initialized, same mode" — also counts as success). If it fails + // outright (e.g. RPC_E_CHANGED_MODE) we can't proceed, so bail. + // Safety: FFI call; `None` reserved param per the API contract. + if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() { + return; + } + + // Create the Network List Manager COM object (mirrors the `CoCreateInstance` + // idiom in lib.rs `set_badge_count`). If it's unavailable, tear COM back down + // and stop the thread cleanly. + // Safety: standard COM instantiation; type is inferred from the annotation. + let manager: INetworkListManager = + match unsafe { CoCreateInstance(&NetworkListManager, None, CLSCTX_INPROC_SERVER) } { + Ok(manager) => manager, + Err(_) => { + // Safety: balances the successful CoInitializeEx above. + unsafe { CoUninitialize() }; + return; + } + }; + + // `None` = unknown; the first successful read is treated as a transition so + // the web side always learns the initial connectivity state. + let mut last: Option = None; + + loop { + // `IsConnectedToInternet` yields a VARIANT_BOOL (VARIANT_TRUE == -1 when + // connected). Skip transient read errors without emitting. + // Safety: FFI call on a live COM interface owned by this thread. + if let Ok(connected) = unsafe { manager.IsConnectedToInternet() } { + let online = connected.as_bool(); + if last != Some(online) { + last = Some(online); + super::emit_to_web( + &app, + "network-changed", + &serde_json::to_string(&NetworkState { online }).unwrap_or_default(), + ); + } + } + + std::thread::sleep(Duration::from_secs(3)); + } + + // Note: the loop never returns, so we intentionally don't call + // `CoUninitialize` here — COM stays initialized for this thread until the + // process exits, which is exactly the desired lifetime. +} diff --git a/src-tauri/src/native/power.rs b/src-tauri/src/native/power.rs new file mode 100644 index 0000000..f660ab9 --- /dev/null +++ b/src-tauri/src/native/power.rs @@ -0,0 +1,48 @@ +//! P5-46 — System power management (call continuity). +//! +//! Prevents the system from sleeping / turning off the display while a voice or +//! video call is active, then releases the request when the call ends. The web +//! client calls `set_call_active(true|false)` from `useTauriCallPower` as the +//! call-embed atom transitions. +//! +//! Windows: `SetThreadExecutionState`. The request is per-thread and persists +//! until cleared, so we run every set/clear on the **main thread** (via +//! `run_on_main_thread`) to guarantee the clear cancels the matching set even +//! though Tauri commands otherwise run on a pool thread. +//! +//! Other platforms are a no-op for now (macOS would use `IOPMAssertionCreate`, +//! Linux `org.freedesktop.ScreenSaver`/`login1` inhibit) — tracked as a future +//! extension; the command stays cross-platform so the web side is unconditional. + +use tauri::AppHandle; + +#[tauri::command] +pub fn set_call_active(app: AppHandle, active: bool) { + #[cfg(target_os = "windows")] + { + let _ = app.run_on_main_thread(move || { + use windows::Win32::System::Power::{ + SetThreadExecutionState, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, + }; + let flags = if active { + ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED + } else { + // Clearing to ES_CONTINUOUS alone releases the sleep/display + // requirement while leaving no lingering per-thread state. + ES_CONTINUOUS + }; + // Safety: FFI call with no pointers; returns the previous state, + // which we don't need. + unsafe { + SetThreadExecutionState(flags); + } + }); + } + + #[cfg(not(target_os = "windows"))] + { + // No-op on non-Windows platforms (see module docs). Bind the args so the + // signature stays identical cross-platform and no unused warnings fire. + let _ = (&app, active); + } +} diff --git a/src-tauri/src/native/smtc.rs b/src-tauri/src/native/smtc.rs new file mode 100644 index 0000000..84e77fd --- /dev/null +++ b/src-tauri/src/native/smtc.rs @@ -0,0 +1,189 @@ +//! P5-43 — System Media Transport Controls (SMTC) call surface (Windows). +//! +//! Surfaces the active voice/video call to the Windows media overlay (the +//! volume flyout / SMTC card) so the user can mute or hang up from the OS +//! media controls. We create the SMTC via the WinRT interop factory +//! (`ISystemMediaTransportControlsInterop::GetForWindow`), keep the object in +//! managed state, and let the web client drive its state through +//! `set_smtc_call_state` as the call-embed atom / mic state changes. +//! +//! Button mapping: **Play/Pause → mute toggle**, **Stop → end call**. Presses +//! are forwarded to the web client as a `smtc-action` DOM CustomEvent (see +//! `super::emit_to_web`) with `action` in `"mute" | "end"`; the web hook +//! (`useTauriSmtc`) translates them into `CallControl.toggleMicrophone()` / +//! `CallEmbed.hangup()`. +//! +//! RUNTIME NOTE: SMTC is designed for real media apps. For a non-media app the +//! card may not actually appear unless the process owns an active audio session +//! recognised by the system. This module prioritises a clean compile and +//! correct WinRT API usage; visibility of the overlay at runtime is uncertain +//! and may depend on the embedded Element Call iframe holding an audio session. +//! +//! Other platforms are a no-op (SMTC is Windows-only); the command keeps an +//! identical cross-platform signature so the web side stays unconditional. + +use tauri::AppHandle; + +/// Payload for the `smtc-action` DOM event forwarded to the web client. +#[cfg(target_os = "windows")] +#[derive(serde::Serialize)] +struct Ev { + action: String, +} + +/// Holds the SMTC object (and its `ButtonPressed` registration token) in Tauri +/// managed state so `set_smtc_call_state` can update it at runtime. Mirrors the +/// `TrayUnreadState` managed-state pattern in lib.rs. +#[cfg(target_os = "windows")] +struct SmtcState { + controls: std::sync::Mutex>, + // Kept alive so the ButtonPressed handler stays registered for the app's + // lifetime; never unregistered. + _token: windows::Foundation::EventRegistrationToken, +} + +/// Called once from `native::setup()`. Creates and configures the SMTC on +/// Windows; no-op elsewhere. SMTC init failures are logged and swallowed so a +/// missing/unsupported overlay never blocks app startup. +pub fn setup(app: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "windows")] + { + if let Err(err) = init_smtc(app) { + eprintln!("smtc: failed to initialize System Media Transport Controls: {err:?}"); + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = app; + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn init_smtc(app: &AppHandle) -> Result<(), Box> { + use tauri::Manager; + use windows::core::{factory, HSTRING}; + use windows::Foundation::TypedEventHandler; + use windows::Media::{ + MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControls, + SystemMediaTransportControlsButton, SystemMediaTransportControlsButtonPressedEventArgs, + }; + use windows::Win32::Foundation::HWND; + use windows::Win32::System::WinRT::ISystemMediaTransportControlsInterop; + + let window = app + .get_webview_window("main") + .ok_or("smtc: main window not found")?; + // Match the HWND conversion used by set_badge_count in lib.rs. + let hwnd = HWND(window.hwnd()?.0 as _); + + // SMTC has no WinRT constructor; it's obtained per-window via the interop + // factory. `factory::()` fetches the activation factory for the + // runtime class `C` cast to the classic COM interop interface `I`. + let interop = + factory::()?; + let controls: SystemMediaTransportControls = unsafe { interop.GetForWindow(hwnd)? }; + + controls.SetIsEnabled(true)?; + controls.SetIsPlayEnabled(true)?; + controls.SetIsPauseEnabled(true)?; + controls.SetIsStopEnabled(true)?; + + // Configure the card metadata once ("In call"); the web side only toggles + // playback status afterwards. + let updater = controls.DisplayUpdater()?; + updater.SetType(MediaPlaybackType::Music)?; + let music = updater.MusicProperties()?; + music.SetTitle(&HSTRING::from("In call"))?; + updater.Update()?; + + // Idle until a call becomes active (set_smtc_call_state flips this). + controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?; + + // ButtonPressed → forward a normalized action to the web client. + let app_for_handler = app.clone(); + let handler = TypedEventHandler::< + SystemMediaTransportControls, + SystemMediaTransportControlsButtonPressedEventArgs, + >::new(move |_sender, args| { + if let Some(args) = args.as_ref() { + let button = args.Button()?; + let action = if button == SystemMediaTransportControlsButton::Play + || button == SystemMediaTransportControlsButton::Pause + { + Some("mute") + } else if button == SystemMediaTransportControlsButton::Stop { + Some("end") + } else { + None + }; + if let Some(action) = action { + let payload = serde_json::to_string(&Ev { + action: action.to_string(), + }) + .unwrap_or_default(); + super::emit_to_web(&app_for_handler, "smtc-action", &payload); + } + } + Ok(()) + }); + let token = controls.ButtonPressed(&handler)?; + + app.manage(SmtcState { + controls: std::sync::Mutex::new(Some(controls)), + _token: token, + }); + + Ok(()) +} + +/// Reflect the call state onto the SMTC. When `active`, enable the controls and +/// set playback status to Playing (unmuted) / Paused (muted); when inactive, +/// mark the card Closed and disable it. Windows-only; no-op elsewhere. +#[tauri::command] +pub fn set_smtc_call_state(app: AppHandle, active: bool, muted: bool) { + #[cfg(target_os = "windows")] + { + use tauri::Manager; + if let Some(state) = app.try_state::() { + if let Ok(guard) = state.controls.lock() { + if let Some(controls) = guard.as_ref() { + let _ = apply_call_state(controls, active, muted); + } + } + } + } + + #[cfg(not(target_os = "windows"))] + { + // No-op off Windows; bind args so the signature is identical everywhere + // and no unused warnings fire. + let _ = (&app, active, muted); + } +} + +#[cfg(target_os = "windows")] +fn apply_call_state( + controls: &windows::Media::SystemMediaTransportControls, + active: bool, + muted: bool, +) -> windows::core::Result<()> { + use windows::Media::MediaPlaybackStatus; + + if active { + controls.SetIsEnabled(true)?; + let status = if muted { + MediaPlaybackStatus::Paused + } else { + MediaPlaybackStatus::Playing + }; + controls.SetPlaybackStatus(status)?; + } else { + controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?; + controls.SetIsEnabled(false)?; + } + + Ok(()) +} diff --git a/src-tauri/src/native/thumbbar.rs b/src-tauri/src/native/thumbbar.rs new file mode 100644 index 0000000..21f1e15 --- /dev/null +++ b/src-tauri/src/native/thumbbar.rs @@ -0,0 +1,379 @@ +//! P5-44 — Taskbar thumbnail toolbar (call controls). +//! +//! While a voice/video call is active the web client calls `set_thumbbar` from +//! `useTauriThumbbar`, which mirrors the call-embed atom + mic/sound state onto +//! three buttons on the taskbar thumbnail toolbar: **Mute/Unmute**, +//! **Deafen/Undeafen** and **End Call**. Clicking a button pushes a +//! `thumbbar-action` DOM event back to the web side (`"mute" | "deafen" | "end"`) +//! which drives the real call controls. +//! +//! Windows: `ITaskbarList3::ThumbBarAddButtons` (first call for the window) then +//! `ThumbBarUpdateButtons` (subsequent calls) — mirrors the COM + GDI/HICON idiom +//! in `set_badge_count`. Thumb-button clicks arrive as `WM_COMMAND` with +//! `HIWORD(wParam) == THBN_CLICKED`, so we subclass the main window (installed +//! once in `setup`) to catch them. The main window HWND comes from the "main" +//! webview window; the "buttons added" flag lives in managed `ThumbbarState` +//! (like lib.rs's `TrayUnreadState`) so add-vs-update works across calls. +//! +//! Other platforms are a no-op — the command stays cross-platform so the web +//! side is unconditional. + +use tauri::{AppHandle, Manager}; + +/// Managed state shared with lib.rs (registered in `setup`). Only the Windows +/// path reads `added`; kept cross-platform so `set_thumbbar` can inject it. +#[derive(Default)] +pub struct ThumbbarState { + #[allow(dead_code)] + added: std::sync::atomic::AtomicBool, +} + +// Thumb-button ids (LOWORD of wParam on WM_COMMAND / THBN_CLICKED). +#[cfg(target_os = "windows")] +const BTN_MUTE: u32 = 1; +#[cfg(target_os = "windows")] +const BTN_DEAFEN: u32 = 2; +#[cfg(target_os = "windows")] +const BTN_END: u32 = 3; + +/// HIWORD(wParam) value on a thumb-button click (CommCtrl `THBN_CLICKED`). +#[cfg(target_os = "windows")] +const THBN_CLICKED: u16 = 0x1800; + +/// uIdSubclass passed to SetWindowSubclass — identifies our subclass instance. +#[cfg(target_os = "windows")] +const SUBCLASS_ID: usize = 1; + +/// Payload emitted to the web on a thumb-button click. +#[cfg(target_os = "windows")] +#[derive(serde::Serialize)] +struct Action<'a> { + action: &'a str, +} + +/// Build a single THUMBBUTTON, attaching an icon (and the THB_ICON mask) when one +/// was created. Always carries a tooltip and enabled/hidden flags. +#[cfg(target_os = "windows")] +fn thumb_button( + id: u32, + hidden: bool, + tip: &str, + icon: Option, +) -> windows::Win32::UI::Shell::THUMBBUTTON { + use windows::Win32::UI::Shell::{ + THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBF_ENABLED, THBF_HIDDEN, THB_FLAGS, + THB_ICON, THB_TOOLTIP, + }; + use windows::Win32::UI::WindowsAndMessaging::HICON; + + let mut mask: THUMBBUTTONMASK = THB_TOOLTIP | THB_FLAGS; + let flags: THUMBBUTTONFLAGS = if hidden { THBF_HIDDEN } else { THBF_ENABLED }; + let mut hicon = HICON::default(); + if let Some(i) = icon { + mask = mask | THB_ICON; + hicon = i; + } + let mut sz_tip = [0u16; 260]; + for (dst, ch) in sz_tip.iter_mut().zip(tip.encode_utf16().take(259)) { + *dst = ch; + } + THUMBBUTTON { + dwMask: mask, + iId: id, + iBitmap: 0, + hIcon: hicon, + szTip: sz_tip, + dwFlags: flags, + } +} + +/// Update (or hide) the three thumb-toolbar buttons for the given call state. +#[tauri::command] +pub fn set_thumbbar( + app: AppHandle, + state: tauri::State<'_, ThumbbarState>, + active: bool, + muted: bool, + deafened: bool, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::sync::atomic::Ordering; + use windows::Win32::{ + Foundation::HWND, + System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER}, + UI::{ + Shell::{ITaskbarList3, TaskbarList}, + WindowsAndMessaging::DestroyIcon, + }, + }; + + // Nothing to do (and nothing to hide) if a toolbar was never added. + if !active && !state.added.load(Ordering::SeqCst) { + return Ok(()); + } + + let window = app + .get_webview_window("main") + .ok_or_else(|| "no main window".to_string())?; + let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _); + + let mic_icon = make_icon(Glyph::Mic, muted); + let deaf_icon = make_icon(Glyph::Head, deafened); + let end_icon = make_icon(Glyph::End, false); + + let buttons = [ + thumb_button(BTN_MUTE, !active, if muted { "Unmute" } else { "Mute" }, mic_icon), + thumb_button( + BTN_DEAFEN, + !active, + if deafened { "Undeafen" } else { "Deafen" }, + deaf_icon, + ), + thumb_button(BTN_END, !active, "End Call", end_icon), + ]; + + let result = unsafe { + let taskbar: ITaskbarList3 = CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER) + .map_err(|e| e.to_string())?; + taskbar.HrInit().map_err(|e| e.to_string())?; + + let r = if state.added.load(Ordering::SeqCst) { + taskbar.ThumbBarUpdateButtons(hwnd, &buttons) + } else { + let r = taskbar.ThumbBarAddButtons(hwnd, &buttons); + if r.is_ok() { + state.added.store(true, Ordering::SeqCst); + } + r + }; + r.map_err(|e| e.to_string()) + }; + + // The shell copies the icons on add/update, so release ours (mirrors the + // DestroyIcon after SetOverlayIcon in set_badge_count). + for icon in [mic_icon, deaf_icon, end_icon].into_iter().flatten() { + unsafe { + let _ = DestroyIcon(icon); + } + } + + result?; + } + + #[cfg(not(target_os = "windows"))] + { + // No-op elsewhere; bind args so the signature stays identical and no + // unused warnings fire. + let _ = (&app, &state, active, muted, deafened); + } + + Ok(()) +} + +/// Which glyph a thumb-button icon draws. +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +enum Glyph { + Mic, + Head, + End, +} + +/// Draw a simple white monochrome glyph onto a 32x32 32-bpp DIB and wrap it in an +/// `HICON`. Mirrors the CreateDIBSection → alpha-fixup → CreateIconIndirect idiom +/// in `set_badge_count`. Returns `None` on any GDI failure (the button is then +/// added tooltip-only). `slashed` overlays a transparent diagonal cut to signal +/// the muted / deafened state. +#[cfg(target_os = "windows")] +fn make_icon(glyph: Glyph, slashed: bool) -> Option { + use windows::core::BOOL; + use windows::Win32::Foundation::COLORREF; + use windows::Win32::Graphics::Gdi::{ + Arc, CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreatePen, CreateSolidBrush, + DeleteDC, DeleteObject, Ellipse, GetDC, LineTo, MoveToEx, ReleaseDC, RoundRect, + SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, PS_SOLID, + }; + use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO}; + + unsafe { + let size = 32i32; + let hdc_screen = GetDC(None); + let hdc = CreateCompatibleDC(Some(hdc_screen)); + + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() 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(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0).ok()?; + 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 white = COLORREF(0x00FF_FFFF); + let hbrush = CreateSolidBrush(white); + let old_brush = SelectObject(hdc, hbrush.into()); + let hpen = CreatePen(PS_SOLID, 2, white); + let old_pen = SelectObject(hdc, hpen.into()); + + match glyph { + Glyph::Mic => { + // Capsule mic head + stand. + let _ = RoundRect(hdc, 13, 5, 19, 19, 6, 6); + let _ = MoveToEx(hdc, 16, 19, None); + let _ = LineTo(hdc, 16, 25); + let _ = MoveToEx(hdc, 11, 25, None); + let _ = LineTo(hdc, 21, 25); + } + Glyph::Head => { + // Headphone band + two ear cups. + let _ = Arc(hdc, 6, 7, 26, 27, 6, 17, 26, 17); + let _ = RoundRect(hdc, 6, 16, 11, 26, 2, 2); + let _ = RoundRect(hdc, 21, 16, 26, 26, 2, 2); + } + Glyph::End => { + // Filled disc (end-call button). + let _ = Ellipse(hdc, 6, 6, 26, 26); + } + } + + if slashed { + // Draw the slash in black (pixel 0), which the alpha fixup below + // leaves fully transparent — carving a visible diagonal gap. + let black_pen = CreatePen(PS_SOLID, 4, COLORREF(0)); + let prev = SelectObject(hdc, black_pen.into()); + let _ = MoveToEx(hdc, 6, 6, None); + let _ = LineTo(hdc, 26, 26); + SelectObject(hdc, prev); + let _ = DeleteObject(black_pen.into()); + } + + SelectObject(hdc, old_brush); + SelectObject(hdc, old_pen); + SelectObject(hdc, old_bm); + let _ = DeleteObject(hbrush.into()); + let _ = DeleteObject(hpen.into()); + + // GDI leaves alpha at 0; mark every painted pixel opaque so Windows uses + // per-pixel alpha instead of the opaque mask (same fix as set_badge_count). + 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 None; + } + + let icon_info = ICONINFO { + fIcon: BOOL(1), + xHotspot: 0, + yHotspot: 0, + hbmMask: hbm_mask, + hbmColor: hbm_color, + }; + let hicon = CreateIconIndirect(&icon_info).ok(); + + let _ = DeleteObject(hbm_color.into()); + let _ = DeleteObject(hbm_mask.into()); + let _ = DeleteDC(hdc); + let _ = ReleaseDC(None, hdc_screen); + + hicon + } +} + +/// Window subclass proc: catches thumb-button clicks (`WM_COMMAND` / +/// `THBN_CLICKED`) and forwards them to the web as `thumbbar-action`. `dwrefdata` +/// is a leaked `Box` installed by `setup`; it is reclaimed on +/// `WM_NCDESTROY`. +#[cfg(target_os = "windows")] +unsafe extern "system" fn subclass_proc( + hwnd: windows::Win32::Foundation::HWND, + umsg: u32, + wparam: windows::Win32::Foundation::WPARAM, + lparam: windows::Win32::Foundation::LPARAM, + _uidsubclass: usize, + dwrefdata: usize, +) -> windows::Win32::Foundation::LRESULT { + use windows::Win32::Foundation::LRESULT; + use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass}; + use windows::Win32::UI::WindowsAndMessaging::{WM_COMMAND, WM_NCDESTROY}; + + match umsg { + WM_COMMAND => { + let w = wparam.0; + let notif = ((w >> 16) & 0xFFFF) as u16; + let id = (w & 0xFFFF) as u32; + if notif == THBN_CLICKED { + let action = match id { + BTN_MUTE => Some("mute"), + BTN_DEAFEN => Some("deafen"), + BTN_END => Some("end"), + _ => None, + }; + if let Some(action) = action { + if dwrefdata != 0 { + // Borrow (do not take ownership of) the leaked AppHandle. + let app = &*(dwrefdata as *const AppHandle); + let detail = + serde_json::to_string(&Action { action }).unwrap_or_default(); + super::emit_to_web(app, "thumbbar-action", &detail); + } + return LRESULT(0); + } + } + DefSubclassProc(hwnd, umsg, wparam, lparam) + } + WM_NCDESTROY => { + let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID); + if dwrefdata != 0 { + drop(Box::from_raw(dwrefdata as *mut AppHandle)); + } + DefSubclassProc(hwnd, umsg, wparam, lparam) + } + _ => DefSubclassProc(hwnd, umsg, wparam, lparam), + } +} + +/// Called once from `native::setup`. Registers `ThumbbarState` and, on Windows, +/// subclasses the main window so thumb-button clicks reach the web client. +pub fn setup(app: &AppHandle) -> tauri::Result<()> { + app.manage(ThumbbarState::default()); + + #[cfg(target_os = "windows")] + { + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::Shell::SetWindowSubclass; + + if let Some(window) = app.get_webview_window("main") { + if let Ok(handle) = window.hwnd() { + let hwnd = HWND(handle.0 as _); + // Leak an AppHandle for the proc; reclaimed on WM_NCDESTROY. + let refdata = Box::into_raw(Box::new(app.clone())) as usize; + unsafe { + let _ = SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, refdata); + } + } + } + } + + Ok(()) +}