From 1674fd273fa792d158a4e8e6be39de89a2da5315 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 9 Nov 2026 17:24:01 -0500 Subject: [PATCH] feat(overlay): in-game voice overlay window, Windows (#25) While you're in a call and Lotus isn't the focused window, a small always-on-top, click-through window lists everyone in the call, lights up whoever is speaking, and shows your own mute / deafen / PTT state. - A separate window, nothing injected into the game: no anti-cheat surface. Never takes focus (focusable(false), WS_EX_NOACTIVATE, SW_SHOWNOACTIVATE) and ignores the mouse. - Its page is three static files served by the binary over the `lotusoverlay` custom protocol, with a strict CSP (no network) and no IPC (no capability names the window). Rust pushes state in as a JSON literal; the page renders with textContent only. - New `set_overlay_state` command (main window only) takes the call snapshot from cinny's `useTauriOverlay`. Sanitised: bounded list and names, known corner. Placed in the chosen corner of the foreground window's monitor, DPI-scaled. - Windows only; a no-op elsewhere (Linux has no in-app calls yet, #21). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src-tauri/build.rs | 1 + src-tauri/capabilities/migrated.json | 1 + src-tauri/overlay/index.html | 18 + src-tauri/overlay/overlay.css | 117 ++++++ src-tauri/overlay/overlay.js | 75 ++++ src-tauri/src/lib.rs | 6 + src-tauri/src/native/mod.rs | 2 + src-tauri/src/native/overlay.rs | 572 +++++++++++++++++++++++++++ 8 files changed, 792 insertions(+) create mode 100644 src-tauri/overlay/index.html create mode 100644 src-tauri/overlay/overlay.css create mode 100644 src-tauri/overlay/overlay.js create mode 100644 src-tauri/src/native/overlay.rs 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")); + } +}