diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index 7b5f5c5..4f926fe 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -29,6 +29,7 @@ fn main() {
"set_custom_chrome",
"set_global_hotkeys",
"set_jump_list",
+ "set_overlay_state",
"set_smtc_call_state",
"set_start_minimized",
"set_taskbar_progress",
diff --git a/src-tauri/capabilities/migrated.json b/src-tauri/capabilities/migrated.json
index 45f18bc..2ec0802 100644
--- a/src-tauri/capabilities/migrated.json
+++ b/src-tauri/capabilities/migrated.json
@@ -84,6 +84,7 @@
"allow-set-custom-chrome",
"allow-set-global-hotkeys",
"allow-set-jump-list",
+ "allow-set-overlay-state",
"allow-set-smtc-call-state",
"allow-set-start-minimized",
"allow-set-taskbar-progress",
diff --git a/src-tauri/overlay/index.html b/src-tauri/overlay/index.html
new file mode 100644
index 0000000..be79e7a
--- /dev/null
+++ b/src-tauri/overlay/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Lotus Chat overlay
+
+
+
+
+
+
+
diff --git a/src-tauri/overlay/overlay.css b/src-tauri/overlay/overlay.css
new file mode 100644
index 0000000..d99dbe3
--- /dev/null
+++ b/src-tauri/overlay/overlay.css
@@ -0,0 +1,117 @@
+/* Lotus Chat in-game voice overlay (cinny-desktop #25).
+ Sizes match native/overlay.rs: width 240, padding 8, header 30, rows 30. */
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ overflow: hidden;
+ user-select: none;
+ cursor: default;
+ font: 13px/1.2 "Segoe UI", system-ui, sans-serif;
+ color: #f2f2f2;
+}
+
+.panel {
+ box-sizing: border-box;
+ width: 240px;
+ padding: 8px;
+}
+
+.panel[hidden] {
+ display: none;
+}
+
+.self {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 8px;
+ box-sizing: border-box;
+ border-radius: 15px;
+ background: rgba(18, 18, 24, 0.78);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.badge {
+ padding: 1px 7px;
+ border-radius: 9px;
+ font-size: 11px;
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.badge.off {
+ background: rgba(220, 60, 60, 0.85);
+}
+
+.people {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.person {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 30px;
+ padding: 3px 8px 3px 3px;
+ box-sizing: border-box;
+}
+
+.avatar {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ font-size: 11px;
+ font-weight: 700;
+ color: #fff;
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.45);
+}
+
+.person.speaking .avatar {
+ box-shadow: 0 0 0 2px #3ddc84;
+}
+
+.name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: rgba(18, 18, 24, 0.7);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
+}
+
+.person.speaking .name {
+ color: #fff;
+ background: rgba(18, 18, 24, 0.85);
+}
+
+.person.muted .name {
+ color: rgba(242, 242, 242, 0.6);
+}
+
+.mic {
+ flex: none;
+ font-size: 11px;
+ padding: 1px 6px;
+ border-radius: 8px;
+ background: rgba(220, 60, 60, 0.85);
+}
+
+.more {
+ height: 30px;
+ line-height: 30px;
+ padding-left: 35px;
+ color: rgba(242, 242, 242, 0.75);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
+}
diff --git a/src-tauri/overlay/overlay.js b/src-tauri/overlay/overlay.js
new file mode 100644
index 0000000..e3540d7
--- /dev/null
+++ b/src-tauri/overlay/overlay.js
@@ -0,0 +1,75 @@
+// Lotus Chat in-game voice overlay (cinny-desktop #25).
+//
+// Rust (native/overlay.rs) calls window.lotusOverlay(state, maxRows) with the
+// call snapshot from the web app. Everything is rendered with textContent /
+// createElement, never markup strings, so a display name can't inject HTML.
+'use strict';
+
+(function () {
+ var panel = document.getElementById('panel');
+ var selfRow = document.getElementById('self');
+ var list = document.getElementById('people');
+
+ // Stable colour per user, from the user ID.
+ var COLOURS = ['#7c5cff', '#2f9e8f', '#d9822b', '#c2417c', '#3d7be0', '#5aa02c', '#a35bd6', '#c94f4f'];
+ function colourFor(id) {
+ var h = 0;
+ for (var i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) >>> 0;
+ return COLOURS[h % COLOURS.length];
+ }
+
+ function initial(name) {
+ var trimmed = (name || '').replace(/^[@#!+]/, '').trim();
+ var first = Array.from(trimmed)[0];
+ return first ? first.toUpperCase() : '?';
+ }
+
+ function el(tag, cls, text) {
+ var node = document.createElement(tag);
+ if (cls) node.className = cls;
+ if (text !== undefined) node.textContent = text;
+ return node;
+ }
+
+ function badge(text, off) {
+ return el('span', off ? 'badge off' : 'badge', text);
+ }
+
+ function renderSelf(state) {
+ selfRow.replaceChildren();
+ selfRow.appendChild(el('span', '', 'Lotus voice'));
+ if (state.deafened) selfRow.appendChild(badge('Deafened', true));
+ else if (state.muted) selfRow.appendChild(badge('Muted', true));
+ if (state.ptt) selfRow.appendChild(badge('PTT', false));
+ }
+
+ function renderPerson(p) {
+ var row = el('li', 'person');
+ if (p.speaking) row.classList.add('speaking');
+ if (p.muted) row.classList.add('muted');
+ var avatar = el('span', 'avatar', initial(p.name));
+ avatar.style.background = colourFor(p.userId || p.name || '');
+ row.appendChild(avatar);
+ row.appendChild(el('span', 'name', p.isSelf ? p.name + ' (you)' : p.name));
+ if (p.muted) row.appendChild(el('span', 'mic', 'Muted'));
+ return row;
+ }
+
+ window.lotusOverlay = function (state, maxRows) {
+ if (!state || !state.active) {
+ panel.hidden = true;
+ return;
+ }
+ renderSelf(state);
+ list.replaceChildren();
+ var people = Array.isArray(state.people) ? state.people : [];
+ var limit = typeof maxRows === 'number' ? maxRows : 10;
+ people.slice(0, limit).forEach(function (p) {
+ list.appendChild(renderPerson(p));
+ });
+ if (people.length > limit) {
+ list.appendChild(el('li', 'more', '+' + (people.length - limit) + ' more'));
+ }
+ panel.hidden = false;
+ };
+})();
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 89aadb5..286b885 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -999,6 +999,8 @@ pub fn run() {
}
builder = builder
+ // cinny-desktop #25: the overlay window's static page (no IPC, no network).
+ .register_uri_scheme_protocol(native::overlay::OVERLAY_SCHEME, native::overlay::serve)
.invoke_handler(tauri::generate_handler![
set_badge_count,
has_tray_host,
@@ -1021,6 +1023,7 @@ pub fn run() {
native::power::set_call_active,
native::jumplist::set_jump_list,
native::thumbbar::set_thumbbar,
+ native::overlay::set_overlay_state,
native::smtc::set_smtc_call_state,
native::chrome::set_custom_chrome,
native::chrome::window_minimize,
@@ -1090,6 +1093,9 @@ pub fn run() {
// web side registers bindings on call join). Managed unconditionally
// so the commands can never hit a missing-state panic.
app.manage(native::hotkeys::HotkeyPoll::default());
+ // cinny-desktop #25: in-game voice overlay state (inert until the
+ // web side sends an active call snapshot).
+ app.manage(native::overlay::Overlay::default());
// --- System tray: keeps Lotus Chat running in the background so
// notifications keep arriving after the window is closed-to-tray. ---
diff --git a/src-tauri/src/native/mod.rs b/src-tauri/src/native/mod.rs
index 795ff00..5854fe6 100644
--- a/src-tauri/src/native/mod.rs
+++ b/src-tauri/src/native/mod.rs
@@ -14,6 +14,7 @@ pub mod focus_assist;
pub mod hotkeys;
pub mod jumplist;
pub mod network;
+pub mod overlay;
pub mod power;
pub mod smtc;
pub mod thumbbar;
@@ -44,5 +45,6 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> {
smtc::setup(app)?;
network::setup(app)?;
focus_assist::setup(app)?;
+ overlay::setup(app)?;
Ok(())
}
diff --git a/src-tauri/src/native/overlay.rs b/src-tauri/src/native/overlay.rs
new file mode 100644
index 0000000..5807188
--- /dev/null
+++ b/src-tauri/src/native/overlay.rs
@@ -0,0 +1,572 @@
+//! In-game voice overlay (cinny-desktop #25).
+//!
+//! While you're in a call and another app (a game) is in front, a small
+//! always-on-top, click-through window lists everyone in the call, lights up
+//! whoever is speaking and shows your own mute / deafen / push-to-talk state,
+//! so you never have to alt-tab to see who is talking.
+//!
+//! Design (plan in #25):
+//! - A **separate window**, not code injected into the game: nothing touches
+//! the game process, so anti-cheat has nothing to flag. Exclusive-fullscreen
+//! games draw over every top-most window and won't show it; borderless and
+//! "fullscreen optimised" games (most modern titles) do.
+//! - The window never takes focus: `focusable(false)`, `WS_EX_NOACTIVATE`,
+//! shown with `SW_SHOWNOACTIVATE`, and click-through
+//! (`set_ignore_cursor_events`). Taking focus from a game is the one thing it
+//! must never do.
+//! - It loads a tiny static page served by this binary (`overlay/`, custom
+//! protocol `lotusoverlay`), not the cinny bundle. The page has no IPC (no
+//! capability names the `overlay` window), no network (strict CSP), never
+//! sees the access token, and renders names with `textContent` only. Rust
+//! pushes state into it with `eval` of a JSON literal.
+//! - The web app sends the call snapshot with `set_overlay_state` (cinny
+//! `useTauriOverlay`). The overlay shows when the snapshot is `active` (in a
+//! call, setting on) and the Lotus main window is not focused, on the
+//! monitor of the foreground window, in the chosen corner.
+//!
+//! Windows only for now: Linux has no in-app calls yet (#21) and Wayland has no
+//! standard way to keep a window on top. Elsewhere the command is a no-op.
+
+// The window code is Windows-only; elsewhere the helpers are only exercised by
+// the tests.
+#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
+
+use std::sync::Mutex;
+
+use tauri::{AppHandle, Manager, State};
+
+/// The overlay window's label. No capability lists it, so the page gets no IPC.
+pub const OVERLAY_LABEL: &str = "overlay";
+/// Custom protocol serving the overlay page (`http://lotusoverlay.localhost/`
+/// on Windows).
+pub const OVERLAY_SCHEME: &str = "lotusoverlay";
+/// Most people listed by name; the rest are summed up as "+N more".
+pub const MAX_ROWS: usize = 10;
+/// Cap on what the web side may send (a call can't usefully list more).
+const MAX_PEOPLE: usize = 50;
+/// Display names are cut to this many characters.
+const MAX_NAME_CHARS: usize = 40;
+
+// Layout in logical pixels (the page's CSS uses the same numbers).
+const WIDTH: f64 = 240.0;
+const PAD: f64 = 8.0;
+const HEADER: f64 = 30.0;
+const ROW: f64 = 30.0;
+const MARGIN: f64 = 16.0;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub enum Corner {
+ #[default]
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+}
+
+impl Corner {
+ /// Unknown values fall back to the default (top-left, like Discord).
+ pub fn parse(s: &str) -> Self {
+ match s {
+ "top-right" => Corner::TopRight,
+ "bottom-left" => Corner::BottomLeft,
+ "bottom-right" => Corner::BottomRight,
+ _ => Corner::TopLeft,
+ }
+ }
+}
+
+/// One participant row, as sent by the web side.
+#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default, PartialEq)]
+#[serde(rename_all = "camelCase", default)]
+pub struct OverlayPerson {
+ pub user_id: String,
+ pub name: String,
+ pub speaking: bool,
+ pub muted: bool,
+ pub is_self: bool,
+}
+
+/// The snapshot from cinny's `useTauriOverlay`. `active` is false when the
+/// setting is off or there is no joined call.
+#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default, PartialEq)]
+#[serde(rename_all = "camelCase", default)]
+pub struct OverlayState {
+ pub active: bool,
+ pub corner: String,
+ pub muted: bool,
+ pub deafened: bool,
+ pub ptt: bool,
+ pub people: Vec,
+}
+
+/// Clamp what the web side sent: known corner, bounded list, bounded names.
+pub(crate) fn sanitize(mut state: OverlayState) -> OverlayState {
+ state.people.truncate(MAX_PEOPLE);
+ for p in &mut state.people {
+ if p.name.chars().count() > MAX_NAME_CHARS {
+ p.name = p.name.chars().take(MAX_NAME_CHARS - 1).collect::() + "…";
+ }
+ p.user_id = p.user_id.chars().take(255).collect();
+ }
+ state.corner = match Corner::parse(&state.corner) {
+ Corner::TopLeft => "top-left",
+ Corner::TopRight => "top-right",
+ Corner::BottomLeft => "bottom-left",
+ Corner::BottomRight => "bottom-right",
+ }
+ .to_string();
+ state
+}
+
+/// Shown only during an active call while Lotus itself is not focused.
+pub(crate) fn should_show(state: &OverlayState, main_focused: bool) -> bool {
+ state.active && !main_focused
+}
+
+/// Window size in logical pixels: the self-state header, one row per person
+/// (up to [`MAX_ROWS`]) and a "+N more" row when the list is cut.
+pub(crate) fn logical_size(people: usize) -> (f64, f64) {
+ let rows = people.min(MAX_ROWS) + usize::from(people > MAX_ROWS);
+ (WIDTH, PAD * 2.0 + HEADER + ROW * rows as f64)
+}
+
+/// A monitor work area in physical pixels.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Area {
+ pub x: i32,
+ pub y: i32,
+ pub w: i32,
+ pub h: i32,
+}
+
+/// Top-left position (physical pixels) of a `size` window in `corner` of
+/// `area`, `margin` away from the edges, never outside the area.
+pub(crate) fn place(corner: Corner, area: Area, size: (i32, i32), margin: i32) -> (i32, i32) {
+ let (w, h) = size;
+ let left = area.x + margin;
+ let top = area.y + margin;
+ let right = (area.x + area.w - margin - w).max(area.x);
+ let bottom = (area.y + area.h - margin - h).max(area.y);
+ match corner {
+ Corner::TopLeft => (left, top),
+ Corner::TopRight => (right, top),
+ Corner::BottomLeft => (left, bottom),
+ Corner::BottomRight => (right, bottom),
+ }
+}
+
+/// The script that hands `state` to the page. JSON is a valid JS expression
+/// and serde escapes quotes and control characters, so names can't break out.
+pub(crate) fn render_script(state: &OverlayState) -> String {
+ let json = serde_json::to_string(state).unwrap_or_else(|_| "null".into());
+ format!("window.lotusOverlay&&window.lotusOverlay({json},{MAX_ROWS})")
+}
+
+/// Static files of the overlay page, by request path.
+pub(crate) fn page_asset(path: &str) -> Option<(&'static str, &'static [u8])> {
+ match path {
+ "/" | "/index.html" => Some((
+ "text/html; charset=utf-8",
+ include_bytes!("../../overlay/index.html"),
+ )),
+ "/overlay.css" => Some((
+ "text/css; charset=utf-8",
+ include_bytes!("../../overlay/overlay.css"),
+ )),
+ "/overlay.js" => Some((
+ "text/javascript; charset=utf-8",
+ include_bytes!("../../overlay/overlay.js"),
+ )),
+ _ => None,
+ }
+}
+
+/// Handler for the `lotusoverlay` custom protocol (registered in lib.rs).
+pub fn serve(
+ _ctx: tauri::UriSchemeContext<'_, R>,
+ request: tauri::http::Request>,
+) -> tauri::http::Response> {
+ let builder = tauri::http::Response::builder()
+ .header(
+ "Content-Security-Policy",
+ "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; form-action 'none'",
+ )
+ .header("X-Content-Type-Options", "nosniff");
+ let response = match page_asset(request.uri().path()) {
+ Some((mime, body)) => builder.header("Content-Type", mime).body(body.to_vec()),
+ None => builder.status(404).body(Vec::new()),
+ };
+ response.unwrap_or_else(|_| tauri::http::Response::new(Vec::new()))
+}
+
+/// Managed state: the latest snapshot and whether the main window has focus.
+#[derive(Default)]
+pub struct Overlay {
+ inner: Mutex,
+}
+
+#[derive(Default)]
+struct Inner {
+ state: OverlayState,
+ main_focused: bool,
+}
+
+/// Receive the call snapshot from the web app. Only the main window may call
+/// it (the capability also grants it to the app's own origin only).
+#[tauri::command]
+pub async fn set_overlay_state(
+ app: AppHandle,
+ webview: tauri::Webview,
+ overlay: State<'_, Overlay>,
+ state: OverlayState,
+) -> Result<(), String> {
+ if webview.label() != "main" {
+ return Err("not allowed".into());
+ }
+ let state = sanitize(state);
+ if let Ok(mut inner) = overlay.inner.lock() {
+ if inner.state == state {
+ return Ok(());
+ }
+ inner.state = state;
+ }
+ refresh(&app);
+ Ok(())
+}
+
+/// Track the main window's focus (the overlay hides while Lotus is in front).
+/// The [`Overlay`] state itself is managed in lib.rs before the window exists.
+pub fn setup(app: &AppHandle) -> tauri::Result<()> {
+ if let Some(main) = app.get_webview_window("main") {
+ if let Ok(focused) = main.is_focused() {
+ if let Some(o) = app.try_state::() {
+ if let Ok(mut inner) = o.inner.lock() {
+ inner.main_focused = focused;
+ }
+ }
+ }
+ let handle = app.clone();
+ main.on_window_event(move |event| {
+ if let tauri::WindowEvent::Focused(focused) = event {
+ let changed = handle
+ .try_state::()
+ .and_then(|o| {
+ o.inner.lock().ok().map(|mut inner| {
+ let changed = inner.main_focused != *focused;
+ inner.main_focused = *focused;
+ changed
+ })
+ })
+ .unwrap_or(false);
+ if changed {
+ // Off the event-loop callback: refresh may create the window.
+ let handle = handle.clone();
+ tauri::async_runtime::spawn(async move { refresh(&handle) });
+ }
+ }
+ });
+ }
+ Ok(())
+}
+
+/// Bring the overlay window in line with the latest state.
+fn refresh(app: &AppHandle) {
+ let Some(overlay) = app.try_state::() else {
+ return;
+ };
+ let (state, main_focused) = match overlay.inner.lock() {
+ Ok(inner) => (inner.state.clone(), inner.main_focused),
+ Err(_) => return,
+ };
+
+ #[cfg(target_os = "windows")]
+ windows_impl::apply(app, &state, should_show(&state, main_focused));
+
+ #[cfg(not(target_os = "windows"))]
+ let _ = (app, state, main_focused);
+}
+
+/// Re-send the latest state once the page has loaded (the first push can
+/// arrive before the page's script is ready).
+#[cfg(target_os = "windows")]
+fn push_latest(window: &tauri::WebviewWindow) {
+ if let Some(overlay) = window.app_handle().try_state::() {
+ if let Ok(inner) = overlay.inner.lock() {
+ let _ = window.eval(render_script(&inner.state));
+ }
+ }
+}
+
+#[cfg(target_os = "windows")]
+mod windows_impl {
+ use super::*;
+ use tauri::webview::PageLoadEvent;
+ use tauri::{WebviewUrl, WebviewWindowBuilder};
+ use windows::Win32::Foundation::{HWND, RECT};
+ use windows::Win32::UI::WindowsAndMessaging::{
+ GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, SetWindowLongPtrW, SetWindowPos,
+ ShowWindow, GWL_EXSTYLE, HWND_TOPMOST, SWP_NOACTIVATE, SW_SHOWNOACTIVATE, WS_EX_NOACTIVATE,
+ WS_EX_TOOLWINDOW,
+ };
+
+ pub(super) fn apply(app: &AppHandle, state: &OverlayState, show: bool) {
+ let existing = app.get_webview_window(OVERLAY_LABEL);
+ if !show {
+ if let Some(window) = existing {
+ let _ = window.hide();
+ }
+ return;
+ }
+ let window = match existing {
+ Some(w) => w,
+ None => match create(app) {
+ Some(w) => w,
+ None => return,
+ },
+ };
+ let _ = window.eval(render_script(state));
+ position_and_show(app, &window, state);
+ }
+
+ fn create(app: &AppHandle) -> Option {
+ let url = format!("http://{OVERLAY_SCHEME}.localhost/index.html")
+ .parse()
+ .ok()?;
+ let (w, h) = logical_size(0);
+ let window = WebviewWindowBuilder::new(app, OVERLAY_LABEL, WebviewUrl::CustomProtocol(url))
+ .title("Lotus Chat overlay")
+ .inner_size(w, h)
+ .decorations(false)
+ .transparent(true)
+ .shadow(false)
+ .resizable(false)
+ .always_on_top(true)
+ .skip_taskbar(true)
+ .focusable(false)
+ .focused(false)
+ .visible(false)
+ .on_page_load(|window, payload| {
+ if matches!(payload.event(), PageLoadEvent::Finished) {
+ push_latest(&window);
+ }
+ })
+ .build()
+ .map_err(|e| eprintln!("overlay: could not create window: {e}"))
+ .ok()?;
+ let _ = window.set_ignore_cursor_events(true);
+ if let Ok(handle) = window.hwnd() {
+ let hwnd = HWND(handle.0 as _);
+ // SAFETY: reading and extending this window's own extended style.
+ unsafe {
+ let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
+ SetWindowLongPtrW(
+ hwnd,
+ GWL_EXSTYLE,
+ ex | (WS_EX_NOACTIVATE.0 | WS_EX_TOOLWINDOW.0) as isize,
+ );
+ }
+ }
+ Some(window)
+ }
+
+ /// The work area and scale of the monitor the foreground window is on,
+ /// falling back to the primary monitor.
+ fn target_monitor(app: &AppHandle) -> Option<(Area, f64)> {
+ // SAFETY: plain queries of the foreground window's rectangle.
+ let center = unsafe {
+ let fg = GetForegroundWindow();
+ let mut rect = RECT::default();
+ if fg.0.is_null() || GetWindowRect(fg, &mut rect).is_err() {
+ None
+ } else {
+ Some((
+ f64::from(rect.left + (rect.right - rect.left) / 2),
+ f64::from(rect.top + (rect.bottom - rect.top) / 2),
+ ))
+ }
+ };
+ let monitor = match center {
+ Some((x, y)) => app.monitor_from_point(x, y).ok().flatten(),
+ None => None,
+ }
+ .or_else(|| app.primary_monitor().ok().flatten())?;
+ let wa = monitor.work_area();
+ Some((
+ Area {
+ x: wa.position.x,
+ y: wa.position.y,
+ w: wa.size.width as i32,
+ h: wa.size.height as i32,
+ },
+ monitor.scale_factor(),
+ ))
+ }
+
+ fn position_and_show(app: &AppHandle, window: &tauri::WebviewWindow, state: &OverlayState) {
+ let Some((area, scale)) = target_monitor(app) else {
+ return;
+ };
+ let (lw, lh) = logical_size(state.people.len());
+ let size = ((lw * scale).round() as i32, (lh * scale).round() as i32);
+ let margin = (MARGIN * scale).round() as i32;
+ let (x, y) = place(Corner::parse(&state.corner), area, size, margin);
+ let Ok(handle) = window.hwnd() else {
+ return;
+ };
+ let hwnd = HWND(handle.0 as _);
+ // SAFETY: moving/showing our own window without activating it.
+ unsafe {
+ let _ = SetWindowPos(
+ hwnd,
+ Some(HWND_TOPMOST),
+ x,
+ y,
+ size.0,
+ size.1,
+ SWP_NOACTIVATE,
+ );
+ let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn person(name: &str) -> OverlayPerson {
+ OverlayPerson {
+ user_id: format!("@{name}:x"),
+ name: name.into(),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn shows_only_in_an_active_call_while_lotus_is_not_focused() {
+ let active = OverlayState {
+ active: true,
+ ..Default::default()
+ };
+ assert!(should_show(&active, false));
+ assert!(!should_show(&active, true));
+ assert!(!should_show(&OverlayState::default(), false));
+ }
+
+ #[test]
+ fn corner_parses_with_top_left_fallback() {
+ assert_eq!(Corner::parse("top-right"), Corner::TopRight);
+ assert_eq!(Corner::parse("bottom-left"), Corner::BottomLeft);
+ assert_eq!(Corner::parse("bottom-right"), Corner::BottomRight);
+ assert_eq!(Corner::parse("top-left"), Corner::TopLeft);
+ assert_eq!(Corner::parse("middle"), Corner::TopLeft);
+ assert_eq!(Corner::parse(""), Corner::TopLeft);
+ }
+
+ #[test]
+ fn sanitize_bounds_the_list_names_and_corner() {
+ let long = "x".repeat(100);
+ let state = sanitize(OverlayState {
+ corner: "nowhere".into(),
+ people: (0..80).map(|_| person(&long)).collect(),
+ ..Default::default()
+ });
+ assert_eq!(state.corner, "top-left");
+ assert_eq!(state.people.len(), MAX_PEOPLE);
+ assert_eq!(state.people[0].name.chars().count(), MAX_NAME_CHARS);
+ assert!(state.people[0].name.ends_with('…'));
+ let kept = sanitize(OverlayState {
+ corner: "bottom-right".into(),
+ people: vec![person("Alice")],
+ ..Default::default()
+ });
+ assert_eq!(kept.corner, "bottom-right");
+ assert_eq!(kept.people[0].name, "Alice");
+ }
+
+ #[test]
+ fn size_grows_with_people_and_caps_with_a_more_row() {
+ assert_eq!(logical_size(0), (WIDTH, PAD * 2.0 + HEADER));
+ assert_eq!(logical_size(3), (WIDTH, PAD * 2.0 + HEADER + ROW * 3.0));
+ let capped = PAD * 2.0 + HEADER + ROW * (MAX_ROWS as f64 + 1.0);
+ assert_eq!(logical_size(MAX_ROWS + 1).1, capped);
+ assert_eq!(logical_size(40).1, capped);
+ assert_eq!(logical_size(MAX_ROWS).1, capped - ROW);
+ }
+
+ #[test]
+ fn places_in_each_corner_of_the_work_area() {
+ // A second monitor to the right, with a taskbar at the bottom.
+ let area = Area {
+ x: 1920,
+ y: 0,
+ w: 2560,
+ h: 1400,
+ };
+ let size = (480, 200);
+ assert_eq!(place(Corner::TopLeft, area, size, 32), (1952, 32));
+ assert_eq!(
+ place(Corner::TopRight, area, size, 32),
+ (1920 + 2560 - 32 - 480, 32)
+ );
+ assert_eq!(
+ place(Corner::BottomLeft, area, size, 32),
+ (1952, 1400 - 32 - 200)
+ );
+ assert_eq!(
+ place(Corner::BottomRight, area, size, 32),
+ (1920 + 2560 - 32 - 480, 1400 - 32 - 200)
+ );
+ }
+
+ #[test]
+ fn never_places_outside_a_tiny_area() {
+ let area = Area {
+ x: -800,
+ y: 100,
+ w: 300,
+ h: 150,
+ };
+ let (x, y) = place(Corner::BottomRight, area, (480, 400), 16);
+ assert!(x >= area.x && y >= area.y);
+ }
+
+ #[test]
+ fn script_embeds_names_as_json_not_code() {
+ let state = OverlayState {
+ active: true,
+ people: vec![person("\");alert(1);//\u{2028}")],
+ ..Default::default()
+ };
+ let script = render_script(&state);
+ assert!(script.starts_with("window.lotusOverlay&&window.lotusOverlay({"));
+ assert!(script.ends_with(&format!("}},{MAX_ROWS})")));
+ // The quote is escaped, so the name stays inside its string literal.
+ assert!(script.contains(r#"\");alert(1);//"#));
+ let json = &script["window.lotusOverlay&&window.lotusOverlay(".len()
+ ..script.len() - format!(",{MAX_ROWS})").len()];
+ let back: OverlayState = serde_json::from_str(json).unwrap();
+ assert_eq!(back, state);
+ }
+
+ #[test]
+ fn serves_only_the_page_files() {
+ assert!(page_asset("/").is_some());
+ assert!(page_asset("/index.html").is_some());
+ assert!(page_asset("/overlay.js").is_some());
+ assert!(page_asset("/overlay.css").is_some());
+ assert!(page_asset("/../Cargo.toml").is_none());
+ assert!(page_asset("/secret").is_none());
+ }
+
+ #[test]
+ fn page_references_only_its_own_files() {
+ let html = std::str::from_utf8(page_asset("/").unwrap().1).unwrap();
+ assert!(html.contains(r#"src="/overlay.js""#));
+ assert!(html.contains(r#"href="/overlay.css""#));
+ assert!(!html.contains("http"));
+ let js = std::str::from_utf8(page_asset("/overlay.js").unwrap().1).unwrap();
+ assert!(!js.contains("innerHTML"));
+ assert!(js.contains("window.lotusOverlay"));
+ }
+}