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