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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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. ---
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user