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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-11-09 17:24:01 -05:00
co-authored by Claude Opus 5.5
parent f59b12276e
commit 1674fd273f
8 changed files with 792 additions and 0 deletions
+1
View File
@@ -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",
+1
View File
@@ -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",
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<!-- Lotus Chat in-game voice overlay (cinny-desktop #25). Served by the desktop
binary over the `lotusoverlay` custom protocol; no IPC, no network. State
arrives via window.lotusOverlay(...) called from Rust (native/overlay.rs). -->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Lotus Chat overlay</title>
<link rel="stylesheet" href="/overlay.css" />
</head>
<body>
<div id="panel" class="panel" hidden>
<div id="self" class="self"></div>
<ul id="people" class="people"></ul>
</div>
<script src="/overlay.js"></script>
</body>
</html>
+117
View File
@@ -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);
}
+75
View File
@@ -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;
};
})();
+6
View File
@@ -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. ---
+2
View File
@@ -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(())
}
+572
View File
@@ -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<OverlayPerson>,
}
/// 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::<String>() + "…";
}
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<R: tauri::Runtime>(
_ctx: tauri::UriSchemeContext<'_, R>,
request: tauri::http::Request<Vec<u8>>,
) -> tauri::http::Response<Vec<u8>> {
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<Inner>,
}
#[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::<Overlay>() {
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::<Overlay>()
.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::<Overlay>() 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::<Overlay>() {
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<tauri::WebviewWindow> {
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);//</script>\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"));
}
}