feat(native): Tier A desktop features (P5-46/36/44/43/49) + window chrome (P5-47)
Adds a `native/` module system (each feature = its own module exposing `#[tauri::command]`s + optional `setup`; `emit_to_web` pushes DOM CustomEvents to the web like `forward_deeplink`). Wired into generate_handler! + native::setup; windows-crate feature union added to Cargo.toml. - power.rs (P5-46): SetThreadExecutionState held on the main thread while a call is active; released on end. Cross-platform (no-op off Windows). - jumplist.rs (P5-36): ICustomDestinationList "Recent Rooms" of IShellLink tasks launching the exe with a matrix: arg (existing deep-link handler opens the room). - thumbbar.rs (P5-44): ITaskbarList3 ThumbBar Mute/Deafen/End (GDI HICONs) + a window subclass catching THBN_CLICKED → emit thumbbar-action. - smtc.rs (P5-43): WinRT SystemMediaTransportControls via GetForWindow; ButtonPressed → smtc-action; call-state command. (Experimental for a non-media app.) - network.rs (P5-49): INetworkListManager poll thread → emit network-changed. - chrome.rs (P5-47): cross-platform window-control commands + set_custom_chrome (set_decorations) for the opt-in TDS titlebar. NOT compile-verified locally (no Rust/Windows toolchain on the dev box) — this is for the CI Windows compile pass (GitHub test.yml / Gitea windows runner). Expect a possible fixup round (windows-crate feature/namespace paths, e.g. subclass APIs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
//! `<TitleBar/>` (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();
|
||||
}
|
||||
}
|
||||
@@ -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<JumpItem>) -> 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<u16> = 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<u16> =
|
||||
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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<bool> = 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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Option<windows::Media::SystemMediaTransportControls>>,
|
||||
// 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<dyn std::error::Error>> {
|
||||
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::<C, I>()` fetches the activation factory for the
|
||||
// runtime class `C` cast to the classic COM interop interface `I`.
|
||||
let interop =
|
||||
factory::<SystemMediaTransportControls, ISystemMediaTransportControlsInterop>()?;
|
||||
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::<SmtcState>() {
|
||||
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(())
|
||||
}
|
||||
@@ -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::WindowsAndMessaging::HICON>,
|
||||
) -> 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<HICON> {
|
||||
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::<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(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<AppHandle>` 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user